Build with it · Beginner
Data, SQL and Getting to the Answer
Draft, open for academic review
Most AI problems turn out to be data problems. This is where you learn to ask a database a question and defend the answer.
Your progress here is recorded once you pass the Building Apps with AI exam.

6 modules. 66 lessons.
Tables, types and the question you are actually asking
12 lessons · 1 h 35
You will be able toSet up a working database on your own machine, read an unfamiliar schema well enough to say what one row of each table means and which columns join to which, and state the grain of any result you are about to compute before you compute it.
- 1.1When a spreadsheet stops working
- 1.2Getting a database onto the machine you have
- 1.3What a column type actually buys you
- 1.4Keys: how a database knows one thing from another
- 1.5Where each fact should live
- 1.6SELECT and WHERE: asking for rows
- 1.7Sorting, limits, and the rows that move between runs
- 1.8CASE, COALESCE and computing a column that does not exist
- 1.9Reading a database somebody else built
- 1.10Grain: the sentence to say before you write the query
- 1.11A right number and a useful number are not the same
- 1.12Asking an AI for SQL without being lied to
An illustrative case, built from documented patternsThe fee sheet that could not be trusted twice
Sunita Deshpande has run Vidyankur for nine years. The centre’s entire record is one spreadsheet named fees_master_FINAL_v4.xlsx: one row per payment, with the student’s name, parent’s phone number, class, batch, month, amount and mode. It has 41,000 rows. It takes fifty seconds to open, and for nine years it has been good enough.
In January three things happen in the same fortnight.
A parent calls to say she has been paying for her son since June and the centre has just sent her a defaulter notice. She is right. Her phone number changed in August; the office updated it in the rows they could find, and missed eleven. The defaulter list is built by matching on phone number, so those eleven payments now belong to nobody.
The second is quieter. Sunita asks her office manager, Faiz, how many students are in Class 10 Maths. He gives her 214. Her senior teacher gives her 227 from the same file an hour later. Neither number is wrong: Faiz counted rows where batch is Class 10 Maths, and the teacher counted rows where batch starts with Class 10, which also catches Class 10 Maths (Evening), a batch someone created in July by typing a new value into the column. Nothing in the file says which of the two is the real batch list, because there is no batch list. There are only whatever strings people have typed.
The third is that Faiz, tidying up, sorts the sheet by amount to find the largest payments and does not extend the selection to the whole row. Two thousand rows now have somebody else’s amount against their name. He notices within a minute and undoes it. Nobody can prove the undo was complete.
Sunita’s nephew, who is finishing a diploma, offers to rebuild the whole thing as a small Postgres database: a students table with a generated id and the phone as a plain column, a batches table with one row per batch, an enrolments table in the middle because a student can be in several batches, and a payments table whose student_id and batch_id point at rows that must exist. He estimates eleven working days, most of it spent deciding what the existing rows mean rather than typing SQL.
Eleven working days lands squarely on the fee drive, which is the fortnight that pays for the year. During the rebuild there is no defaulter list, because the old sheet is frozen and the new tables are not loaded. Faiz cannot write SQL. Sunita cannot write SQL. If the nephew goes back to college in March, the centre owns a database it cannot query, having given up a spreadsheet it could at least look at.
Against that: the fee drive is the exact moment when the defaulter list has to be right. A notice sent to a parent who has paid costs a student. Last year the centre lost four that way, which is about a hundred and forty thousand rupees a year in fees, and Sunita only knows about the four who complained.
There is a third option nobody has costed. Keep the spreadsheet as the working record for this year, and spend two days building only the parts that stop the specific bleeding: a real list of batches, a real list of students with a permanent id written back into the sheet, and a nightly export into a database that answers the two questions (who owes what, and who is in which batch) while all writing continues in the sheet as before.
That option leaves the sheet’s other faults untouched for a year. It also means the same fact lives in two places for a year, which is the problem the rebuild exists to remove.
How it played out
Sunita took the third option, and it half worked. Two days produced a batches table with 31 rows, a students table with 620 rows and a permanent id, and a script that loaded the sheet nightly into Postgres. Assigning a permanent id forced the office to resolve identity by hand for the first time: 620 students in the database against 664 distinct name-and-phone combinations in the sheet, of which 31 were spelling variants, 9 were siblings sharing a parent’s phone number, and 4 were genuinely two people with the same name in the same batch. The nine siblings were the ones that mattered: the defaulter list had been merging them for years, so a family with two children was marked paid when one of them had paid. The batch list found the evening batch and two more nobody had known existed. Faiz learned five queries and stopped asking anyone for counts. The rebuild never happened, and eighteen months later the nightly export is still the only trustworthy record, with the sheet still being edited by four people. Sunita’s own summary: the two days bought the answers, and paid for them with a copy of every fact, which somebody will have to unpick.
Worth arguing about
Faiz and the senior teacher both counted Class 10 Maths and got different numbers. Which of the ideas in this module names the fault, and what would fix it permanently?
One answer
It is a missing controlled vocabulary and a missing table.
batchis a free-text column, so its values are whatever people typed, andClass 10 Maths (Evening)is a batch that exists in the data and nowhere in anyone’s head. The permanent fix is abatchestable with one row per batch and a foreign key from enrolments to it, so a batch that does not exist cannot be typed. The immediate fix isSELECT batch, count(*) FROM sheet GROUP BY batch ORDER BY 1and reading the tail of the list, which is where the variants live.The defaulter list matched on the parent’s phone number. Explain, using the keys lesson, why that was going to fail no matter how careful the office was.
One answer
A phone number is a natural key, and natural keys fail on stability rather than on uniqueness. When the number changes, every row that referenced it must change too, and any row that is missed is silently orphaned, which is exactly what happened to the eleven payments. It also fails on uniqueness here, because siblings share a parent’s number, so two students collapse into one. A surrogate key (a meaningless, permanent id the database generates) carries identity, and the phone number sits beside it as an ordinary column that is allowed to change.
Sunita’s third option leaves the same facts in two places for a year. Under what conditions is that a defensible piece of denormalisation rather than a mess?
One answer
It is defensible when exactly one place writes the derived copy and there is a way to rebuild it from the source of truth. Here the sheet is the system of record and the database is rebuilt from it nightly, so the copy has one writer and can always be regenerated. It stops being defensible the moment anyone starts editing the database directly, because then two writers exist, the copies can disagree, and nobody can say which is real.
Test yourself6 questions on this module
Ade has two rows in
payments: 3,000 naira in cash, and 8,000 naira by transfer.SELECT student_id FROM payments WHERE method = 'cash' AND amount > 5000does not return him. Why not?A report shows the twenty highest classes with
ORDER BY class_level DESC LIMIT 20, and forty students share class 10. Different students appear on different runs. What fixes it?A CASE is meant to label amounts of 20,000 or more as
largeand 5,000 or more asmedium, but the 5,000 branch is written first. What does the query return?SELECT sum(c.credit_limit) FROM customers c JOIN orders o ON o.customer_id = c.idreturns a figure many times the sum of the column incustomers. What happened?In Postgres,
SELECT 0.1::float + 0.2::float = 0.3::floatis false. What is the mechanism?You are given access to an unfamiliar database and asked for last month’s revenue from
orders. Which habit most often prevents a confidently wrong number?
Putting tables together
10 lessons · 1 h 20
You will be able toCombine any set of related tables without inflating a total: diagnose a join key that silently fails to match, recognise and defuse the fan trap between two child tables, choose between a join, a subquery, an EXISTS and a set operation, and generate the rows for periods in which nothing happened.
- 2.1Joins: reason about them, do not memorise them
- 2.2The four joins, and what each one is for
- 2.3When the join key looks right and matches nothing
- 2.4Many-to-many, and the table in the middle
- 2.5Two child tables, one very wrong total
- 2.6Joining a table to itself
- 2.7UNION, INTERSECT and EXCEPT
- 2.8Subqueries: a query inside a query
- 2.9CTEs: naming the steps
- 2.10Generating the rows that are not there
An illustrative case, built from documented patternsTwelve lakh rupees that were never spent
The programme runs 46 sub-centres. Its database has three tables that matter tonight. visits holds one row per patient visit: 214,000 rows for the year. dispensations holds one row per medicine handed out during a visit, with a quantity and a unit cost: 611,000 rows. referrals holds one row per onward referral made during a visit: 38,000 rows.
The funder wants one page: for each sub-centre, the number of visits, the total drug cost, and the number of referrals. Priya, the programme’s data officer, writes what the question asks for.
SELECT v.centre_id,
count(*) AS visits,
sum(d.qty * d.unit_cost) AS drug_cost,
count(r.id) AS referrals
FROM visits v
JOIN dispensations d ON d.visit_id = v.id
LEFT JOIN referrals r ON r.visit_id = v.id
GROUP BY v.centre_id;The total drug cost comes back as 1,84,00,000 rupees. She knows, without checking anything, that the programme’s entire drug budget for the year was 1,72,00,000 and that it underspent. The number is twelve lakh above a ceiling it cannot have crossed.
She checks the row count. visits has 214,000 rows; the joined result has 638,000. Something on the right matched more than once, which she expects: a visit has several dispensations. But the drug cost should still be right, because each dispensation appears once per visit. It is the referrals join that is doing the damage in the other direction, and the drug cost that is inflated by it: a visit with four dispensations and two referrals emits eight rows, so each of the four dispensation rows is counted twice.
It is 8pm. Priya has three options and each costs something.
The first is to pre-aggregate. Two CTEs, one summing dispensations per visit and one counting referrals per visit, then join both summaries to visits one row to one row. It is the correct answer, it is about fifteen minutes of typing, and it needs to be checked afterwards, which is another half hour she has.
The second is faster and is what a colleague suggests on the phone: add DISTINCT. count(DISTINCT r.id) for referrals and count(DISTINCT v.id) for visits both become right immediately. The drug cost does not, because there is no sum(DISTINCT) that means what she needs: sum(DISTINCT d.qty * d.unit_cost) would collapse two genuinely separate dispensations of the same medicine at the same price into one, and would under-report instead of over-reporting. The colleague’s fix repairs the two numbers she can see are wrong and quietly corrupts the one she cannot.
The third is to send last year’s methodology note with a figure computed the way it was computed last year, which she knows was the same query, and flag the discrepancy in a footnote. That costs nothing tonight. It also means that if last year’s report used this query, last year’s drug cost was inflated the same way, and the funder has been told a wrong number twice.
The funder’s officer has already said, in an email Priya has open, that a resubmission after the deadline moves the district to the next quarterly cycle, which delays a tranche of about forty lakh by three months.
How it played out
Priya took the first option and it took fifty minutes rather than fifteen, because the pre-aggregated version returned 46 rows against 44 the original had produced. Two sub-centres had visits and no dispensations at all, and the inner join to dispensations had been dropping them: the chasm trap sitting underneath the fan trap, deleting two centres from a report that also inflated the rest. Both were centres that had run out of stock in February, which was the most useful finding in the whole exercise and appeared nowhere in the funder’s requested columns. The corrected drug cost was 92,00,000 rupees, almost exactly half the original, which is what two referrals per visit on average will do. She sent the report at 9.40pm with a note saying that the previous year’s figure had been produced by the same query and was likely to be similarly overstated. The funder’s officer replied that the previous year’s figure had never been reconciled against the drug budget by anyone, and asked for the corrected series. The two out-of-stock centres received a supervisory visit in March.
Worth arguing about
State the mechanism that inflated the drug cost, in one sentence, without using the words “fan trap”.
One answer
Two independent one-to-many joins hang off the same parent, so every dispensation row for a visit is paired with every referral row for that visit, and the output is the Cartesian product of the two children per visit, which means each dispensation’s cost is summed once per referral.
The colleague’s DISTINCT fix repairs the counts. Why is applying the same instinct to the drug cost worse than leaving it wrong?
One answer
Because it fails in the opposite direction and is harder to notice.
count(DISTINCT ...)deduplicates identifiers, which is exactly right. There is no summing equivalent:sum(DISTINCT qty * unit_cost)deduplicates values, so two separate dispensations that genuinely cost the same amount collapse into one and the total is understated. An overstated figure that crosses a known budget ceiling gets caught; an understated one that sits comfortably below it does not.The corrected query returned 46 rows where the original returned 44. What check would have caught that before the totals were even looked at, and why does it belong in every multi-join query?
One answer
A row ledger: count the rows at each step and compare with the grain you claimed. She claimed one row per sub-centre and there are 46 sub-centres, so a result with 44 rows has lost two before any arithmetic happened. The same ledger catches the opposite fault: a count that rises after a join means something on the right matched more than once. It takes seconds, it is arithmetic rather than judgement, and it is the only check that sees both the inflation and the deletion.
Test yourself6 questions on this module
FROM orders o LEFT JOIN refunds r ON r.order_id = o.id WHERE r.reason <> 'damaged'was meant to return all orders. It returns only orders that have a refund. Why?Which check tells you a query has a fan trap before you look at any total?
A join on
c.id = o.customer_refreturns zero rows, although both tables plainly contain the same customers. Which step should come first?Two payments of 500 rupees from the same student, on the same day, are identical in every selected column. The two result sets containing them are combined with UNION. What happens?
You want one row per student who has paid, with no duplicates and no DISTINCT. Why does
EXISTSsuit this better than a join topayments?A monthly report LEFT JOINs a spine of every branch-month to
orders. Why must it usecount(o.id)rather thancount(*)?
Summaries, and the piles behind them
11 lessons · 1 h 33
You will be able toCompute any count, sum, average, percentile or rate at a stated grain with NULLs and time boundaries handled deliberately, produce subtotals and a wide report from long data in one query, and reconcile every total against a figure you already trust before sending it.
- 3.1GROUP BY: making piles and asking about each one
- 3.2NULL, and why your counts are wrong
- 3.3Averages: the four ways AVG misleads
- 3.4Percentiles: describing a distribution instead of its mean
- 3.5Grouping by day, week and month without lying about the boundaries
- 3.6Rates: a numerator, a denominator, and the same grain for both
- 3.7Listing the members of a pile: string_agg and array_agg
- 3.8The row behind the MAX: which order was the biggest
- 3.9Subtotals and grand totals in one query: ROLLUP and GROUPING
- 3.10Wide and long: pivoting rows to columns and back
- 3.11Reconciling: making a total tie out before you send it
An illustrative case, built from documented patternsThe waiting time everybody was happy with
The operator’s contract with the city contains one performance number: average waiting time at a stop, which must stay under nine minutes. Every quarter, Karthik’s team reports it. Every quarter it comes in between seven and eight minutes, and every quarter the committee’s public consultation produces a stack of complaints about waiting half an hour at particular stops on particular evenings.
The number is computed from arrivals, one row per bus per stop per arrival, with a timestamp. Waiting time is derived as the gap since the previous bus at that stop. There are 4.1 million rows a quarter.
This quarter Karthik is asked by a new committee member for the median as well, and for the 90th percentile. He runs them. The mean is 7.8 minutes. The median is 5.2. The 90th percentile is 19 minutes. The 99th is 54.
Then he looks at how the mean has been computed for the last three years, and finds two things.
The first is that the quarterly figure has always been the average of the daily figures held in a daily_stats table, which stores one row per stop per day with an avg_wait column. Sunday has a tenth of the traffic of a Tuesday and much longer gaps, so its long waits sit in the average with the same weight as a weekday’s short ones, but the far bigger effect runs the other way, because a stop with four buses a day and one with two hundred contribute equally to an average of averages. Recomputing from the raw gaps, weighted properly, gives a mean of 11.4 minutes, not 7.8.
The second is the denominator. The gap is only recorded when two consecutive arrivals are logged at the same stop. When a bus is cancelled, no arrival is logged, so the long wait it caused is not a row in the table at all. The rows that exist are the rows where a bus turned up. The measure is a measure of waiting time among people whose bus came.
Karthik has two weeks and a decision that is not really technical.
He can report the number the way it has always been reported, with the median and the percentiles added as supplementary information, and note the weighting question in an annexe. That keeps the contract compliant, keeps three years of published figures internally consistent, and lets the committee see the tail for the first time. It also means the headline number in the minutes will be 7.8 minutes, and the headline number is what the newspaper prints.
Or he can report 11.4 minutes as the mean, with the method change explained. That breaches the contractual ceiling for the first time in three years, triggers a review clause, and (because the previous figures cannot be recomputed for stops where the arrival data has been archived) produces a series with a step in it that no one can smooth. The operator’s managing director has already asked him, not unreasonably, whether a number that changes because the analyst changed his mind is a number at all.
The cancellation problem breaks both options equally, and fixing it needs the scheduled timetable joined against the arrivals (a spine of expected buses, LEFT JOINed to what actually arrived) which is three days of work he does not have this quarter.
How it played out
Karthik reported both. The headline stayed 7.8 minutes, labelled “as previously computed: unweighted mean of daily stop averages”, with 11.4 beside it labelled “weighted mean over all recorded gaps” and the p50, p90 and p99 under both. The annexe said in one paragraph that neither figure counts a wait caused by a cancelled bus, because a cancelled bus logs no arrival, and estimated from the timetable that this excluded roughly 4% of the quarter’s expected services. The committee spent almost none of its meeting on the mean and most of it on the p90 of 19 minutes, broken down by stop, which identified eleven stops accounting for a third of all waits over twenty minutes. The review clause was not triggered, because the committee accepted the old definition as the contractual one and asked for the new one to be adopted from the following year with a renegotiated ceiling of thirteen minutes. The spine against the timetable was built the following quarter and moved the p90 to 24 minutes. Karthik’s note to himself afterwards was that the three years of compliant reports had never been wrong, exactly; they had answered a question about the average day at the average stop, and nobody rides the average stop.
Worth arguing about
Why is the average of the daily
avg_waitvalues not the average waiting time, and what shoulddaily_statshave stored instead?One answer
Because an average of averages weights each daily row equally regardless of how many gaps it summarises, so a stop-day with four buses counts as much as one with two hundred. Averages do not combine; sums and counts do. The summary table should store the components (the total waiting minutes and the number of gaps per stop per day) so any period’s mean is
sum(total_minutes) / sum(gaps). This is the same rule that makes it impossible to recover a quarterly percentile from daily percentiles.The measure only sees stops where two consecutive arrivals were logged. Name the failure and explain why adding a filter or a date range cannot repair it.
One answer
It is survivorship: the population is defined by having survived the thing being measured. The long waits caused by cancellations are absent from the table before any filter runs, so no WHERE clause can bring them back. The only repair is to supply the rows that should exist (a spine of scheduled services from the timetable, LEFT JOINed to the arrivals) so that a missing bus becomes a row with a known gap rather than no row at all.
The committee spent its meeting on the p90 rather than on either mean. What does that suggest about which statistics to put in front of a decision-maker?
One answer
That a single central figure describes a distribution nobody experiences, and that the tail is where the decisions live. Reporting p50, p90 and p99 beside the mean, with the count, turns “is the service acceptable” into “which stops, on which evenings, produce the waits people complain about”: a question with an operational answer. The gap between the mean and the median is itself the finding, and it is more useful than either number alone.
Test yourself6 questions on this module
In a table where one row is one order, a city’s pile returns
COUNT(*)of 900 andCOUNT(DISTINCT customer_id)of 340. What is true?statusholds ‘active’, ‘churned’, or NULL for people who never set it.SELECT COUNT(*) FROM users WHERE status <> 'churned'comes back smaller than expected. Why?Branch A took 100 orders averaging 500 rupees; branch B took 10 orders averaging 5,000. Averaging the two branch averages gives 2,750, and the true average is 909. What is the general rule?
Server A has a p95 of 300 ms and server B a p95 of 900 ms. What is the p95 across both?
A
daily_statstable has one row per day with aratecolumn. Sunday: 12 visits, 6 conversions. Monday: 4,000 visits, 120 conversions. What is last week’s conversion rate?A report uses
GROUP BY ROLLUP (region, city), and some sales have no recorded city. Why isGROUPING()not optional here?
Windows, sequences and time
10 lessons · 1 h 29
You will be able toAnswer any “compared with what came before” or “which one within each group” question (rank in group, top N per group, running total, change from the previous row, moving average, streaks, sessions and cohort retention) with a window function whose frame you can state, and join a fact to the value that was true at its own moment in time.
- 4.1A window: GROUP BY that keeps the rows
- 4.2ROW_NUMBER, RANK and DENSE_RANK: three answers to “what position”
- 4.3Top N per group in two lines
- 4.4Running totals, and the frame you did not know you set
- 4.5LAG and LEAD: the previous row, and the change since it
- 4.6Moving averages: seven rows is not seven days
- 4.7Streaks and sessions: gaps and islands
- 4.8Cohorts and retention: how a month of new customers behaves afterwards
- 4.9Dates, intervals and the two kinds of timestamp
- 4.10As-of joins: the value that was true at the time
An illustrative case, built from documented patternsThe retention curve that was falling because it was young
Ananya runs analytics for the app. Every month she produces a cohort grid: learners grouped by the month of their first completed lesson, and for each cohort the share still active one, two, three and more months later. Active means completing at least one lesson in the month.
The grid for the last fourteen months, pivoted the way the board likes it, shows month-6 retention of 31% for the January cohort of last year, 29% for March, 26% for June, 22% for September and 14% for November. Read across that row and the story is unmistakable: retention has almost halved in a year. The board’s paper, drafted by someone else from her grid, says the content is stale and recommends the forty-lakh investment in new material.
Ananya checks the numbers and they are all arithmetically correct. Then she checks the shape of the grid, and finds that the November cohort is four months old. Its month-6 cell is not 14%; it is empty. What the pivot has produced for November is the last cell that had any data in it, which is month 4, and the board paper has read a triangle along an edge that does not exist.
The genuinely comparable comparison is a column, not a row: month-3 retention for every cohort that has reached month 3. That series is 34%, 33%, 31%, 32%, 30%, 29%: a slow decline of about five points over a year, not a halving.
That is still a decline, and now Ananya has a second problem. Two of the cohorts contain a promotion. In June and July the app ran a free-trial campaign with a telecom partner that brought in 21,000 registrations, of whom a large share completed one lesson and never returned. Those learners are in the June and July cohorts by the definition being used (first completed lesson) and they drag those cohorts’ curves down without anything having changed about the content. Excluding them is defensible and is also the kind of exclusion that can be made to produce any number you like.
She has three days, and the decision is what to put in front of the board.
If she sends the corrected column-wise reading, the recommendation loses most of its evidence. The head of content, who has spent two months preparing the plan, will point out, correctly, that a five-point decline is still a decline and that the app’s competitors ship new material monthly. Killing the plan on Ananya’s chart and then losing ground would be an expensive way to be right about a pivot table.
If she says nothing and lets the paper go, the board approves forty lakh on the strength of a number that means the November cohort is young. Ananya will know. And the same grid goes to the board every quarter, so the error will be repeated, will eventually be found by somebody, and will make every future number she produces arguable.
There is a third path with its own cost: present both readings and the promotion effect, and ask the board to decide on a smaller pilot. That is honest, it delays the content plan by a quarter in a market where a quarter matters, and it hands a room of eleven people a methodological argument three days before they were expecting a recommendation.
How it played out
Ananya took the third path and made one change to the artefact rather than to the argument: she reissued the grid with every cell that lay beyond a cohort’s age blanked out rather than left as the last available value, so that the triangle looked like a triangle. The board paper’s headline could not be written from the new grid at all, which settled the discussion faster than her memo did. She showed the month-3 column, the five-point decline, and the June and July cohorts with and without the telecom registrations: 29% against 33% for June, which put most of the recent decline down to who had been acquired rather than what they were taught. The board approved a pilot of about a quarter of the proposed spend, aimed at the two levels where the month-1 drop was steepest, and asked for the cohort grid to carry a caption. The caption is now four lines: what event defines a cohort, what counts as active, that the measure can rise because a learner who returns in month 4 after missing month 3 is counted again, and the age beyond which cohorts are not comparable. The head of content’s own view six months later was that the pilot had been the better plan anyway, because it had a measurable edge and the full programme did not.
Worth arguing about
Explain precisely why reading the cohort grid across a row produced a halving, and what reading replaces it.
One answer
The grid is a triangle: a cohort that started four months ago has ages 0 to 4 and nothing beyond, so its month-6 cell does not exist. Reading a row compares cohorts at different ages, or worse, compares a real month-6 value with whatever the pivot put in an empty cell. The comparable reading is down a column (the same age across cohorts) and only as far as every cohort in the comparison has actually reached.
The measure counts a learner as retained in a month if they completed a lesson in it, so the curve can rise. Why does that matter when this chart is shown next to a subscription retention chart?
One answer
Because they are different measures with the same name. Purchase- or activity-based retention asks whether the learner was active in that month, so someone active in months 0, 1 and 3 is retained at 3 and not at 2, and the curve can go up. Subscription retention asks whether the learner had not cancelled by then, which can only fall. Put side by side without captions, the activity curve looks noisy or the subscription curve looks better, and neither comparison means anything.
Excluding the telecom registrations improved the June cohort by four points. What makes that a legitimate adjustment rather than a convenient one?
One answer
Legitimacy comes from the rule being stated before the result is seen, applied to every cohort rather than to the inconvenient ones, and reported alongside the unadjusted figure rather than instead of it. Ananya showed June with and without, and the definition (registrations arriving through a specific partner campaign) is a fact about acquisition that anybody can check, not a threshold tuned until the line looked right. The same adjustment made quietly, on one cohort, would be indistinguishable from choosing the answer.
Test yourself6 questions on this module
SELECT *, row_number() OVER (ORDER BY amount DESC) AS rn FROM orders WHERE rn <= 10fails with “column rn does not exist”. Why, and what is the fix?Four orders fall on days 1, 2, 2 and 3, for 10, 20, 30 and 40.
sum(amount) OVER (ORDER BY day)returns 10, 60, 60, 100 rather than 10, 30, 60, 100. What is happening?last_value(price) OVER (PARTITION BY product_id ORDER BY changed_at)returns each row’s own price instead of the latest one. What is wrong?Five products sell 900, 700, 700, 500 and 500. A filter of
<= 3on the ranking returns five rows. Which function was used?A seven-row moving average is computed over a daily revenue table in which closed days have no row. What does the line actually show?
A margin report joins each order to
productsfor a cost. Run in June and again in December, it disagrees about January’s margins. What is the fault?
Data that arrives dirty
12 lessons · 1 h 46
You will be able toLoad an exported CSV with mixed encodings, ambiguous dates and duplicate rows into a typed, deduplicated table by re-runnable query with every dropped row counted, decide and document what each missing value means, and produce a feature table for a model in which no column knows anything that happened after its row’s timestamp.
- 5.1Cleaning real data without destroying it
- 5.2Getting a file in without losing a row
- 5.3Stage as text, then cast in a query you can re-run
- 5.4Parsing dates that were typed by people
- 5.5Text that looks identical and is not
- 5.6Duplicates: finding them, and deciding which row survives
- 5.7Assertions: queries that return no rows when everything is right
- 5.8Missing values: NULL, empty, N/A, zero and minus one
- 5.9Categories: canonical values, mapping tables and encoding for a model
- 5.10A feature table: one row per entity, as of a moment
- 5.11Leakage: when a feature knows the future
- 5.12Why a query is slow, and what an index does
An illustrative case, built from documented patternsThe eleven hundred rows that were not random
The partner hospital pays Sahyog a fee per referred test. Once a year the hospital sends a CSV and the two finance teams reconcile it against Sahyog’s own records. This year’s file has 28,900 rows and one column that will not behave: referred_on, which arrives as text like 14/01/2026, 2026-01-14, 14-Jan-26 and, in a few hundred rows, 45671.
Meena, who does the analysis, stages the file exactly as she was taught: every column as text, into a raw_referrals table that is never updated, with a load_file column recording where each row came from. Then she writes the casting view and counts what refuses.
to_date(referred_on, 'DD/MM/YYYY') parses 27,720 rows and fails on 1,180. She lists the failures with their frequencies. Six hundred and ten are 14-Jan-26 style, three hundred and twelve are ISO, two hundred and fifty-eight are five-digit numbers (spreadsheet serials, days since 30 December 1899) and none of the three is difficult to parse once identified. It is an hour’s work to write a CASE that tests the shape of each value and applies the right parser.
The reconciliation is due to the partner’s finance office in two days, and Meena’s manager points out that 1,180 out of 28,900 is 4.1%, that the fee is a flat 340 rupees per referral, and that the difference is about four lakh on a settlement of just under a crore. His proposal is to load the 27,720 rows that parse, settle on those, and reconcile the remainder next quarter when there is time. Four lakh is real money and it is also within the tolerance the two organisations have used before.
Meena runs one more query before answering: the 1,180 failing rows grouped by centre and by month.
They are not spread across the year and the nine centres. One thousand and four of them are from the Pollachi centre, and every one of those is from 14 January onwards. Pollachi changed its practice-management software in the second week of January, and the new software writes dates in a different format and exports spreadsheet serials for anything entered on a mobile device.
So the 4.1% is not a random 4.1%. It is most of one centre’s second half of the year. Dropping it does not lose four lakh evenly; it removes Pollachi from the analysis from mid-January, and Pollachi is the centre whose referral volume the partner hospital has been questioning. A settlement computed on the parsing rows would under-pay Sahyog for that centre specifically, and, worse for the relationship, would produce a referral count for Pollachi that supports the partner’s argument that the centre is not referring.
Meena’s manager is not being careless. His point is that an hour of parsing work invites a second hour of checking, and a third when the parsed dates turn out to move rows between months, and that the deadline is real: the partner’s own quarter closes on Friday and a late reconciliation waits three months for the next cycle. He has also seen a colleague spend a week on a date column and arrive at the same settlement figure, and he is weighing a certain cost in staff time against an uncertain gain of a few lakh. What he has not seen is Meena’s query grouping the failures by centre, because she ran it after he made his proposal and before she answered him.
How it played out
Meena did the hour of parsing and it cost most of a day, as her manager predicted. Two of the three formats were straightforward. The spreadsheet serials were not: 258 rows parsed correctly with the 30 December 1899 anchor, but a histogram by month afterwards showed 41 of them landing in 1970, which turned out to be zeros in the source: a genuinely empty field exported as 0 rather than as an empty string. Those 41 went to NULL with a flag, and the count of them went into the load note. The reparsed file gave Pollachi 1,004 additional referrals worth 3.41 lakh, and moved 63 rows across a month boundary because the day-first and month-first readings of the same string had been landing them in different months. The reconciliation went out on Friday with a one-page note listing the three date formats found, the counts of each, the 41 rows set to NULL and why. The partner hospital’s finance office replied that they had been dropping unparseable rows from their own side for two years and had no idea how many, which turned the settlement into a joint exercise to rebuild both sides from the raw files. The parsing view is now run monthly rather than annually, and a check that fails the load when a new date format appears was added the same week.
Worth arguing about
The manager’s proposal to drop 4.1% of rows would have been reasonable under one condition that turned out not to hold. What was it, and what query established that it did not?
One answer
The condition is that the dropped rows are a random sample of the file, so that removing them shifts the total without changing its shape. The query that tested it was a count of the failing rows grouped by centre and by month, which showed 1,004 of 1,180 coming from one centre from one date onwards. Unparseable rows are very often not random: they are one branch, one week, or one system change, and dropping them removes a whole segment while the totals still look plausible.
Why did staging every column as text make this recoverable, when a typed load would not have?
One answer
A typed load has to be right about every row before any row is in: Postgres would have aborted the whole COPY on the first unparseable date, and a loader that guesses would have silently skipped or mangled the offending rows. Text staging cannot fail, so all 28,900 rows are on disk exactly as they arrived, and every decision about what they mean happens afterwards in a view, next to a count of the rows that needed the decision. When the spreadsheet serials turned out to include zeros, the original values were still there to look at.
After reparsing, 63 rows moved across a month boundary. Why is that worth reporting rather than quietly fixing?
One answer
Because it changes figures that have already been sent: a monthly referral count for December or January that somebody has seen will now be different, and the difference has a cause anyone can check. Reporting it is also the only way the other side can find the same rows in their own file. A cleaning step that cannot say what it changed is indistinguishable from a cleaning step that changed the wrong thing, and the whole point of cleaning in a re-runnable query is to be able to answer “what did you actually change?” six months later.
Test yourself6 questions on this module
Why load every column of a messy CSV as text before casting anything?
A date column holds values like
03/04/2026and the file does not say which country produced it. What tells you whether it is day-first?Why is
SELECT DISTINCT *not a deduplication strategy for a payments export?Two hundred of a thousand respondents skipped the income question. The gaps are filled with the column’s mean. What has changed?
A nightly export arrives with half its usual rows because a source system dropped a branch. Every row-level check passes. What catches it?
A churn model scores 0.99 in testing and predicts almost nothing useful in production. Its feature
total_spendissum(amount)over every order the customer ever placed. What went wrong?
Changing data safely
11 lessons · 1 h 37
You will be able toDesign and create a small schema whose keys and constraints refuse bad data, insert, update and delete inside a transaction you have rehearsed and can roll back, explain what happens when two writers touch the same row, alter a table that is in use without an outage, and give an analyst or an AI a read-only path into the database.
- 6.1CREATE TABLE: a constraint is a test that runs on every write
- 6.2INSERT, and the INSERT you can run twice
- 6.3UPDATE and DELETE: rehearse it as a SELECT
- 6.4Transactions: making a mistake reversible
- 6.5Two people, one row: lost updates and how the database prevents them
- 6.6Changing a table that is in use
- 6.7Views: a saved question, not a saved answer
- 6.8JSON in a column: when it helps and what it costs
- 6.9Keeping history: soft deletes, audit rows and who changed what
- 6.10Who may do what: roles, GRANT and a read-only door
- 6.11From a paragraph to a schema: a tuition centre
An illustrative case, built from documented patternsFour thousand marks, and the update that could not be rehearsed
Results go live at nine the next morning. At six in the evening the exam controller discovers that one paper (Applied Mathematics II, taken by 4,100 students) has been entered against the wrong scheme. The paper was moderated with a 5-mark grace applied to anyone who scored between 35 and 39, and the grace was applied twice by two different clerks working from the same list, so those students carry marks 5 higher than they should.
Ravi, who maintains the college’s ERP database, has the marks table in front of him: one row per student per paper, with marks_obtained, entered_by and updated_at. He also has a moderation_log table listing which students received grace and by whom, with 1,180 rows for this paper, of which some students appear twice.
The correction is arithmetically simple: subtract 5 from the students whose grace was applied twice. Identifying them is a group and a count. The statement writes itself:
UPDATE marks m
SET marks_obtained = m.marks_obtained - 5
FROM moderation_log l
WHERE l.student_id = m.student_id
AND l.paper_code = m.paper_code
AND m.paper_code = 'AM-II';Ravi does not run it, for the reason the module gives: an UPDATE ... FROM whose right-hand side matches more than once per target row updates that row with one of the matches and does not say which. Here the right-hand side matches twice for exactly the students he is trying to fix (that duplication is the bug), and Postgres will apply the subtraction once per target row, not once per match. The statement would take 5 off every student with any grace at all, including the 1,180 minus the duplicates who received it correctly. The rehearsal query he runs instead, counting matches per target row, returns 612 rows with a count above one. That is the population.
Which leaves the decision, at half past six.
The first option is a corrected statement: subtract 5 only from the 612 students identified by the group-and-count, inside a transaction, with the row count checked against 612 before committing. Twenty minutes including the rehearsal. It requires Ravi to be confident that moderation_log is a complete record of what the clerks did, and the clerks were working from a printed list, entering marks directly into the ERP screen, with the log written by a trigger that was added eight months ago and has never been audited.
The second is what the exam controller wants: hold publication, re-enter the paper from the moderated answer sheets tomorrow, and publish at five in the evening. That is unarguably correct and it costs a day in which 6,200 students, most of whom need the result for internship applications closing that week, refresh a page that says nothing. The college did this two years ago and it made the local newspaper.
The third is to publish on time with the paper’s results withheld, and issue that one paper later. It affects only the affected students, and it also tells 4,100 of them, publicly, that something went wrong with their marks, before anyone knows whether the fix is right.
What none of the three options addresses is that the ERP’s application user owns the schema and can alter it, that every clerk shares one login, and that updated_at is set by the application rather than by the database, so a row changed directly in the database tonight will carry a timestamp that says nothing about who changed it.
How it played out
Ravi took the first option and added one step: before the UPDATE, he took a copy of the affected rows into a table called marks_ammii_before_20260714, with the student id, the old mark and the time. The whole correction then ran inside one transaction (the copy, the update, and a count) and he read UPDATE 612 before typing COMMIT. The check that mattered came afterwards, and it was not a row count: he picked six students from the 612, pulled their moderated answer sheets from the exam cell, and added the marks by hand. Five matched. One did not, because that student’s grace had been applied twice by the same clerk in the same minute and the trigger had logged it once, so the double application was invisible to moderation_log entirely. That single row turned a settled question back into an open one, and the exam controller held the paper (the third option) while a full reconciliation against the answer sheets ran the next morning. It found nine more. Results for the other papers went out at nine as planned. The lasting changes were smaller and more useful than the incident: separate database roles for the application, the migration tool and the exam cell’s read-only reporting; an audit trigger on marks recording the old row, the new row, the database user and the time; and a standing rule that no correction to a published mark is made by an UPDATE without a before-copy in the same transaction.
Worth arguing about
Why would the first version of the UPDATE have been wrong, and what rehearsal exposes that class of fault in any
UPDATE ... FROM?One answer
Because the join matches twice for exactly the students being fixed, and an
UPDATE ... FROMapplies the SET once per target row rather than once per match, choosing one matching row silently. The rehearsal is a count of matches per target row: join the two tables, group by the target’s key, and keep the groups with a count above one. It must return nothing before the UPDATE is safe. Here it returned 612 rows, which was both the proof that the statement was wrong and the definition of the population that needed correcting.The row count after the UPDATE was 612, exactly as rehearsed, and the statement was still not known to be right. What did the hand check add that no count could?
One answer
A count proves the statement touched the rows it was aimed at. It cannot prove the aim was right, because both the rehearsal and the update read the same source (
moderation_log) and that source was incomplete. Checking six students against the answer sheets tested the assumption underneath the whole correction, which is the one thing internal consistency can never test. This is the same principle as reconciling a total against an external figure: an error shared by the query and its check is invisible to the check.Name two of the changes made afterwards and explain which specific risk each one removes.
One answer
Separate database roles remove the risk that an ordinary reporting session, or a compromised application, can alter or drop a table: the exam cell’s reporting role can only SELECT, and the schema can only be changed by the migration user from a deployment pipeline. An audit trigger on
marksremoves the risk that a corrected mark leaves no trace of what it was before or who changed it: the trigger runs on every write regardless of which code path or which person made it, which is exactly the property an audit that depends on the application remembering does not have. The before-copy rule removes the risk that a correct-looking UPDATE cannot be reversed after COMMIT.
Test yourself6 questions on this module
INSERT ... ON CONFLICT (reference) DO NOTHINGfails with “there is no unique or exclusion constraint matching the ON CONFLICT specification”. What does that mean?You are about to run
UPDATE orders o SET city = c.city FROM customers c WHERE c.id = o.customer_id AND o.city IS NULL. What must the rehearsal prove?Inside a transaction in Postgres, one statement fails. Every statement afterwards, including
SELECT 1, returns “current transaction is aborted”. Why is that deliberate?A course has one seat left. Two requests read
seats_left, both see 1, both write 0, and two students are enrolled. Which fix removes the fault most simply?A forty-million-row table needs a new required column. Which sequence keeps the site up?
A dashboard reads a materialised view refreshed at six each morning. What must the dashboard say?
What this course is
A first course in data for people who have never written a query. You will learn SELECT and WHERE, how to reason about joins instead of memorising diagrams, GROUP BY, the NULL rules that quietly break counts, what an index actually does, how to clean real exported data without destroying it, and how to tell a number that is technically correct from a number that answers the question you were asked. It ends with the part nobody teaches: how to get SQL out of an AI without being confidently misled.
Who wrote it
Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.
More in Build with it
- Python, from Zero, for AIFrom your first line of code to your first API call.
- Building with AIFrom your first API call to a feature you can trust
- Agents and AutomationWhat an agent actually is, and when you should build a script instead.
- Shipping ItGet what you built in front of real people, and keep it alive.
- Building Apps with AIBuild working software before you can write it, without fooling yourself.
- Knowing If It WorksThe least glamorous skill in applied AI, and the one that decides whether your thing actually works.
- Context EngineeringThe window is a budget. Learn to spend it.