The SQL Masterclassretrieval · joins · windows · design · optimization
A complete course · first query to production

Master SQL,
SELECT the signal from the noise.

Relational databases from first principles — the model, the syntax, then aggregation, joins, window functions, schema design, and query optimization. Every example is drawn from real e-commerce, gaming, FMCG & digital-media data; every claim is labelled fact, inference, or open question; and every lesson maps to LeetCode problems you'll meet in analytics & data-engineering interviews. Written for PostgreSQL 16 and MySQL 8, current to June 2026.

First principles Four verticals Interview-ready
5
months
25
lessons
25
interview quizzes
110
glossary terms
30+
LeetCode problems

A first-principles path from a blank query window to production-grade analytics and schema design. Progress is saved locally in your browser. SQL behaviour varies by engine — dialect differences are flagged throughout, and you should always confirm against the official documentation linked in References.

Course brief · field guide

Course Brief & Field Guide

What this course is, how it was designed, the evidence behind its examples, and how to get the most from it — before you write a single query.

Course brief·3 min read

Executive Summary

A first-principles, five-month path from a blank query window to production-grade analytics and schema design.

Learning objective. By the end of this course, learners will be able to write production-level SQL for business analytics and database design in commercial contexts — retrieving and filtering data, aggregating and joining across normalized schemas, expressing analytical logic with subqueries and window functions, designing and modifying schemas safely, and reading execution plans to reason about performance.

Who it is for. Beginners to early-intermediate practitioners with no prior SQL or coding experience required — aspiring data analysts, analytics engineers, backend developers, and anyone preparing for a data-focused interview. The only prerequisites are comfort with spreadsheets-style tabular thinking and a willingness to run queries by hand.

What makes it different. Three things. First, it is taught from first principles: every keyword is grounded in the relational model rather than presented as a recipe. Second, every example comes from one of four commercial verticals — e-commerce, gaming, FMCG (fast-moving consumer goods), and digital media — using a small set of recurring schemas so the mental model transfers. Third, the course is interview-aligned: each concept maps to specific LeetCode problems, and every claim is explicitly tagged as a Fact, an Inference, or an Open question so you always know how much to trust it.

The structure follows a five-month spiral: Month 1 retrieval, Month 2 aggregation & joins, Month 3 subqueries & window functions, Month 4 database design & modification, Month 5 optimization & a portfolio capstone. Concepts learned early are deliberately re-used later — the normalization you learn in Month 4, for instance, is exactly what determines the join and indexing decisions you make in Month 5.

Scope & dialect

Examples target PostgreSQL 16 as the primary teaching dialect, with MySQL 8 differences flagged where they matter (LeetCode's judge defaults to MySQL). Where syntax is ANSI-standard it will run on SQLite, SQL Server, and most engines with little change. Fact Core relational operations — selection, projection, join, aggregation — are standardised; Inference most differences you hit early are in functions (dates, strings) and in optimizer behaviour, not in the core query shape.

Course brief·4 min read

Key Findings & Pedagogical Insights

The design principles that emerged while building this curriculum — the patterns worth internalising before the details.

Designing a first-principles SQL course surfaces a handful of recurring lessons about how SQL is best learned. These shaped every chapter that follows.

Pedagogical insights
  • Teach the relational model and NULL first. A large share of downstream bugs — rows silently dropped by a join, a WHERE clause that excludes the rows you wanted, a NOT IN that returns nothing — trace back to not understanding tables-as-sets and three-valued logic. We front-load both.
  • Joins are set operations on keys, not a syntax to memorise. Using one recurring schema across all four verticals lets the mental model ("match rows where keys are equal, then decide what to do with non-matches") transfer instead of being re-learned per example.
  • Window functions demand a solid grasp of partitioning. We introduce them through two interview-grade problems — top-N per group and running totals — because partitioning logic is the conceptual hurdle, and these patterns recur constantly in analytics.
  • Solve the same question three ways. For several problems we show a subquery, a JOIN, and a CTE side by side, so learners internalise trade-offs (readability, reusability, performance) rather than a single recipe.
  • Normalization is a spectrum, not a rule. We teach 1NF–3NF with explicit denormalization trade-offs, then connect those choices directly to Month-5 query performance — design and speed are the same conversation.
  • Optimization is reading, not memorising. Because optimizer behaviour is engine- and data-dependent, we teach learners to read EXPLAIN and reason about plans rather than apply blanket "always do X" rules.
  • Anchor every concept to a real problem. Each lesson links to specific LeetCode problems so practice is immediate, concrete, and aligned with what interviews actually test.
  • Label confidence explicitly. Tagging statements as fact, inference, or open question trains calibrated judgement — a skill that matters more in real data work than recall.
Course brief·4 min read

Evidence Table

Each month's core concept, the vertical example used to teach it, where the example comes from, and how far it generalises.

Every scenario in this course is either a constructed example (invented to be representative of a typical schema) or a derived one (built from established theory or mirroring a well-known interview problem). None use proprietary data. The table maps concepts to examples and is honest about the limits of each.

Month & conceptVertical exampleSourceGeneralizability / uncertainty
M1 · SELECT/WHERE/ORDER BYE-commerce product catalogue browse & filterConstructed (typical OLTP catalogue)High — retrieval syntax is ANSI-stable across engines.
M1 · NULL three-valued logicGaming players with no recorded countryConstructedHigh as standard; some engines (e.g. older MySQL modes) differ on edge cases.
M2 · GROUP BY + aggregatesFMCG units sold by category & regionConstructed (typical sell-out schema)High; ONLY_FULL_GROUP_BY behaviour differs MySQL vs Postgres.
M2 · INNER vs LEFT JOINGaming users × sessions × purchases (revenue per paying user)Constructed; mirrors LeetCode Game Play AnalysisMedium — join semantics are universal; performance is schema-specific.
M3 · ranking window functionsDigital-media top-N content per categoryConstructed; mirrors LeetCode Department Top Three SalariesMedium — logic universal; tie-handling choice (RANK vs DENSE_RANK) is a design decision.
M3 · LAG/LEAD, running totals, gaps-and-islandsFMCG cumulative sales; gaming login streaksConstructed; mirrors LeetCode Human Traffic of StadiumMedium — pattern is standard; frame-clause defaults vary, so we state them explicitly.
M4 · normalization 1NF–3NFE-commerce orders / order-items splitDerived from relational design theory (Codd)High as theory; the right level depends on workload — we assume OLTP.
M4 · indexingE-commerce lookups by email / SKUConstructedLow–Medium — benefit depends on table size, selectivity, and write mix.
M5 · EXPLAIN / execution plansDigital-media slow reporting queryConstructed; PostgreSQL 16 plan shapesLow — plans are engine- and statistics-specific; treat shapes as illustrative.
M5 · transactions & ACIDE-commerce checkout (decrement stock + insert order)ConstructedHigh as a concept; isolation-level defaults differ by engine.

Every constructed scenario is labelled in-lesson. Where a query's performance or behaviour is asserted, the surrounding text marks it as fact, inference, or open question.

Course brief·4 min read

Risks & Common Pitfalls

The misunderstandings that bite beginners hardest — and where the course defuses each one.

SQL is easy to write and easy to write wrongly in a way that still returns rows. These are the highest-frequency traps; each links to the lesson that addresses it.

PitfallWhy it bitesWhere we defuse it
NULL in WHERE / NOT INThree-valued logic: comparisons with NULL yield unknown, and NOT IN (…NULL…) returns no rows.Lesson 1.3
Accidental Cartesian productA missing or wrong join predicate multiplies rows (1000 × 1000 = a million).Lessons 2.4–2.5
WHERE vs HAVINGFiltering an aggregate in WHERE fails; WHERE runs before grouping, HAVING after.Lesson 2.2
Non-aggregated columns in GROUP BYSelecting a column that's neither grouped nor aggregated is an error in Postgres but historically silent in MySQL.Lesson 2.2
COUNT(*) vs COUNT(col) vs COUNT(DISTINCT col)COUNT(col) skips NULLs; the three answer different questions.Lesson 2.1
Integer division5/2 is 2, not 2.5, in integer contexts — silent truncation in rate calculations.Lesson 1.1
DELETE/UPDATE with no WHEREOne missing clause rewrites or destroys the whole table.Lessons 4.2, 5.3
Assuming an index is always fasterOn small or low-selectivity data a full scan can win; indexes also slow writes.Lessons 4.5, 5.2
Relying on implicit row orderWithout ORDER BY, row order is not guaranteed — ever.Lesson 1.4
The meta-pitfall

The most dangerous SQL bug is the one that returns a plausible number. A query that errors out is harmless — you fix it. A query that quietly drops 4% of rows because of an inner join, or double-counts because of a fan-out, can sit in a dashboard for months. This course repeatedly asks the same question: “could this silently be wrong, and how would I know?”

Course brief·3 min read

Recommendations for Learners

How to actually get fluent — concrete habits, not just “practise more.”

Do these
  • Set up a sandbox today. Run PostgreSQL locally (Docker: docker run -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:16) or use a zero-install option like DB Fiddle, SQLZoo, or the SQLite fiddle. You cannot learn SQL by reading.
  • Finish each month's mini-project before advancing. The spiral depends on it — Month 3 assumes the joins from Month 2 are automatic.
  • Run the LeetCode SQL 50 set in parallel. Aim for 1–2 problems a day; the per-lesson LeetCode boxes tell you which ones to do when.
  • Type queries by hand. Resist copy-paste. Muscle memory for SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY pays off under interview pressure.
  • Read EXPLAIN early. Don't wait for Month 5 — run EXPLAIN on your Month-2 joins just to see what the planner does.
  • Keep a personal “gotchas” log. Every time a query surprises you, write down the one-line lesson. This becomes your interview cheat-sheet.
  • Build the capstone in a real Git repository. A clean, documented schema + analytics queries is a portfolio piece you can show.
Avoid these

Don't skip the relational-model lesson because it “isn't code.” Don't memorise query templates without understanding why they work. Don't tune for performance before the query is correct. And don't practise only on one engine if you'll be interviewed on another — know at least where MySQL and PostgreSQL diverge.

Course brief·3 min read

Next Questions & Extensions

Where to go once the five months are behind you — open problems and adjacent skills.

This course covers transactional, single-node relational SQL thoroughly. Beyond it lie several rich directions, each genuinely open-ended:

  • Open question How do execution plans differ between MySQL and PostgreSQL for the same query and data? The optimizers, join algorithms, and statistics models differ; the only reliable answer is to run EXPLAIN on both.
  • Materialized views and incremental refresh for reporting — when is it worth pre-computing, and how do you keep a materialized result fresh without full recomputation?
  • Columnar & analytical engines (BigQuery, Snowflake, Redshift, DuckDB). The SQL looks familiar, but cost models, partitioning, and clustering change which queries are cheap. Inference habits that are good on row-store OLTP (narrow lookups, many indexes) often don't transfer to columnar analytics.
  • Query tuning beyond indexes: table partitioning, parallel query, and keeping the planner's statistics accurate.
  • Analytics engineering with dbt — version-controlled, tested, modular SQL transformations as a discipline.
  • When relational isn't the right fit — JSON/array column types, and the trade-offs of document or key-value stores for certain workloads.
  • Advanced window frames and statistical SQL — percentiles, PERCENTILE_CONT, sessionisation, and cohort retention beyond the basics in Month 3.

Each lesson ends with its own “next questions” where relevant, so these threads are picked up in context as you go.

Part 0

Foundations: From Bytes to Databases

How a computer stores anything, why databases were invented, and the relational ideas every later part depends on. No prior knowledge assumed.

Lesson 0.1·12 min read

How a Computer Stores Anything: Bits, Bytes & Encoding

After this you will be able to explain why a name can be 4 characters and 5 bytes at the same time — and why that gap causes real production bugs.

A support ticket arrives at a Retail company: customer names with accents are being truncated in the loyalty card print run. “François Bouchard” comes out as “François Bouchar”. The column is declared as twenty characters. The name is eighteen characters. Nobody can see the problem.

The problem is that the machine was never counting characters. It was counting bytes, and the two are not the same number. To debug that ticket — and a hundred others like it — you need to know exactly what a computer does when you ask it to remember something.

A bit is a decision, not a number

Physical storage devices are good at exactly one thing: holding a component in one of two distinguishable states. A transistor is charged or not. A patch of magnetic disk is polarised one way or the other. A cell in flash memory holds electrons or does not. Fact There is no third state; two-state devices are what engineers could manufacture reliably and cheaply at scale.

We name those two states 0 and 1, and one such device is a bit. A single bit can distinguish two possibilities: yes/no, active/inactive, paid/unpaid. That is not much. But bits compose multiplicatively: two bits give four combinations (00, 01, 10, 11), three give eight, and n bits give 2n. Ten bits already distinguish over a thousand things.

Eight bits grouped together is a byte, giving 256 combinations. The byte became the standard unit of addressing — when software asks for “the thing at position 4,096”, it means the 4,096th byte, not the 4,096th bit.

Analogy

Think of a corridor of light switches. Each switch is up or down — that is a bit. One switch tells you almost nothing. But a row of eight switches has 256 distinct patterns, and if you and I agree in advance on a code — say, pattern 01000001 means the letter A — then the corridor can spell words. The switches never contain letters. They contain patterns, plus an agreement. Every “data type” you will ever meet is one of those agreements written down.

The same bits mean different things

Here is the crucial point, and the one most beginners skip past. Fact The byte 01000001 is not “an A”. It is also the number 65, and also part of a colour, and also part of a date. What it means depends entirely on which interpretation rule you apply. That rule is the encoding, and the type system of a database is nothing more than a durable, enforced record of which encoding applies to which bytes.

Three encodings you will meet constantly:

  • Integers. Bytes read as a base-2 number. A 4-byte integer covers roughly ±2 billion; an 8-byte integer covers vastly more. Exceeding the range is called overflow, and in some systems it silently wraps around to a negative number.
  • Text. Bytes read through a character map. ASCII assigned 128 characters to single bytes, which covered English and nothing else. UTF-8 replaced it: English letters still take one byte, but accented Latin letters take two, most Asian scripts take three, and emoji take four.
  • Dates and times. Almost always stored as a count — days, seconds, or microseconds since an agreed starting instant — and rendered as “2024-01-08” only when displayed. The text you see is never what is stored.

UTF-8 is the reason characters and bytes diverge. You can watch it happen directly:

sqlchars_vs_bytes.sql
-- length() counts characters. octet_length(encode(...)) counts the
-- actual UTF-8 bytes. to_hex shows those bytes in base 16.
SELECT s                        AS text_value,
       length(s)                AS characters,
       octet_length(encode(s))  AS bytes_utf8,
       to_hex(encode(s))        AS utf8_hex
FROM (VALUES ('cafe'), ('café'), ('東京')) AS t(s);
Result
text_valuecharactersbytes_utf8utf8_hex
cafe4463616665
café45636166C3A9
東京26E69DB1E4BAAC

Read the hex column carefully. In “cafe” each letter is one byte: 63, 61, 66, 65. In “café” the first three letters are identical, and then the é occupies two bytes, C3 A9. Two Japanese characters occupy six bytes. This is the loyalty-card bug: a field sized in bytes, filled with text measured in characters.

Why money is never stored as a decimal fraction

Whole numbers encode exactly. Fractions frequently do not. Fact The standard binary floating-point format cannot represent 0.1 exactly, for the same structural reason base-10 cannot represent one-third exactly — the expansion never terminates, so it is cut off. The stored value is very slightly wrong.

For a temperature reading that is irrelevant. Finance For money it is a defect. Add a million slightly-wrong amounts and the errors accumulate into a discrepancy an auditor will find. This is why every column in this course's dataset that holds money is declared NUMERIC(10,2) — a decimal type that stores digits rather than a binary approximation, and is exact for addition and subtraction.

Common pitfall — trusting the display

Tools round for readability. A float holding 0.30000000000000004 is displayed as 0.3, so the error is invisible right up to the moment two systems disagree by a penny and someone spends a day reconciling. Inference The rule that follows: choose the type by what the value is, never by what looks right on screen. Money and quantities are exact and belong in decimal types; measurements and ratios are approximate and floats are fine.

When you should ignore all of this

Most of the time you should not think about bytes at all, and a beginner who obsesses over storage layout writes worse SQL, not better. Skip this layer when:

  • Your data is plain ASCII and small. Internal identifiers, country codes, status flags — characters and bytes coincide and nothing surprising happens.
  • You are exploring. While you are still deciding what question to ask, correctness of the answer matters far more than the efficiency of the representation.
  • The engine already decided. Modern columnar engines compress and re-encode automatically. Hand-tuning types to save bytes usually loses to the compressor and costs you readability.

Open Where the line sits is genuinely arguable. Some teams declare every text column as unbounded text and let the engine sort it out; others size every column deliberately as documentation of intent. Both positions are defensible, and which is right depends on whether your schema is read more often by humans or by machines.

Interview angle

“Why shouldn't you store currency in a float?” is a screening question at fintechs and it separates memorisation from understanding. Weak answer: “because of rounding errors”. Strong answer: binary floating point cannot exactly represent most decimal fractions, so each stored amount carries a tiny error that accumulates under summation and breaks equality comparisons; a fixed-point decimal type stores digits and is exact for addition, subtraction and comparison. The best candidates add the counterpoint: floats are the right choice for measured, approximate quantities where the error is smaller than the measurement error anyway.

Exercise 0.1

Telecommunications A carrier stores customer display names in a field limited to 30 bytes. Product management insists this is “plenty for 30 characters”. Show them, using the dataset, how to compare the character length and byte length of stored names, and state the shortest name in characters that could still overflow 30 bytes.

Show solution
sqlbyte_audit.sql
SELECT full_name,
       length(full_name)                AS characters,
       octet_length(encode(full_name))  AS bytes_utf8,
       octet_length(encode(full_name)) - length(full_name) AS overhead
FROM customers
ORDER BY overhead DESC, full_name
LIMIT 5;

Every name in this dataset is plain ASCII, so overhead is 0 throughout — which is exactly the point to make to product management: the test data proves nothing, because it contains no non-ASCII text. The answer to the second part is eight characters: eight characters from a script that encodes at four bytes each (emoji, or some historic scripts) is 32 bytes and overflows a 30-byte field. Even ordinary European names break it — 16 accented characters at two bytes each is 32 bytes. The fix is to size the field in characters, or to size it in bytes with the worst case deliberately calculated.

Knowledge check

A text value shows 4 characters but occupies 5 bytes. What is the most likely explanation?

Key takeaway

Storage holds patterns of two-state cells, nothing more. Meaning comes from an encoding agreed in advance, and a database's type system is that agreement made durable and enforced. Two consequences pay for the whole lesson: characters are not bytes, and binary floats are not exact — so size text fields by their worst case and store money in decimal types.

Lesson 0.2·12 min read

Memory vs Disk: The Speed Hierarchy That Explains Everything

Almost every database design decision — indexes, page sizes, buffer pools, columnar storage — falls out of one fact about hardware. Learn the fact and the rest stops being arbitrary.

A Banking team runs the same report twice. The first run takes minutes. The second, identical in every respect, returns almost instantly. Nobody changed anything. A junior analyst concludes the first run “warmed something up” and moves on; a senior engineer knows precisely what warmed up, and can predict which reports will be fast tomorrow.

The explanation is the memory hierarchy, and it is the single most load-bearing piece of hardware knowledge in data work.

The ladder

Computers have not one place to put data but several, arranged in a strict trade-off: the faster a storage layer is, the more it costs per byte, and therefore the less of it you get.

            SMALLER + FASTER + PRICIER
   +-------------------------------------------+
   |  CPU registers      bytes                 |
   +-------------------------------------------+
   |  CPU cache (L1/L2/L3)   kilobytes-megabytes|
   +-------------------------------------------+
   |  RAM (main memory)      gigabytes         |   <-- volatile: emptied on power loss
   +-------------------------------------------+
   |  SSD / NVMe             terabytes         |   <-- durable
   +-------------------------------------------+
   |  Spinning disk / object store  petabytes  |   <-- durable, cheapest
   +-------------------------------------------+
   |  Network / another datacentre             |
   +-------------------------------------------+
            BIGGER + SLOWER + CHEAPER

Fact Each step down the ladder is slower than the one above by a wide margin — not by a few percent, but by orders of magnitude, and the gaps between RAM, solid-state storage and network are each large enough that crossing one dominates everything else a query does. Inference That is why the practical goal of most database engineering is not “compute faster” but “cross fewer boundaries”. Arithmetic is nearly free; fetching the operands is the expense.

Two properties matter beyond speed. Volatility: RAM forgets everything the instant power is lost, while disk remembers. That single asymmetry is why databases have a write-ahead log and why the D in ACID exists at all. Granularity: durable storage cannot hand you one byte. It transfers a fixed-size block — typically a few kilobytes — whether you wanted one byte from it or all of it.

Analogy

You are working at a desk. Registers are what you are holding in your hands. Cache is the desk surface. RAM is the bookshelf behind you — a swivel away. Disk is the basement archive: you fill in a request slip and someone brings up a whole box, never a single page. The network is an inter-library loan. Nobody plans their day around how fast they can read; they plan it around how many trips to the basement they must make. A query planner does exactly the same arithmetic.

The buffer pool, and the mystery solved

Because block transfers are expensive, every database keeps a slab of RAM called the buffer pool (or page cache) holding recently used blocks. Reading a row means: look in the buffer pool; if it is there, you are done in RAM speed; if not, fetch the block from disk, evict something old, and keep it in case you need it again.

That is the whole answer to the banking team's puzzle. The first run pulled blocks off durable storage. The second run found them already in memory. Nothing was optimised — a boundary simply was not crossed the second time. Inference The practical lesson is that any single timing of a query is nearly meaningless. You must know whether it ran cold or warm, or you are measuring the cache rather than the query.

Here is a scan you can reason about concretely. Every one of the thirty line-item rows must be visited to produce these two numbers, because no shortcut exists for “sum of a column” without reading the column:

sqlfull_scan.sql
-- No filter, so every row is touched: a full scan.
-- At 30 rows this is one block. At 30 billion it is the whole problem.
SELECT COUNT(*)      AS line_items,
       SUM(quantity) AS units_sold
FROM order_items;
Result
line_itemsunits_sold
3036

At this size the entire table lives in one block and the distinction is academic. The value of the mental model is that it scales: the same query over billions of rows becomes a question of how many blocks must move from durable storage into memory, and every optimisation you will ever learn — indexes, partitioning, columnar layout, compression, materialised aggregates — is a different strategy for making that number smaller.

Sequential beats random, by a lot

Not all reads cost the same. Reading a million blocks that sit next to each other is far cheaper per block than reading a million scattered ones, because storage devices and operating systems both aggressively pre-fetch what comes next. Fact This gap was enormous on spinning disks, where a physical arm had to move, and it is smaller but still real on solid-state storage.

This explains a result that startles beginners: an index is not always faster. An index turns one big sequential read into many small random ones. If your filter selects a handful of rows out of millions, that trade is overwhelmingly worth it. If it selects a third of the table, the engine will often ignore the index and scan — and it is right to.

Common pitfall — benchmarking a warm cache

The classic mistake: run query A, run query B, declare B faster, rewrite the codebase. In fact A pulled every block into the buffer pool and B inherited a warm cache for free. Inference A defensible comparison alternates the order, runs each version several times, and reports both a cold and a warm figure — because production traffic contains both, and a query that is only fast when warm will be slow exactly when the system is under stress and the cache is being churned.

When the hierarchy stops mattering

  • The whole dataset fits in memory. Below a few gigabytes, everything is resident after first touch and disk layout is close to irrelevant. Optimise for clarity instead.
  • The bottleneck is elsewhere. A query blocked on a lock, on a slow network hop to a dashboard, or on a single-threaded serialisation step will not be helped by anything in this lesson. Measure before assuming the cause.
  • You are on serverless / separated storage. In cloud warehouses, compute and storage are separate services and the dominant cost is data scanned across the network. The ladder still exists, but the rung that hurts is a different one — which is why pruning columns and partitions matters more there than index tuning.
In the field

When a stakeholder says “the dashboard is slow”, the useful first question is never “which query?” but “slow for whom, and slow every time?”. Slow for the first person each morning and fast afterwards is a cold-cache signature and points at scheduling a warm-up, not at rewriting SQL. Slow for everyone, always, is a genuine volume or plan problem. Diagnosing which of the two you have takes one conversation and saves days.

Interview angle

“Why is the second run of a query faster?” is asked to check whether you understand caching or merely observe it. Name the buffer pool, distinguish cold from warm, and add the measurement discipline: alternate execution order and report both figures. A very strong answer volunteers the second-order point — that caching makes benchmarks non-transitive, so A-beats-B and B-beats-C does not imply A-beats-C unless each pair was measured under the same cache conditions.

Exercise 0.2

Insurance A claims table holds one row per claim with forty columns. Two reports run nightly: (a) “fetch the full record for claim 88214” and (b) “average settlement amount by month across all claims”. For each, say how many rows and how many columns must be read, and which layer of the hierarchy dominates the cost.

Show solution

(a) One row, forty columns. The work is finding where that row lives. With an index the engine performs a few small random reads — a handful of blocks — and the dominant cost is the latency of each round trip to durable storage, not the volume transferred. Without an index it degenerates into a full scan and becomes bandwidth-bound instead.

(b) All rows, two columns (settlement amount and date). No index helps, because nothing is filtered out — every row contributes. The dominant cost is raw bandwidth: how many bytes must move from storage into memory. This is precisely the case columnar storage is built for, since a column layout can read those two columns and skip the other thirty-eight entirely. Lesson 0.13 develops this.

The general shape to carry forward: point lookups are latency-bound and love indexes; aggregations are bandwidth-bound and love columnar layouts. That single sentence is most of what separates transactional from analytical system design.

Knowledge check

A query filters a 500-million-row table down to about 200 million rows. The engine has an index on the filter column but ignores it and scans the table. Why is that most likely correct?

Key takeaway

Storage forms a ladder: faster means smaller and pricier, and each rung down is slower by orders of magnitude. Two consequences drive nearly every design choice ahead — databases work to cross fewer boundaries, and sequential access beats random access. Remember also that the buffer pool makes any single timing untrustworthy: always ask whether the run was cold or warm.

Lesson 0.3·12 min read

Files, Formats & Why CSV Is Not Enough

You will be able to say exactly what a file format guarantees, what CSV cannot promise, and why CSV remains the right choice more often than purists admit.

A Supply chain analyst receives a daily CSV from a haulage partner. On Tuesday the delivery report shows negative transit times and three depots that do not exist. Nothing failed. No error appeared. The file loaded perfectly, because a CSV cannot refuse anything.

Before a database can be justified, you need to know precisely what a plain file does and does not promise. Lesson 0.5 makes the full argument for databases; this lesson establishes the ground it stands on.

A file is a named sequence of bytes

That is the entire definition. The operating system tracks a name, a length, some timestamps, and where the bytes live. It has no idea whether those bytes are a photograph, a spreadsheet or noise. Fact The .csv extension is a hint to humans and to file-launcher software; it is not a guarantee and nothing verifies it.

All structure therefore comes from a format: a convention for arranging bytes so that a reader can recover meaning. Formats divide into two families, and the distinction matters more than any individual format.

  • Self-describing formats carry their own schema. The file states that column three is a 32-bit integer named quantity. Any reader recovers the same types. Parquet, Avro and ORC work this way.
  • Schema-less text formats carry only characters. Every reader must guess what the characters mean, and different readers guess differently. CSV works this way.
Analogy

CSV is a handwritten shopping list: universally readable, needs no equipment, and utterly dependent on shared assumptions. Does “2 cream” mean two tubs or two litres? Parquet is a labelled parts crate from a factory: heavier, needs a machine to open, but the manifest is bolted to the lid and every recipient reads the same numbers. Neither is better. You would not ship an engine block in an envelope, and you would not crate a shopping list.

The five things CSV cannot do

1. It has no types. Everything is text. The reader guesses, and guesses inconsistently — one tool reads 01 as the number 1, another as the string “01”. Product codes with leading zeros are destroyed in this way constantly.

2. It has no constraints. A quantity of −3, a date of 2024-13-45, a depot that does not exist: all accepted silently, which is exactly the haulage bug above.

3. It has no canonical spec. Delimiters vary (comma, semicolon, tab, pipe). So do quoting rules, escape characters, line endings, and whether the first row is a header. A file that opens correctly on one machine can be mangled on another.

4. It has no index and no column pruning. To read one column you must read every byte of every row, because rows are stored end to end and column boundaries are only discoverable by parsing.

5. It has no concurrency or atomicity. Two writers overwrite each other. A crash mid-write leaves a truncated file that still looks like a valid CSV, just shorter — a silent partial dataset, which is worse than an obvious error.

You can watch the type problem happen. TRY_CAST attempts a conversion and returns NULL instead of failing, which is exactly how a lenient loader behaves:

sqlcsv_type_guessing.sql
-- Four rows as they would arrive from a CSV: everything is text.
-- TRY_CAST returns NULL where the text cannot be interpreted.
SELECT raw_qty,
       TRY_CAST(raw_qty  AS INTEGER) AS parsed_qty,
       raw_date,
       TRY_CAST(raw_date AS DATE)    AS parsed_date
FROM (VALUES ('3',    '2024-01-08'),
             ('three','2024-01-08'),
             ('3',    '2024-13-45'),
             ('-3',   '08/01/2024')) AS t(raw_qty, raw_date);
Result
raw_qtyparsed_qtyraw_dateparsed_date
332024-01-082024-01-08
threeNULL2024-01-082024-01-08
332024-13-45NULL
-3−308/01/2024NULL

Three separate failures, three different flavours. “three” cannot be a number and becomes NULL. “2024-13-45” is not a real date and becomes NULL. “08/01/2024” is a perfectly valid date to a human but ambiguous to a machine — 8 January or 1 August? — and is rejected rather than guessed.

The worst row is the fourth column of the last line, and not for the reason you think. −3 parsed cleanly. It is a valid integer, so no loader will object; it is only invalid as a quantity. That distinction — syntactically valid, semantically impossible — is what a CHECK constraint exists to catch and what no file format can express.

💡Intuition — where does the schema live?

Every dataset has a schema. The only question is where it is stored. With CSV it lives in a colleague's memory, an email thread, or a loader script that someone must maintain. With Parquet it lives in the file. With a database it lives in the catalogue, is enforced on every write, and is queryable. Moving the schema from human memory into machine-enforced metadata is the through-line of this entire part of the course.

What a columnar file adds

Fact Parquet stores data column by column rather than row by row, carries the schema and per-column statistics in its own footer, and compresses each column independently. Three consequences follow directly: a reader can fetch two columns out of forty without touching the rest; compression is far more effective because similar values sit adjacent; and the reader recovers exact types with no guessing.

The cost is real too. Parquet is binary, so you cannot open it in a text editor or email it to someone in accounts and expect them to read it. It needs a library. It is awkward to append to a row at a time. Inference Parquet is optimised for machines reading large volumes; CSV is optimised for humans and heterogeneous tooling. Choosing between them is really choosing your reader.

Common pitfall — letting the loader infer the schema

Loaders infer types by sampling the first few thousand rows. If a postcode column happens to contain only digits in that sample, it is inferred as an integer — and then row 900,000 contains “EC1A 1BB” and the load fails, or worse, silently nulls it. Leading zeros vanish the same way. Inference Declare the schema explicitly for any file you load more than once. Inference is a convenience for exploration, never a production contract.

When CSV is the right answer

CSV survives every attempt to kill it, and not out of inertia. Reach for it when:

  • A human is the recipient. Anyone can open a CSV in any spreadsheet on any machine with no software to install. That is a real and undervalued property.
  • The system boundary is unknown. Handing data to a partner whose stack you cannot see, CSV is the safest bet precisely because it demands so little.
  • The dataset is small and one-off. Under a few hundred thousand rows read once, the format simply does not matter and CSV needs no ceremony.
  • You need to eyeball the raw bytes. When debugging an ingestion problem, being able to open the file and look is worth more than any efficiency.

Open The boundary keeps moving. Engines that read Parquet directly from cloud object storage have blurred the old line between “a file” and “a table”, and table formats layered on top of Parquet now add transactions and schema evolution to what is still, underneath, a directory of files. Whether that makes the file-versus-database distinction obsolete or merely relocates it is actively argued.

Interview angle

“When would you choose Parquet over CSV?” is common in data-engineering screens. The expected content: Parquet is columnar, typed, self-describing and compressed, so it wins on analytical scans and on type fidelity. What distinguishes a strong candidate is naming the reverse case without prompting — CSV for human recipients, unknown downstream tooling, and debuggability — and then framing the choice as “who or what reads this file?” rather than reciting a feature list.

Exercise 0.3

Healthcare A clinic exports appointments to CSV nightly. Columns: patient_ref (values like 00471), appointment_date, duration_mins, clinician_id. List three distinct ways this file can corrupt data silently, and for each name the mechanism a database would use to prevent it.

Show solution
  1. Leading zeros lost. patient_ref 00471 is inferred as the integer 471, and now no longer matches the clinic's records. Prevention: declare it as a text type. A database stores the declared type once and every reader gets it right; a CSV re-guesses on every load.
  2. Ambiguous dates. 08/01/2024 loads as 8 January in one locale and 1 August in another, so appointments silently move by months. Prevention: a DATE column stores a point in time, not its text rendering, so no locale interpretation happens at read time.
  3. Impossible values accepted. A duration_mins of −30, or a clinician_id that no longer exists. Prevention: a CHECK (duration_mins > 0) constraint and a foreign key from clinician_id to the clinician table. Both reject the row at write time rather than surfacing months later in a report.

You can demonstrate the third class of failure against the course dataset by checking whether any order references a customer that does not exist — the check a foreign key performs automatically on every insert:

sqlorphan_check.sql
SELECT COUNT(*) AS orders_with_no_matching_customer
FROM orders AS o
LEFT JOIN customers AS c ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL;

The answer is 0, because a foreign key has been enforcing it all along. In a CSV pipeline this query is something you must remember to run; in a database it is something you cannot violate.

Knowledge check

A CSV row contains a quantity of −3. The load completes with no warnings. What does this tell you?

Key takeaway

A file is bytes; a format is the convention that gives them meaning. CSV carries no types, no constraints, no canonical spec, no column pruning and no atomicity — so its schema lives in someone's head. Parquet moves the schema into the file; a database moves it into an enforced catalogue. Choose by asking who reads this, and remember that a value can be perfectly well-formed and still impossible.

Lesson 0.4·12 min read

What a Database Management System Actually Is

You will be able to name the five components inside a DBMS, say what each one protects you from, and stop treating the engine as a black box.

“The database is slow.” It is the most common sentence in data work and one of the least useful, because “the database” is not one thing. It is at least five distinct pieces of software stacked on each other, and the slowness belongs to exactly one of them. An engineer who can name the pieces asks a sharper question and finds the answer in minutes rather than days.

A database is the data. A database management system — DBMS — is the software that stands between you and the data and refuses to let you corrupt it. Everyone says “database” for both, which is fine in conversation and confusing when debugging.

The five components

flowchart TD
  Q["Your SQL text"] --> P["1. Parser
is this valid SQL?"] P --> C["2. Catalogue
do these tables and columns exist?"] C --> O["3. Optimiser
which plan is cheapest?"] O --> E["4. Execution engine
run the chosen plan"] E --> S["5. Storage manager
blocks, buffer pool, indexes"] E --> T["Transaction manager
locks, log, recovery"] T --> S
Five stages plus the transaction manager that shadows them all. “The database is slow” is nearly always a statement about box 3 or box 5.

1. The parser reads your text and checks it is grammatically valid SQL. It knows nothing about your tables. An error here is a typo — a missing comma, a stray bracket — and it is the cheapest error to get, because nothing has been touched yet.

2. The catalogue (or system catalogue, or data dictionary) is the database's own record of what exists: every table, every column, every type, every constraint, every index. Crucially, the catalogue is itself stored in tables, so you can query it with ordinary SQL. This is where your query is checked for meaning rather than grammar — does orders exist, does it have a column called status, are you comparing a date to a number?

sqlread_the_catalogue.sql
-- The database describing itself. Nothing here was written by hand:
-- it was recorded when the table was created.
SELECT column_name,
       data_type,
       is_nullable
FROM information_schema.columns
WHERE table_name = 'orders'
ORDER BY ordinal_position;
Result
column_namedata_typeis_nullable
order_idINTEGERNO
customer_idINTEGERYES
order_dateDATENO
statusVARCHARNO
ship_countryVARCHARYES

Look at what this gives you for free. Nobody documented these types in a wiki. The database knows them because it enforced them, and the enforcement and the documentation are the same object. That is the difference between a schema written down and a schema in force.

3. The optimiser is the interesting one. Your query says what you want; the optimiser decides how. For a join of three tables it might consider dozens of orderings, several join algorithms, and whether to use each available index. It estimates the cost of each option using statistics about the data — how many rows, how many distinct values, how they are distributed — and picks the cheapest estimate. Fact These are estimates, not measurements, which is why a plan can be badly wrong when the statistics are stale.

4. The execution engine carries out the chosen plan: scanning, filtering, joining, sorting, aggregating. This is where CPU time is actually spent.

5. The storage manager owns the bytes. It decides which block a row lives in, maintains indexes, and runs the buffer pool from Lesson 0.2. Beside it sits the transaction manager, holding locks and writing the log that makes crash recovery possible.

Analogy

A DBMS is a restaurant kitchen. You hand over an order (parser: is this something we serve?). The maitre d' checks the menu and today's stock (catalogue). The head chef decides the sequence — what to start first, which station does what, whether two dishes share a pan (optimiser). The line cooks execute (engine). The store room fetches ingredients in crates, never single carrots (storage manager). And the till records each completed order atomically, so a bill is never half-paid (transaction manager). You never specify the sequence of pans, and that is exactly the point: you order the dish, not the procedure.

Declarative is the whole trick

Nearly every programming language is imperative: you write the steps. SQL is declarative: you write the result you want and the system chooses the steps. Inference This is what lets a query written in 2015 still be correct in 2026 on a thousand times more data, running a plan nobody had invented when it was written. You never had to describe the plan, so the plan was free to change.

It also has a cost, and beginners feel it as frustration. When SQL is slow, you often cannot fix it by writing better steps — you have to persuade the optimiser to choose a different plan, by adding an index, refreshing statistics, or restructuring the query so a cheaper plan becomes available. The indirection that gives you longevity also takes away direct control.

Product analytics A concrete example: the query below asks for units sold per category. You have specified no join order, no algorithm, no memory budget. Three different engines will run it three different ways and all return the same answer.

sqlwhat_not_how.sql
SELECT c.department,
       c.category_name,
       SUM(oi.quantity) AS units
FROM order_items AS oi
JOIN products   AS p ON p.product_id  = oi.product_id
JOIN categories AS c ON c.category_id = p.category_id
GROUP BY c.department, c.category_name
ORDER BY units DESC, c.category_name;
Result
departmentcategory_nameunits
FootwearRunning Shoes10
ApparelJackets6
FootwearTrail Shoes6
OutdoorSleeping Bags5
AccessoriesWatches5
OutdoorTents4

Not all DBMSs are relational

The word covers a wider family than this course will use:

FamilyData shapeBought for
RelationalTables with a fixed schemaConstraints, joins, transactions, SQL
DocumentNested JSON-like recordsFlexible, evolving shapes
Key-valueOpaque value fetched by keyExtremely fast point lookups
GraphNodes and edgesRelationship traversal of unknown depth
Time seriesTimestamped measurementsAppend-heavy metrics, time-window queries

Inference Relational systems dominate analytics not because tables are the only sensible shape but because the relational model is the one with a rigorous mathematical foundation (Lesson 0.8), which is what makes automatic query optimisation possible in the first place. If you cannot reason about equivalent forms of a query, you cannot safely rewrite it — and if you cannot rewrite it, you have no optimiser.

Common pitfall — blaming the engine for a data problem

A query that ran in seconds last month now takes an hour, and the team requests a bigger machine. Frequently the real cause is stale statistics: the optimiser still believes a table holds ten thousand rows when it now holds ten million, so it chooses a plan that was optimal for the old size. Inference Before scaling hardware, inspect the plan. A plan change with unchanged SQL points at statistics or data distribution — and no amount of extra CPU will fix a fundamentally wrong plan.

When you do not want a DBMS at all

  • Embedded, single-process work. An analyst exploring a few Parquet files needs a query engine, not a server. In-process engines give you SQL with no daemon, no ports, no users to administer.
  • Pure caching. If you need a fast key-to-value store with no durability requirement, a full DBMS is enormous overhead for what an in-memory cache does better.
  • Archival. Data that will be read once in five years, if ever, belongs in cheap object storage as compressed files. Keeping it in a live database means paying for availability nobody will use.
  • Truly unstructured blobs. Video, images and raw documents belong in object storage, with only their metadata in a database.
In the field

When a performance complaint arrives, the productive move is to localise it to a component before touching anything. Is it slow to parse (essentially never)? Slow to plan (very large queries with many joins, or a planner defeated by hundreds of unions)? Slow to execute (a genuine volume problem)? Or slow to wait (blocked on a lock held by another session)? The last is the one most often misdiagnosed as slowness, because the query itself is doing nothing at all — and it is the only one where adding compute makes matters worse.

Interview angle

“Walk me through what happens when you run a SELECT” is a standard mid-level question, and it is a structure test as much as a knowledge test. Give the pipeline in order — parse, resolve against the catalogue, plan and cost, execute, fetch through the buffer pool — and then name the transaction manager as the component running alongside all of it. Candidates who volunteer that the optimiser works from estimates, and that stale statistics therefore cause plan regressions, tend to be the ones who have actually debugged a slow query rather than read about one.

Exercise 0.4

Manufacturing You have joined a plant analytics team and know nothing about their warehouse. Without asking anyone, use the catalogue to list every column of the products table with its type and whether it accepts missing values. Then state one thing the answer tells you about the business rules that no wiki page could tell you as reliably.

Show solution
sqldiscover_products.sql
SELECT column_name,
       data_type,
       is_nullable
FROM information_schema.columns
WHERE table_name = 'products'
ORDER BY ordinal_position;

The output shows unit_price and unit_cost as DECIMAL(10,2) and not nullable, while launched_on is a DATE that is nullable.

The business rule that reveals: a product cannot exist without a price and a cost, but it can exist without a launch date. That is a real statement about how the company works — products are set up in the system before they go on sale — and it is more trustworthy than any wiki page because it is not a description of the rule, it is the rule. A wiki can drift from reality; a NOT NULL constraint cannot, because any row that contradicted it was rejected at write time.

The decimal type carries information too: whoever built this schema knew that money must not be a float.

Knowledge check

A query's SQL text has not changed in a year, but it suddenly runs twenty times longer. Which component is the most likely culprit?

Key takeaway

A DBMS is not a box, it is a pipeline: parser, catalogue, optimiser, execution engine, storage manager — with a transaction manager running alongside. You write what, the optimiser decides how, and it decides from estimates rather than measurements. Localise every performance complaint to one component before changing anything, and remember that the catalogue is queryable — the schema documents itself because it enforces itself.

Lesson 0.5·13 min read

Why Databases Exist: The Problem With Files

Before you learn a single keyword, you should be able to argue why a database is worth its complexity — because for small problems, it isn't.

Start where every company starts: a spreadsheet. A shop sells running shoes and keeps orders.csv on a shared drive. One person edits it. It works perfectly, and for a long time there is no reason to change.

Then the business grows, and the file starts to fail in five specific ways. Each failure has a name, and each one is a feature a database was built to provide. Inference Learning databases as “a list of five solved problems” rather than “a list of keywords” is what lets you decide, later in your career, when not to reach for one.

The five failures of the shared file

1. Concurrency. Two people open orders.csv at 14:00. Amara adds order 1026 and saves at 14:03. Bruno, who opened the older copy, adds order 1027 and saves at 14:05. Bruno's save overwrites the whole file. Order 1026 is gone — silently, with no error, and nobody notices until the month-end reconciliation.

2. Integrity. Nothing stops a typo. A quantity of -3, a date of 2024-13-45, an order pointing at customer 9999 who does not exist. The file will accept all of it, because a file has no opinion about its contents.

3. Atomicity. Recording a sale means two changes: append to orders, decrement stock. If the laptop dies between them, you have sold a shoe you still believe is in the warehouse. There is no way to say “both of these or neither”.

4. Query cost. To answer “revenue by category last quarter” you must read every row, every time. At ten thousand rows that is instant. At fifty million it is a coffee break, and it gets slower every week.

5. Redundancy. The customer's name and country get typed into every order row. When Amara moves from the UK to Ireland you must find and fix every row — and if you miss one, the file now disagrees with itself. There is no longer a single answer to “where does Amara live?”

Analogy

A file is a notebook; a database is a bank. Anyone can write anything in a notebook, two people can tear out the same page, and a half-finished entry stays half-finished forever. A bank refuses impossible entries, records every movement as a completed pair (money left one account and arrived at another), serialises simultaneous requests, and can answer “what is the balance?” without re-reading every transaction since 1971. You pay for that with paperwork — and the paperwork is exactly what a schema is.

What you are actually buying

A database management system (DBMS) is software that sits between you and the bytes on disk and enforces guarantees the bytes cannot enforce themselves. The five failures map onto them one-for-one:

File failureDatabase mechanismCovered in
Lost updates from concurrent editsConcurrency control — locking or multi-version snapshotsLesson 0.11
Impossible or orphaned valuesConstraints — types, NOT NULL, CHECK, foreign keysLesson 4.1
Half-finished multi-step changesTransactions — all-or-nothing commitsLesson 0.11
Full scans for every questionIndexes & the query optimiserPart 12
The same fact stored many timesNormalisation — store each fact once, join on demandPart 7

E-commerce Here is the redundancy problem made concrete. Storing the customer's country on every order row means the same fact lives in many places. Storing it once in customers and joining means it lives in exactly one:

sqlone_fact_one_place.sql
-- The customer's country is stored ONCE, in customers.
-- Orders reference the customer by id and borrow the country when asked.
SELECT o.order_id,
       o.order_date,
       c.full_name,
       c.country
FROM orders  AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
ORDER BY o.order_date
LIMIT 5;
Result
order_idorder_datefull_namecountry
10012024-01-08Amara OseiGB
10032024-01-25Bruno SalgadoBR
10042024-02-09Chen WeiSG
10062024-02-20Dara NolanIE
10072024-03-09Elif DemirNULL

Change Amara's country in one row of customers and every one of her orders reports the new value immediately. That is not a convenience; it is the elimination of an entire category of bug.

Notice the last row: Elif Demir's country is NULL — she never supplied one. The join did not drop her row, it simply carried the absence through. Fact NULL means “no value recorded here”, which is different from an empty string and different from zero. It is the single most common source of subtle SQL bugs, and Lesson 1.1 is devoted to it.

flowchart LR
  A["Application
(asks a question)"] --> B["Query planner
(chooses HOW)"] B --> C["Execution engine
(does the work)"] C --> D["Storage + indexes
(bytes on disk)"] C --> E["Transaction manager
(all-or-nothing)"] E --> D
You state what you want; the planner decides how. That separation is the reason SQL has outlived every language it was designed alongside — your query keeps working when the data grows a thousandfold and the engine silently changes its strategy.
💡Intuition — declarative vs imperative

In most programming you write how: open the file, loop over lines, keep a running total. In SQL you write what: “total revenue per category, completed orders only”. The engine picks the loops, the order, the memory layout, and the parallelism — and picks differently next year when the table is a hundred times bigger. Inference This is why SQL feels strange at first if you already program: you are not being asked to describe a procedure, and fighting that instinct is most of the early learning curve.

When a database is the wrong choice

The honest answer, and one that separates engineers from keyword-memorisers: databases are frequently overkill. Reach for a file when:

  • The data is small and single-writer. A few thousand rows that one person edits — a spreadsheet is faster to build, easier to inspect, and needs no operations.
  • The data is write-once, read-sequentially. Application logs are appended and later scanned in bulk. Flat files (or Parquet) beat a database on both cost and throughput.
  • The shape changes constantly. Early-stage exploratory data whose fields change weekly fights a rigid schema. Document stores or plain JSON files may fit better until the shape settles.
  • You need one file to hand to someone. CSV is the universal interchange format precisely because it has no guarantees to violate.

Open The boundary is genuinely contested, and it has moved. Tools like DuckDB now run warehouse-grade analytical SQL directly over Parquet files with no server at all — which collapses much of the old “files versus databases” distinction. The five guarantees still describe what you gain; what has changed is how much infrastructure you must run to get them.

Common pitfall — “we'll add constraints later”

Teams routinely create tables with every column nullable and no foreign keys, promising to tighten them once things settle. They almost never do — because by then the table contains rows that violate the rules, and adding a constraint fails. Inference Constraints are cheapest on day one and get monotonically more expensive every day after, since the cost of adding one is proportional to the mess it must first reject. Decide deliberately, not by default.

In the field

When you join a company and open its warehouse, the fastest way to judge its maturity is to look for these five guarantees. Are there foreign keys, or only loose id columns? Is there one customers table, or six near-duplicates with names like customers_final_v2? Does anyone know which is authoritative? Duplicated, unconstrained tables are the file problem re-created inside a database — the tool was adopted, the discipline was not.

Interview angle

“Why use a database instead of files?” is a common opener, and weak candidates answer “it's faster” — which is often false, since a full scan of a CSV can beat a badly indexed table. Strong candidates name the guarantees: concurrency control, integrity constraints, atomic transactions, an optimiser, and single-source-of-truth storage. The strongest add the reverse case unprompted: “though for append-only logs or a hand-off file, I'd use Parquet or CSV.” Volunteering the limits of your own tool signals judgement rather than allegiance.

Exercise 0.5

Retail A shop tracks stock in stock.csv. Every evening a manager opens it, subtracts the day's sales, and saves. Two branch managers now do this from different sites.

(a) Name which of the five failures this design will hit first, and describe the exact sequence of events that loses data. (b) State which single database mechanism prevents it.

Show solution

(a) Concurrency — a lost update. The sequence: manager A opens the file at 18:00 (stock for SKU 5 reads 100). Manager B opens the same file at 18:01, also reading 100. A subtracts 12 and saves 88 at 18:05. B subtracts 7 from the copy they loaded and saves 93 at 18:06. B's write overwrites A's. True stock should be 81; the file says 93. No error is raised, and the discrepancy is only discovered at physical stock-take.

(b) Transactions with concurrency control. Wrapping read-modify-write in a transaction forces the two updates to serialise: the second either waits for the first to commit and then reads the updated 88, or is aborted and retried. Either way the arithmetic composes. Expressed in SQL, the fix is also to stop reading-then-writing in the application and let the database do the arithmetic atomically:

sqlatomic_decrement.sql
-- Both managers can run this simultaneously and the result is correct,
-- because the database reads and writes the row within one atomic step.
BEGIN;
UPDATE products
   SET unit_cost = unit_cost      -- stand-in for a stock column
 WHERE product_id = 105;
COMMIT;

The general rule: never compute a new value in your application from a value you read a moment ago. Express the change relative to the current stored value and let the engine serialise it.

Knowledge check

A team stores each customer's country on every order row. A customer relocates. What is the underlying problem?

Key takeaway

A database is not “a faster file”. It is five guarantees a file cannot give you: concurrency control, integrity constraints, atomic transactions, an optimiser, and single-source-of-truth storage. Every one of those has a cost in rigidity and operations. Choose it when you need the guarantees — and be able to say out loud when you don't.

Lesson 0.6·11 min read

A Short History: From Punch Cards to the Cloud

Every awkward corner of SQL is a fossil of the constraint that produced it. Learn the constraints and the language stops seeming arbitrary.

SQL is old, and it shows. Keywords are shouted in capitals. The clauses are written in an order the engine does not execute them in. NULL behaves unlike anything in any other language you will meet. None of this is bad design in a vacuum — each of it is a reasonable answer to a problem that no longer exists, preserved because too much depends on it to change.

History here is not decoration. It is the shortest route to predicting how a database will behave.

Storage that you could hold

Fact The earliest large-scale data processing used punched cards: stiff cards where the presence or absence of a hole at a position encoded a value. A card held one record. A dataset was a physical box of cards, and processing meant feeding them through a machine in order.

Three properties of that world shaped everything after it. Records were fixed-width, because a card had a fixed number of columns — which is why so many legacy systems pad values with spaces to a set length. Access was strictly sequential, because you fed the box in one end. And a dataset was a physical object, so “the data” and “the file” and “the program that reads it” were inseparable.

Magnetic tape replaced cards and kept the sequential constraint. Then disks arrived, and with them the ability to jump straight to an arbitrary position — random access. That is the hinge on which everything turns. Once you can go directly to a record without reading everything before it, indexes become possible, and once indexes are possible, a query can be answered without a full pass.

Analogy

Tape is a cassette and disk is a vinyl record. To hear track seven on a cassette you wind through tracks one to six. On vinyl you lift the needle and put it down where you like. Nothing about the music changed — but the whole idea of a playlist, of skipping, of shuffling, only becomes thinkable once you can drop the needle anywhere. Databases are what happened when data got the needle.

The navigational era, and its trap

The first true database systems, in the 1960s, were navigational. Records held physical pointers to other records, and a program traversed the structure by hand: fetch the customer, follow the pointer to their first order, follow the next-order pointer, and so on. Two families competed — hierarchical systems, where data formed a strict tree, and network systems, which allowed more general graphs of pointers.

They worked, and they were fast, and they had one crippling property. Fact The application code encoded the physical access path. If the storage layout changed — a new index, a reordering, a new relationship — every program that walked that path had to be rewritten.

Inference That is the cost that made the relational model worth adopting, and it is easy to underestimate today. It was not that navigational systems gave wrong answers; it was that asking a new question meant writing a new program, and changing the storage meant editing every old one. Analysis at the speed of thought was impossible.

Compare what the same question costs now. Below, three tables are combined without you stating a single access path — no pointers, no traversal order, no knowledge of how any of it is stored:

sqlno_pointers_needed.sql
-- In 1968 this required a program that walked pointers between records,
-- and a storage change would have broken it. Here it is a statement of intent.
SELECT c.department,
       COUNT(DISTINCT oi.order_id) AS orders_touching,
       SUM(oi.quantity)            AS units
FROM order_items AS oi
JOIN products    AS p ON p.product_id  = oi.product_id
JOIN categories  AS c ON c.category_id = p.category_id
GROUP BY c.department
ORDER BY units DESC;

The tables are connected by values — a product id that appears in two places — not by stored addresses. Change how any of the three tables is physically laid out and this query is unaffected. That property has a name, physical data independence, and it is the single largest idea in Lesson 0.7.

Codd, System R, and a language called SEQUEL

Fact In 1970 Edgar F. Codd, working at IBM, published a paper proposing that data be organised as mathematical relations — tables — and accessed by a language based on logic rather than on pointer traversal. IBM built a research prototype called System R to test whether the idea could be made fast enough to be practical, and the query language developed for it was called SEQUEL, later shortened to SQL.

Two things about that origin still shape your daily experience. SQL was designed to be readable by non-programmers, which is why it reads like stilted English and why keywords are words rather than symbols. And it was standardised early and widely, which is why every vendor's dialect is recognisably the same language — and why none of them can fix its rough edges without breaking working systems.

💡Intuition — why nothing gets fixed

Every widely-criticised feature of SQL could be improved in isolation. None can be improved in practice, because the value of the language is its ubiquity, and any incompatible improvement forfeits exactly the thing that makes it worth using. Inference This is why learning SQL is unusually good value for the time invested: a language that cannot change is a language whose knowledge does not expire. The syntax you learn this month will still be correct in twenty years.

Warehouses, the NoSQL detour, and separation

Through the 1980s and 1990s relational systems became the default for business record-keeping. Then a second need appeared: analysing the accumulated records rather than recording new ones. The transactional systems were tuned for small, frequent writes and buckled under large scans, so organisations built separate data warehouses — copies of the data restructured for reading. Lesson 0.12 treats that split properly.

In the 2000s, web-scale companies hit limits that relational systems of the day handled badly, and a wave of non-relational stores appeared under the loose label NoSQL, trading joins and strict consistency for horizontal scale. Inference The lasting outcome was not the replacement of SQL but its absorption of the lessons: most systems that launched without SQL have since added a SQL interface, because the query language turned out to be more valuable than the storage engine it was originally attached to.

The most recent structural change is the separation of storage and compute in cloud warehouses: data sits in cheap object storage while query engines are started and stopped independently. This changes the economics rather than the semantics — your SQL is unchanged, but the thing you are billed for is bytes scanned and seconds of compute, not a server you own.

Common pitfall — assuming old means obsolete

Newcomers routinely assume the newest tool must be the best one, and that SQL's age is evidence against it. The opposite inference is better supported: a language that has survived every generation of hardware, several waves of intended replacements, and a complete change in how storage is bought is demonstrating fitness, not inertia. Inference Judge a data tool by whether it solves your constraint, not by its release date — and be aware that the tool you are told is obsolete is often the one running the systems that pay for the new ones.

When history is the wrong guide

  • Precedent is not justification. “We have always denormalised this table” explains the current state, it does not defend it. Ask which constraint produced the decision and whether that constraint still holds.
  • Old benchmarks expire. Advice tuned for spinning disks — obsessive index minimisation, elaborate manual clustering — can be actively harmful on modern storage. The reasoning may be sound and the premises dead.
  • Some problems really are new. Streaming, semi-structured event data and vector search are not variations on 1970s problems, and forcing them into a purely relational frame produces awkward systems.
Interview angle

“Why did the relational model win?” occasionally appears in senior interviews as a proxy for whether you think in terms of trade-offs. The wrong answer is “tables are simpler”. The right one: navigational systems hard-coded the physical access path into application code, so any storage change broke every program and any new question required a new program. The relational model connected records by values rather than addresses, which bought physical data independence — and that independence is what made both automatic optimisation and ad-hoc querying possible.

Exercise 0.6

Banking A bank still runs a 1970s account system where each customer record contains a pointer to a chain of transaction records. Product wants a new report: “average balance change per customer segment, by month”. Explain what this costs in the navigational system versus a relational one, and identify precisely which property makes the difference.

Show solution

Navigational. Somebody writes a new program. It opens the customer file, walks each customer's transaction chain in stored order, accumulates per-month totals in memory, and joins to segment data by walking a second structure. It must be written, tested and deployed; it hard-codes the chain layout; and if anyone later reorganises the transaction chains for a different report, this program breaks silently or slowly. The report takes weeks and creates a permanent maintenance liability.

Relational. Somebody writes a query. Nothing is deployed, nothing else can break, and if the answer is wrong the fix is a one-line edit. The equivalent shape against the course dataset is a grouped aggregate over joined tables:

sqlsegment_by_month.sql
SELECT c.channel                          AS segment,
       date_trunc('month', o.order_date)  AS month,
       COUNT(*)                           AS orders
FROM orders    AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.channel, date_trunc('month', o.order_date)
ORDER BY segment, month;

The property. Physical data independence. Because the relational query names tables and columns rather than an access path, it is decoupled from how the data is stored — so a new question costs a query rather than a program, and a storage change costs nothing at all.

Knowledge check

What was the fundamental weakness of navigational databases that the relational model addressed?

Key takeaway

The path runs: fixed-width sequential records → random access on disk → navigational pointer-walking → Codd's relational model (1970, IBM) and SEQUEL/SQL from System R → warehouses → cloud separation of storage and compute. The decisive break was replacing stored addresses with matched values, which bought physical data independence. SQL's oddities are fossils of its era, and its inability to change is precisely why learning it does not expire.

Lesson 0.7·13 min read

Edgar Codd and the Relational Model

You will be able to state what a relation actually is, why row order and column order carry no meaning, and why that apparent restriction is the source of SQL's power.

An analyst writes a query, sees the rows come back in a pleasing order, and builds a report that depends on it. Weeks later the same query returns the same rows in a different order and the report is wrong. Nobody changed the query. Nothing is broken. The analyst was relying on a guarantee that was never made.

Understanding why that guarantee does not exist — and why refusing to make it was deliberate and correct — is understanding the relational model.

A relation is a set, and sets have no order

Fact In 1970, while at IBM, Edgar F. Codd published a paper proposing that data be represented as mathematical relations. A relation, in that sense, is a set of tuples, where each tuple has one value drawn from each of a fixed collection of named domains. In everyday language: a table with named, typed columns, whose rows form a set.

Three consequences follow immediately from “set”, and all three surprise beginners:

  • Rows have no order. A set is unordered by definition. Any order you observe is an accident of how the engine happened to execute the query, and it may change when the data grows, when the plan changes, or when the query runs in parallel.
  • Columns have no order. They are identified by name, not position. SELECT * presents them in a conventional order, but the model attaches no meaning to it.
  • Rows have no address. There is no “row 7”. The only way to identify a row is by its values — which is precisely why keys exist (Lesson 0.9).

The practical rule that falls out of this is absolute and worth learning before anything else: if you need a specific order, you must write ORDER BY. There is no other way, and every alternative you may be tempted by — insertion order, primary key order, “it comes out sorted anyway” — is an observation about one execution, not a property of the data.

sqlorder_is_not_free.sql
-- These two queries return the same SET of rows.
-- Only the second one guarantees the sequence you see.
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status;

SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY orders DESC, status;
Result (second query)
statusorders
completed21
cancelled2
returned2

Notice the tie-break. Two statuses have the same count, so ORDER BY orders DESC alone would leave their relative order undetermined — a partial sort is not a total one. Adding status as a second key makes the result deterministic: the same input always produces the same sequence. Inference This is why non-deterministic ordering is such a persistent source of flaky tests and unreproducible reports: it usually looks stable until the data or the plan changes.

Analogy

A relation is a bag of numbered raffle tickets, not a queue. If you ask “which tickets are in the bag?” the answer is a set, and it is the same answer however you tip them out. If you want them in numerical order you must sort them — and if you build a system on the assumption that they always fall out in order, it works right up until someone shakes the bag differently. The bag never promised anything. You inferred a promise.

The three rules Codd actually cared about

Codd's argument was not primarily about tables being tidy. It was about independence, and it rests on three separations:

1. Logical structure separated from physical storage. You describe data as tables and columns. The system decides files, blocks, indexes, compression, ordering. Change the physical layer and every query keeps working. This is physical data independence, and it is the property that killed navigational databases.

2. Data separated from the access path. Tables are linked by matching values — a customer_id that appears in two tables — never by stored addresses. So a relationship can be traversed in either direction, and relationships that nobody anticipated can be discovered later without changing anything.

3. What separated from how. You state the result you want. The optimiser chooses the procedure. This is only safe because relations have a rigorous algebra (Lesson 0.8) that tells the optimiser which rewrites preserve meaning.

Insurance The second point is the one that pays off fastest in practice. Suppose you want customers who have never placed an order. Nobody designed a “never ordered” relationship into the schema — and it does not matter, because the relationship can be computed from values:

sqlunanticipated_question.sql
-- Nobody stored a "has never ordered" link. It is derived on demand.
SELECT c.customer_id, c.full_name, c.channel
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL
ORDER BY c.customer_id;
Result
customer_idfull_namechannel
16Priya Ramanorganic
17Quentin Rouxpaid_social

Where the model and SQL part company

SQL is not a faithful implementation of the relational model, and the differences are exactly the places where beginners get hurt.

SQL tables are bags, not sets. A true relation cannot contain duplicate tuples; a SQL table can contain identical rows unless a constraint forbids it. This is why DISTINCT exists at all, and why an accidental duplicate in a join can inflate a total without any error being raised.

SQL has NULL. Codd recognised the need to represent missing information, but SQL's particular treatment — a three-valued logic where comparisons with NULL yield “unknown” rather than true or false — is widely criticised and universally implemented. Open Whether NULL was the right design remains genuinely contested among database theorists; what is not contested is that you must learn its behaviour, because every engine you will use has it. Lesson 1.1 is devoted to it.

SQL column order is visible. SELECT * and positional constructs expose an ordering the model says does not exist — which is one good reason to name your columns explicitly in anything that will run more than once.

Common pitfall — depending on incidental order

The failure mode is specific and common: a query without ORDER BY appears sorted, because on a small table the engine happens to scan in insertion order. The report is signed off. Later the table grows enough for the engine to parallelise the scan, and rows arrive in whatever order the workers finish. Inference Because the bug appears only at scale and only intermittently, it is unusually expensive to diagnose — and the cure is one clause written on day one. Add ORDER BY with enough columns to break every tie whenever the sequence is part of the answer.

When the relational model is the wrong frame

  • Order is intrinsic to the data. Time series, event streams, ranked sequences and document text are naturally ordered. You can model them relationally with an explicit sequence column, but you are re-adding what the model removed, and specialised stores may serve you better.
  • Deeply nested, irregular records. Data whose shape varies per record — a product catalogue where every category has different attributes — either explodes into many sparse columns or many tables. Document storage can be a better fit.
  • Recursive traversal of unknown depth. “Find every account connected to this one through any chain of shared addresses” is expressible in SQL with recursive queries, but a graph database is built for it.
  • Genuinely schemaless landing zones. Raw data whose structure you do not yet know is better landed as-is and structured later than forced into columns on arrival.
In the field

The set-versus-bag distinction causes more silent production errors than any other item in this lesson. A join whose key is not unique on one side duplicates rows on the other, and the query raises no error — it simply reports a larger number. Revenue figures that mysteriously grow after a “harmless” join are nearly always this. The habit worth building now: before joining, know whether the join key is unique in the table you are joining to, and if you are not sure, count the rows before and after.

Interview angle

“Is the output of a SQL query ordered?” looks trivial and is a filter. The answer is no unless ORDER BY is present — rows form a set, and any observed order is an artefact of execution. Strong candidates continue unprompted: ORDER BY must break ties to be deterministic; GROUP BY does not imply sorted output; and a SQL table is technically a bag rather than a set, which is why duplicates are possible and why DISTINCT exists. Naming the SQL-versus-model gap is what marks someone who has read about the theory rather than only used the tool.

Exercise 0.7

Gaming A studio's leaderboard query returns the top players by score with no ORDER BY, relying on the fact that scores “come out highest first”. It has worked for a year. (a) Explain why it worked and why it will eventually stop. (b) Write a correct version against the course dataset that ranks customers by number of completed orders, with a deterministic tie-break.

Show solution

(a) It worked because the engine happened to choose a plan — probably an index scan or a small sequential scan — whose natural output sequence matched the desired one. That is a property of the chosen plan, not of the data. It will stop when the table grows enough to trigger a parallel scan, when an index is added or dropped, when statistics shift and the optimiser picks a different join order, or when the query is run on a different engine. None of those changes touch the query, which is what makes the failure so confusing when it arrives.

sqldeterministic_leaderboard.sql
SELECT c.customer_id,
       c.full_name,
       COUNT(o.order_id) AS completed_orders
FROM customers AS c
LEFT JOIN orders AS o
       ON o.customer_id = c.customer_id
      AND o.status = 'completed'
GROUP BY c.customer_id, c.full_name
ORDER BY completed_orders DESC, c.customer_id
LIMIT 5;

(b) Two things make this correct. First, ORDER BY completed_orders DESC states the ranking explicitly instead of hoping for it. Second, c.customer_id breaks ties — and since customer ids are unique, the ordering is total, so the same data always yields the same sequence. Without that second key, every customer with an equal count could appear in any relative position, and a LIMIT 5 could return different people on different runs. Note also the AND o.status = 'completed' placed in the ON clause rather than a WHERE: that keeps customers with zero completed orders in the result with a count of 0, which is usually what a leaderboard's owner actually wants.

Knowledge check

Why does the relational model insist that rows are unordered?

Key takeaway

Codd's 1970 proposal at IBM was to represent data as relations — sets of tuples over named, typed domains. From “set” follows everything: no row order, no column order, no row addresses, and therefore ORDER BY or nothing. The purpose was independence — logical from physical, data from access path, what from how. SQL departs from the model in two ways you must remember: its tables are bags that permit duplicates, and it has NULL.

Lesson 0.8·14 min read

Relational Algebra: The Maths Under SQL

Six operators generate every query you will ever write. Learn them and SQL stops being a vocabulary list and becomes a small set of moves.

Two analysts write the same report differently. One uses a subquery, the other a join. The plans the engine produces are identical, and neither analyst finds that surprising — but neither can explain why it is safe for the engine to treat two different-looking queries as the same query.

The answer is that SQL sits on an algebra. No arithmetic is required to follow this lesson; “algebra” here means only a small set of operations that take relations in and give relations out, with provable rules about which rearrangements preserve the answer. Those rules are what an optimiser is allowed to do.

Closure: everything in, everything out

The foundational property is closure: every operator takes one or two relations and returns a relation. Nothing else. Inference This is why SQL composes so freely — a query can be nested inside a query, wrapped in a CTE, joined to another query, because the output of any of them is the same kind of thing as the input. Languages without closure force a different syntax at each level of nesting; SQL does not, and that uniformity is why complex queries stay readable.

The six operators

  SELECTION  (sigma)         PROJECTION (pi)
  keep some ROWS             keep some COLUMNS
  +---+---+---+              +---+---+---+
  | a | b | c |              | a | b | c |
  |###|###|###| <- kept      |###|   |###|
  | a | b | c |              |###|   |###|
  |###|###|###| <- kept      |###|   |###|
  +---+---+---+              +---+---+---+
   WHERE ...                  SELECT a, c

  CARTESIAN PRODUCT (x)      JOIN (bowtie) = product THEN selection
  every row of R with            R x S, keeping only pairs
  every row of S                 where the keys match
  R(2 rows) x S(3 rows)          R JOIN S ON R.k = S.k
      = 6 rows

  UNION / DIFFERENCE / INTERSECTION
  set operations on two relations of the SAME shape
  R UNION S       all rows in either
  R EXCEPT S      rows in R that are not in S
  R INTERSECT S   rows in both

Selection (σ) keeps rows satisfying a condition. In SQL: WHERE. Input one relation, output one relation with the same columns and fewer or equal rows.

Projection (π) keeps a subset of columns. In SQL: the SELECT list. Strictly, projection also removes the duplicate rows that dropping columns can create — which SQL does not do unless you write DISTINCT. That single divergence is the source of a great many duplicate-row surprises.

Cartesian product (×) pairs every row of one relation with every row of another. It is almost never what you want on its own, but it is the primitive from which joins are built. Its cardinality is exactly the product of the two inputs, which you can verify:

sqlcardinality_of_product.sql
-- |R x S| = |R| * |S|, always. A missing join condition is a
-- Cartesian product, and this is why it explodes.
SELECT (SELECT COUNT(*) FROM categories) AS categories_r,
       (SELECT COUNT(*) FROM products)   AS products_s,
       (SELECT COUNT(*) FROM categories CROSS JOIN products) AS cross_product;
Result
categories_rproducts_scross_product
61272

Six times twelve is seventy-two. Now imagine both tables holding a million rows: the product is a trillion. Inference This is exactly what happens when you forget a join condition, and it explains the characteristic symptom — not a wrong answer but a query that never finishes.

Join (⋈) is defined as a Cartesian product followed by a selection. Conceptually the engine pairs everything then discards non-matching pairs; in practice it never does that, because it uses hash tables or sorted merges to produce the same result without materialising the product. Fact That substitution is legal precisely because the algebra proves the two are equivalent, and it is one of the most valuable rewrites an optimiser performs.

Set operations combine two relations with identical column shapes. UNION, EXCEPT (difference) and INTERSECT. Marketing Difference answers “who is in this list but not that one” directly:

sqlset_difference.sql
-- Customers who exist but have never appeared in orders.
-- Set difference states the question almost word for word.
SELECT customer_id FROM customers
EXCEPT
SELECT customer_id FROM orders
ORDER BY customer_id;
Result
customer_id
16
17

The same two customers came back from a LEFT JOIN ... IS NULL in Lesson 0.7. Two different-looking queries, one answer — because they are the same algebraic expression written two ways. That is the entire point of the algebra.

Analogy

Relational algebra is Lego. There are only a handful of brick shapes, every brick connects to every other brick the same way, and the result of joining bricks is another thing you can join bricks to. Nobody memorises a catalogue of finished models — you learn the six shapes and the connection rule, and every model becomes constructible. SQL beginners who try to memorise query patterns are collecting finished models; learning the operators is learning the bricks.

Why this pays: predicate pushdown

Because the operators obey algebraic laws, the optimiser can rearrange your query into an equivalent, cheaper one. The most important such law is that selection can often be moved before a join.

flowchart LR
  subgraph W["What you wrote"]
    A1["orders"] --> J1["join on customer_id"]
    B1["customers"] --> J1
    J1 --> F1["filter: status = completed"]
  end
  subgraph R["What the engine runs"]
    A2["orders"] --> F2["filter: status = completed"]
    F2 --> J2["join on customer_id"]
    B2["customers"] --> J2
  end
Filtering before the join means fewer rows enter it. The answer is identical — the algebra proves it — but the work is smaller. The optimiser does this for you, which is why you should write for clarity first.

Both forms return the same rows. The second does less work, because the join processes only completed orders. You did not have to know this: you wrote what you wanted, and a proved-correct rewrite made it cheaper. Inference This is the concrete cash value of the theory, and it is also why “optimising” SQL by manually reordering clauses is usually wasted effort — the optimiser already explored those forms.

What the algebra does not cover

Codd's original algebra is smaller than SQL. Several things you use daily are extensions bolted on afterwards:

  • Aggregation. GROUP BY and SUM are not classical relational operators; they were added because business questions demand them.
  • Ordering. ORDER BY produces a sequence, which is not a relation. Strictly, it is the last thing a query does and its output has left the algebra.
  • Duplicates. The algebra works on sets; SQL works on bags. Every duplicate-row bug you meet lives in this gap.
  • Three-valued logic. NULL introduces “unknown” alongside true and false, which complicates the neat logical equivalences the algebra assumes.
Common pitfall — treating UNION as free

UNION removes duplicates, which requires the engine to sort or hash every row from both inputs. UNION ALL simply concatenates and is dramatically cheaper. Beginners reach for UNION by default and pay for de-duplication they did not need — often on inputs that cannot contain duplicates anyway. Inference Make UNION ALL your default and switch to UNION only when you have a specific reason to believe duplicates exist and must go.

When not to think algebraically

  • When writing your first draft. Express the business question plainly. Algebraic reasoning is a debugging and optimisation tool, not a drafting one.
  • When the engine is not relational. Document stores, search engines and graph databases have different primitives, and importing join-shaped thinking into them produces bad designs.
  • When the problem is data quality. No amount of algebra rescues a query whose inputs contain duplicated keys. Fix the data, not the expression.
Interview angle

“What is the difference between a Cartesian product and a join?” is a fast screen. A join is a Cartesian product followed by a selection on the join predicate; without the predicate you get every pairing, and the row count is the product of the inputs. Follow it with the diagnostic: an unexpectedly enormous result set or a query that never returns is the classic missing-join-condition signature. Candidates who then add that engines never actually materialise the product — they use hash or merge strategies that are provably equivalent — are demonstrating they understand why the theory matters rather than reciting a definition.

Exercise 0.8

Digital media Express “the countries we have customers in, but have never shipped an order to” twice: once with a set operator and once with a join. Then say which you would put in production and why.

Show solution
sqltwo_ways_one_answer.sql
-- Form 1: set difference. Reads like the question.
SELECT country FROM customers WHERE country IS NOT NULL
EXCEPT
SELECT ship_country FROM orders WHERE ship_country IS NOT NULL
ORDER BY country;

-- Form 2: anti-join. Same answer, more room to add columns later.
SELECT DISTINCT c.country
FROM customers AS c
LEFT JOIN orders AS o ON o.ship_country = c.country
WHERE c.country IS NOT NULL
  AND o.order_id IS NULL
ORDER BY c.country;

Both return FR and IN — France and India, the home countries of the two customers who have never ordered.

Which to ship. The EXCEPT form is clearer and states the question almost literally, so it is the better choice when the output is exactly one column and the requirement is stable. The anti-join form is more extensible: because both tables remain in scope, you can add columns from either side, count rows, or attach further filters without restructuring. Note also that EXCEPT de-duplicates for you, whereas the join form needs an explicit DISTINCT — the set-versus-bag gap showing up in practice. Inference Both compile to similar plans on most engines, so choose on readability and expected future edits rather than on guessed performance.

Knowledge check

A join between a 1,000-row table and a 5,000-row table returns 5,000,000 rows. What has almost certainly happened?

Key takeaway

SQL rests on six operators — selection, projection, Cartesian product, join, and the set operations — each taking relations in and giving relations out. That closure is why queries nest freely, and the algebraic laws are what license the optimiser to rewrite your query into a cheaper equivalent, such as pushing a filter below a join. Remember where SQL leaves the algebra: aggregation, ordering, duplicates and NULL are all extensions, and that is where the surprises live.

Lesson 0.9·13 min read

Tables, Rows, Columns, Keys & Domains

The vocabulary every later lesson assumes — and the one idea, grain, that causes more wrong numbers than any other single mistake.

A SaaS team reports monthly revenue that is roughly forty per cent too high. The query is short and looks obviously correct. The cause is not a bug in the SQL — it is that the author joined two tables holding facts at different levels of detail and summed a column that was then repeated. No error was raised, because nothing illegal happened.

That mistake has a name, grain, and it is the last idea in this lesson. The first four are the vocabulary you need to state it.

Four words, precisely

A table represents one kind of thing — customers, or orders, or line items. If you cannot finish the sentence “each row of this table is one ___” in a single noun, the table is doing more than one job.

A row is one instance of that thing. It has no position and no address; it is identified only by its values.

A column is one attribute, with a name and a type. Every value in it comes from the same domain.

A domain is the set of values a column is permitted to hold. This is broader than the data type: status is typed as text, but its domain is the three values completed, returned and cancelled. The type is enforced by the engine automatically; the rest of the domain must be enforced deliberately, with a CHECK constraint or a lookup table, or it is not enforced at all.

sqlinspect_the_domain.sql
-- What values does this column ACTUALLY contain?
-- Run this on any new table before you trust a filter on it.
SELECT status, COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY orders DESC, status;
Result
statusorders
completed21
cancelled2
returned2

Three values, cleanly. On a real system this query is how you discover that the domain also contains Completed, COMPLETE, an empty string and a handful of NULLs — each of which will be silently excluded by WHERE status = 'completed'. Inference Running this before writing any filter is a two-second habit that prevents a large class of quietly-wrong reports.

Analogy

A table is a filing cabinet drawer holding one kind of document. Each folder is a row. The printed fields on the form are the columns. The domain is what the form permits — a box marked “tick one: single / married / other” has a domain of three, even though physically you could scrawl anything in it. And the key is the reference number on the folder tab: the one thing that lets you say “that folder” without describing its contents.

Keys: how you name a row

Since rows have no address, identifying one requires values. A key is a column, or set of columns, whose value is unique across the table.

  • A candidate key is any set of columns that is unique and minimal — drop any column from it and uniqueness is lost. A table can have several: in customers, both customer_id and email could serve.
  • The primary key is the candidate key you nominate as the official identifier. It must be unique and must never be NULL, because “unknown” cannot identify anything.
  • A foreign key is a column whose values must exist as a key in another table. orders.customer_id references customers.customer_id, which is what makes an order pointing at a non-existent customer impossible.
  • A composite key spans several columns, used when no single column is unique — for example an assignment table keyed by customer and experiment together.
  • A surrogate key is a meaningless generated number used as the primary key, in preference to a natural key made of real-world values.

The surrogate-versus-natural choice is worth pausing on, because the argument is not obvious. An email address is a perfectly good natural key today — and then a customer changes it, and every table referencing it must be updated in step. Inference Natural keys embed a business rule you do not control into your physical structure. Surrogate keys are usually preferred for exactly that reason: they are meaningless, so they can never become wrong. The cost is an extra join whenever you want a human-readable label.

A key is a claim, and claims are testable:

sqltest_the_key_claim.sql
-- If total_rows and distinct ids differ, customer_id is NOT a key.
-- non_null_country shows a column that is nullable and therefore
-- can never serve as a primary key.
SELECT COUNT(*)                   AS total_rows,
       COUNT(DISTINCT customer_id) AS distinct_customer_ids,
       COUNT(country)              AS non_null_country
FROM customers;
Result
total_rowsdistinct_customer_idsnon_null_country
171715

Seventeen rows, seventeen distinct ids: the key claim holds. And note COUNT(country) returning 15, not 17 — COUNT(column) ignores NULLs while COUNT(*) counts rows. Two customers have no recorded country, which instantly disqualifies country from ever being part of a primary key.

Grain: the idea that prevents wrong numbers

The grain of a table is what one row means. Say it as a sentence:

  • customers — one row per customer.
  • orders — one row per order.
  • order_items — one row per product line within an order.

Now the failure. Order 1001 contains two line items. Join orders to order_items and order 1001 appears twice — correctly, since the result's grain is now one row per line item. Any order-level value carried along, such as a shipping charge, is now duplicated. Sum it and you have charged the shipping twice.

Inference The reliable rule: after any join, state the new grain out loud before aggregating anything. A join to a table with multiple matching rows changes the grain, and every column from the coarser side is now repeated. This is the forty-per-cent overstatement from the opening paragraph, and it is the most common serious error in analytics work.

Common pitfall — COUNT(*) after a join

“How many orders were completed?” answered as SELECT COUNT(*) FROM orders JOIN order_items ... returns the number of line items, not orders — and it looks entirely reasonable, because it is a plausible number rather than an absurd one. Inference Errors that produce implausible output get caught immediately; errors that produce plausible output reach the board pack. Use COUNT(DISTINCT order_id) when the grain has changed, and be suspicious of any COUNT(*) written after a join.

When to break the rules deliberately

  • Wide denormalised tables in a warehouse. Analytical layers routinely flatten many tables into one for speed and convenience, accepting the redundancy. That is a deliberate trade, not an error — provided the grain is documented and the source of truth lives elsewhere.
  • Natural keys in slowly-changing reference data. A currency code table keyed on the ISO code is fine; the values are externally governed and effectively immutable.
  • No enforced foreign keys in some warehouses. Several cloud warehouses do not enforce foreign keys at all, accepting them only as documentation and optimiser hints. Integrity then has to be enforced by tests in the pipeline instead. Open Whether that is a reasonable trade for load throughput or a step backwards is genuinely argued between engineering teams.
In the field

The single most useful question to ask when handed an unfamiliar table is “what is one row?”. If nobody on the team can answer in one sentence, that is your finding — you have located the source of the disagreements between reports before writing a line of SQL. A table whose grain nobody can state is a table where two people will compute different totals and both will be defensible.

Interview angle

“What is the difference between a primary key and a unique constraint?” appears constantly. Both enforce uniqueness; the primary key additionally forbids NULL, there is only one per table, and it is the identifier other tables reference. A unique constraint may permit a NULL — and the number of NULLs permitted differs between engines, which is a good detail to flag. The follow-up is often surrogate versus natural keys: answer that natural keys embed externally-controlled business rules into your structure, so a change you do not control forces updates everywhere the key is referenced.

Exercise 0.9

Advertising An analyst reports “we have 30 completed orders” using a query that joins orders to order_items. The true figure is 21. (a) Explain the error in terms of grain. (b) Write both the wrong and the corrected query.

Show solution

(a) The grain of orders is one row per order. Joining to order_items changes the grain to one row per line item, because an order containing three products matches three times. COUNT(*) then counts line items. It is not a wrong count — it is a correct count of the wrong thing, which is why it passed review.

sqlgrain_bug_and_fix.sql
-- WRONG: counts line items, because the join changed the grain.
SELECT COUNT(*) AS orders_wrong
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id;

-- RIGHT (a): count distinct orders, restoring the intended grain.
SELECT COUNT(DISTINCT o.order_id) AS orders_right
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'completed';

-- RIGHT (b): better still, do not change the grain at all.
SELECT COUNT(*) AS orders_right
FROM orders
WHERE status = 'completed';

The first returns 30, the number of line-item rows. The second and third both return 21.

Prefer the third. COUNT(DISTINCT ...) repairs the damage after the fact and is more expensive, since the engine must de-duplicate. Not joining at all is cheaper and clearer, and it removes the possibility of the same mistake resurfacing when someone later adds another column to the query. The general principle: join only when you need columns from the other table, and if you are joining purely to filter, consider EXISTS or a semi-join instead, which does not change the grain.

Knowledge check

Why is a surrogate key usually preferred over a natural key such as an email address?

Key takeaway

A table is one kind of thing, a row is one instance, a column is one attribute, and a domain is the values that attribute may take — which is narrower than its type and only enforced if you enforce it. Keys identify rows by value, since rows have no address; prefer surrogate keys because they cannot become wrong. Above all, know the grain: say what one row means before and after every join, because a join to a finer-grained table silently duplicates everything you carried in.

Lesson 0.10·14 min read

Normalisation From First Principles (1NF, 2NF, 3NF)

Derive the normal forms rather than memorise them — and learn the specific circumstances in which good engineers deliberately break them.

Normalisation is usually taught as three rules to memorise for an exam and then quietly ignore. That is backwards. The three forms are not rules; they are consequences of one goal, and if you hold the goal in mind you can re-derive them whenever you need them.

The goal: every fact should be stored in exactly one place. Everything else follows.

Why duplication is dangerous

Consider a FMCG team keeping one flat table of order lines, with the customer's name and country repeated on every row:

sqlthe_flat_table.sql
-- One table, everything in it. Note how Amara Osei and 'GB'
-- are repeated on every line she has ever bought.
WITH flat(order_id, customer_name, customer_country, product_name, unit_price, quantity) AS (
  VALUES (1001,'Amara Osei',   'GB','Velocity Road 7',  129.00,1),
         (1001,'Amara Osei',   'GB','Nimbus 0C Bag',    199.00,1),
         (1002,'Amara Osei',   'GB','Stormshell Jacket',219.00,1),
         (1003,'Bruno Salgado','BR','Ridgeline Trail 3',149.00,2)
)
SELECT * FROM flat ORDER BY order_id, product_name;
Result
order_idcustomer_namecustomer_countryproduct_nameunit_pricequantity
1001Amara OseiGBNimbus 0C Bag199.001
1001Amara OseiGBVelocity Road 7129.001
1002Amara OseiGBStormshell Jacket219.001
1003Bruno SalgadoBRRidgeline Trail 3149.002

“Amara Osei lives in GB” is one fact stored three times. That produces three named failures, and they are the entire justification for normalisation:

  • Update anomaly. Amara moves to Ireland. You must change three rows. Miss one and the table now asserts she lives in two countries, with no way to tell which is right.
  • Insertion anomaly. A new customer signs up but has not ordered. There is nowhere to record them, because a row requires a product and a quantity. The structure forbids a fact you need.
  • Deletion anomaly. Bruno's only order is deleted. His name and country vanish with it — you have lost a fact you never intended to delete.
Analogy

Writing a friend's phone number on the back of every photo of them. Cheap, convenient, and when they change number you must find every photo. Miss one and later you cannot tell which number is current — both are written in your own handwriting, and neither is dated. Normalisation is keeping one address book and writing the person's name on the photos. One more lookup when you want to call; one place to change when the number does.

Deriving the three forms

First normal form (1NF): each cell holds one value from its domain, and there are no repeating groups. A cell containing “shoes, jacket, tent” violates it, as do columns named product_1, product_2, product_3. The reason is practical: you cannot filter, join or aggregate on a value buried inside a delimited string without parsing it first, and any limit like “three products” is an arbitrary cap that reality will exceed.

Second normal form (2NF): 1NF, plus every non-key column depends on the whole primary key. This only bites when the key is composite. If a table is keyed by (order_id, product_id) and also holds customer_name, then the name depends on order_id alone, not on the pair — so it is repeated once per product on the order. Split it out.

Third normal form (3NF): 2NF, plus no non-key column depends on another non-key column. If a table keyed by product_id holds both category_id and category_name, the name depends on the category, not on the product. Every product in a category repeats the name. Split it out.

The traditional summary is that every non-key column must depend on the key, the whole key, and nothing but the key. That is a mnemonic for the three paragraphs above, not a substitute for them.

Decomposition, drawn

  BEFORE  one flat table, facts repeated
  +-------------------------------------------------------------+
  | order_id | cust_name | cust_country | product | price | qty  |
  |   1001   | Amara     |      GB      | Shoe    | 129   |  1   |
  |   1001   | Amara     |      GB      | Bag     | 199   |  1   |  <- 'Amara lives in GB'
  |   1002   | Amara     |      GB      | Jacket  | 219   |  1   |  <-  stored 3 times
  +-------------------------------------------------------------+

  AFTER   each fact once, linked by keys

   customers                 orders                   order_items
  +-------------+          +-----------------+      +---------------------+
  | customer_id |<---------| customer_id     |<-----| order_id            |
  | full_name   |          | order_id  (PK)  |      | product_id  ------+ |
  | country     |          | order_date      |      | quantity          | |
  +-------------+          +-----------------+      | unit_price        | |
   one row per                one row per           +-------------------|-+
   customer                   order                                     |
                                                       products <-------+
                                                      one row per product

The same information, with each fact in exactly one place. Amara's country now lives in a single cell. Change it and every order reports the new value, because no order ever stored it. The cost is that answering the original question now requires joins:

sqlrebuild_the_flat_view.sql
-- The normalised tables reconstruct the flat table on demand.
-- Same rows as before, but no fact is duplicated in storage.
SELECT o.order_id,
       c.full_name  AS customer_name,
       c.country    AS customer_country,
       p.product_name,
       oi.unit_price,
       oi.quantity
FROM orders       AS o
JOIN customers    AS c  ON c.customer_id = o.customer_id
JOIN order_items  AS oi ON oi.order_id   = o.order_id
JOIN products     AS p  ON p.product_id  = oi.product_id
WHERE o.order_id IN (1001, 1002, 1003)
ORDER BY o.order_id, p.product_name;
Result
order_idcustomer_namecustomer_countryproduct_nameunit_pricequantity
1001Amara OseiGBNimbus 0C Bag199.001
1001Amara OseiGBVelocity Road 7129.001
1002Amara OseiGBStormshell Jacket219.001
1003Bruno SalgadoBRRidgeline Trail 3149.002

Identical output. The difference is invisible in the result and decisive in the storage: in the flat version, correcting Amara's country means finding and fixing three rows; in the normalised version it means changing one cell.

💡Intuition — the trade you are making

Normalisation moves cost from write time to read time. Writes become cheap and safe, because each change touches one row and cannot leave the data self-contradictory. Reads become more expensive, because reassembling a complete picture requires joins. Inference This is why transactional systems normalise heavily and analytical systems often do not: which cost you would rather pay depends entirely on whether your workload is dominated by writes or by reads. The forms are not a standard of quality, they are one side of a trade.

When to denormalise on purpose

Deliberate, documented denormalisation is normal professional practice, not a failure. The recognised cases:

  • Analytical star schemas. A wide fact table joined to a few denormalised dimension tables is the standard warehouse design. Reads massively outnumber writes, and the writes come from a controlled pipeline rather than from users, so the update anomaly risk is managed elsewhere.
  • Point-in-time facts that must not change. order_items.unit_price duplicates products.unit_price — and it must. The price paid on the day is a different fact from the price today, and if the order borrowed the current price by joining, every historical order would silently rewrite itself at the next price change. Inference This is the single most important exception to grasp: not all repeated values are redundant. Two values that happen to be equal are only redundant if they represent the same fact.
  • Expensive joins on hot paths. Storing a pre-computed aggregate or a duplicated label to avoid a join in a query that runs constantly is a legitimate optimisation — provided something guarantees the copy stays in step.
  • Immutable reference data. A country code that will never be edited can safely be copied around, since the anomaly it risks cannot occur.
Common pitfall — normalising the historical record away

A tidy-minded engineer notices that order_items.unit_price duplicates products.unit_price, removes it as redundant, and joins to products to recover it. Every historical order is now valued at today's price, so last year's revenue changes whenever pricing does — and because the reports still run and still look reasonable, the corruption may go unnoticed for months. Inference Before deleting a duplicated column, ask whether the two values represent the same fact at the same point in time. If one is a snapshot, it is not redundancy, it is history.

Interview angle

“Explain 3NF” is asked constantly, and reciting “the key, the whole key, and nothing but the key” is a pass at best. Better: state the goal — each fact stored once — then name the three anomalies it prevents (update, insertion, deletion), then give the forms as consequences. The differentiator is volunteering when you would denormalise: star schemas for read-heavy analytics, and point-in-time snapshots like the price paid on an order. Candidates who present normalisation as an unconditional good are usually the ones who have not designed a warehouse.

Exercise 0.10

Healthcare A clinic keeps one table: appointment_id, patient_name, patient_nhs_number, clinician_name, clinician_specialty, appointment_date, room, notes. (a) Identify the normal form violations. (b) Propose a decomposition. (c) Name one column you would deliberately leave denormalised, and justify it.

Show solution

(a) Violations. The primary key is appointment_id. Several columns do not describe the appointment at all: patient_name and patient_nhs_number describe the patient, and clinician_name and clinician_specialty describe the clinician. Worse, clinician_specialty depends on clinician_name — a non-key column determined by another non-key column, which is the textbook 3NF violation. The consequences are the three anomalies: renaming a patient means updating every appointment they have ever had; a newly registered patient with no appointment yet cannot be recorded; and deleting a clinician's last appointment erases their specialty from the system.

(b) Decomposition. Three tables. patients (patient_id, name, nhs_number) — one row per patient. clinicians (clinician_id, name, specialty) — one row per clinician, which places specialty next to the thing it actually describes. appointments (appointment_id, patient_id, clinician_id, appointment_date, room, notes) — one row per appointment, referencing the other two by foreign key. Every fact now has exactly one home, and each of the three anomalies becomes structurally impossible.

(c) Leave denormalised: the clinician's specialty as recorded at the time of the appointment. If a clinician later changes specialty, joining to clinicians would retrospectively rewrite the history of every past appointment, and any audit of “which specialty saw this patient in March” would return the wrong answer. Storing a snapshot on the appointment row looks like redundancy but is not — it is a different fact, pinned to a different point in time, exactly as order_items.unit_price is in the course dataset. The test to apply is always the same: same fact, or same value?

Knowledge check

Why does order_items store unit_price when products already has a unit_price?

Key takeaway

Normalisation has one goal: store each fact exactly once, so that the update, insertion and deletion anomalies become impossible. The three forms are consequences — 1NF one value per cell, 2NF depend on the whole key, 3NF depend on nothing but the key. The trade is cheap safe writes for more expensive reads, which is why transactional systems normalise and analytical systems often do not. And before removing any duplicated column, ask same fact, or same value? — a point-in-time snapshot is history, not redundancy.

Lesson 0.11·13 min read

Transactions & ACID Explained Without Jargon

Four guarantees, each answering a specific way that concurrent or interrupted work destroys data. You will be able to say what each one buys and what it costs.

A Banking transfer moves £500 from one account to another. That is two changes: subtract from one, add to the other. If the machine fails between them, £500 has left the world. No amount of careful programming prevents this, because the failure happens between two instructions and there is no instruction you can put in the gap.

A transaction is the answer: a group of changes the database treats as a single indivisible unit. Either all of them are recorded or none are. ACID is the four properties transactions provide, and each letter answers a distinct disaster.

A — Atomicity: all or nothing

An atomic unit cannot be split. The transfer either completes fully or leaves no trace. There is no state in which the money is nowhere.

The mechanism is worth knowing because it explains several behaviours you will observe. Before changing anything, the database writes its intention to a write-ahead log on durable storage. Only then does it modify data. If it crashes mid-transaction, on restart it reads the log, sees a transaction that started but never committed, and undoes its partial effects. Fact This is why databases can survive an unexpected power loss without corruption, and why the log is written before the data rather than after.

sqlatomic_unit.sql
-- BEGIN opens a transaction. Nothing inside it is visible to
-- anyone else, and ROLLBACK discards the lot as if it never ran.
BEGIN;
UPDATE orders SET status = 'returned' WHERE order_id = 1002;
ROLLBACK;

-- The row is untouched: the change never existed for any other session.
SELECT order_id, status FROM orders WHERE order_id = 1002;
Result
order_idstatus
1002completed

Replace ROLLBACK with COMMIT and the change becomes permanent and visible to everyone at that instant — not gradually, not partially. The commit is the moment the transaction happens.

C — Consistency: the rules always hold

A transaction moves the database from one valid state to another valid state. “Valid” means every declared constraint is satisfied: no negative quantities where a CHECK forbids them, no order referencing a customer who does not exist, no duplicate primary key. If a transaction would leave the data in a state that breaks a rule, the whole transaction is rejected.

Inference The C is the letter people most often misunderstand, because it does not mean “your data is correct”. The database has no idea what correct means. It means “the rules you declared are never violated” — so consistency is only as strong as the constraints you bothered to write, which is the argument Lesson 0.5 makes for writing them on day one.

I — Isolation: concurrent work behaves as if serial

Many transactions run at once. Isolation is the guarantee that each behaves as though it had the database to itself. Without it, transactions read each other's half-finished work and the classic anomalies appear:

AnomalyWhat happens
Dirty readYou read a change another transaction later rolls back — you acted on data that never existed.
Non-repeatable readYou read a row twice in one transaction and get different values, because someone committed in between.
Phantom readYou run the same filtered query twice and new rows have appeared that match it.
Lost updateTwo transactions read the same value, each computes a new one, and the second write silently discards the first.

Isolation is not one setting but a ladder of levels, each preventing more anomalies at a higher cost in blocking and retries. Fact The strongest level, serializable, guarantees the outcome is equivalent to running the transactions one after another in some order; weaker levels permit specific anomalies in exchange for more concurrency. Fact Default levels differ between engines, so a program correct on one database can be subtly wrong on another without any code change.

D — Durability: committed means committed

Once a commit returns successfully, the change survives crashes, power loss and restarts. It is on durable storage, or at least in a log on durable storage from which it can be replayed. Inference This is why commits are not free: the system must genuinely persist the log before reporting success, which involves waiting for physical storage rather than merely for memory. Committing once per row in a large load is therefore dramatically slower than committing once per batch, and that single fact explains most slow bulk-insert problems.

Analogy

A wedding ceremony. Atomicity: both parties say yes or nobody is married — there is no half-married. Consistency: the registrar refuses if a rule is broken, such as an existing marriage. Isolation: two ceremonies in adjacent rooms cannot mix up their vows, however simultaneous they are. Durability: once entered in the register it stays entered, even if the building burns down, because the register is the record and not the memory of the people present.

What it costs

Every guarantee is paid for, and the bill arrives as reduced concurrency.

  • Locks and waiting. Isolation is usually implemented by locking rows or by keeping multiple versions of them. Either way, transactions sometimes wait for each other, and throughput falls.
  • Deadlocks. Two transactions each holding what the other needs will wait forever, so the database detects the cycle and kills one. Your application must be prepared to retry — a transaction that fails is a normal event, not an exceptional one.
  • Long transactions are toxic. A transaction held open for minutes blocks others and forces the system to retain old row versions. Keep them short: do the slow work outside, then open, change, commit.
  • Commit latency. Durability requires waiting for storage. Batch your writes.
Common pitfall — read-modify-write in the application

The most common concurrency bug in commercial software: read a value into the application, compute a new one, write it back. Two users doing this simultaneously both read the old value and the second write silently overwrites the first — a lost update, with no error raised anywhere. Inference The fix is not more locking but a change of expression: state the change relative to the stored value, as SET balance = balance - 500 rather than SET balance = 4500, so the database performs the read and the write as one indivisible step. Wherever you can express a change relatively, do.

When you do not need full ACID

  • Append-only analytics loads. A nightly pipeline writing immutable event rows has no concurrent updaters, so most of the machinery protects against risks that cannot occur.
  • Caches and derived data. Anything reconstructible from a source of truth does not need durability — if it is lost you rebuild it.
  • Very high-volume distributed writes. Some systems deliberately relax isolation or offer eventual consistency to achieve scale and availability that strict guarantees would forbid. Open How far to relax is one of the genuinely contested questions in distributed systems, and the honest answer is that it depends on whether your business can tolerate a temporarily wrong answer — a social media like count can, a bank balance cannot.
  • Single-user analysis. An analyst querying a local file has no concurrency to control.
In the field

Deadlocks and lock waits are almost always a symptom of transaction scope rather than of load. The recurring pattern: someone opens a transaction, calls an external payment API inside it, and holds locks for the several seconds that call takes. Under light traffic nobody notices; at peak the whole system queues behind it. The fix is structural — do the external work first, then open a short transaction to record the outcome — and it is nearly always cheaper than the extra hardware that gets proposed instead.

Interview angle

“Explain ACID” is nearly universal, and the expansion alone is not a passing answer since it is on every flashcard. Give each letter with the failure it prevents: atomicity against partial writes, consistency against constraint violations, isolation against concurrent interference, durability against crashes after commit. Then add what most candidates omit — that consistency means your declared rules rather than correctness, that isolation is a ladder of levels whose default differs by engine, and that all four are paid for in concurrency. If asked for a practical example, describe the lost update and fix it with a relative update.

Exercise 0.11

E-commerce Two customers buy the last unit of a product at the same instant. The application reads stock (1), checks it is greater than zero, and writes stock − 1. Both succeed and stock is now 0, but two units were sold. (a) Name the anomaly. (b) Give two distinct fixes and say which you would ship.

Show solution

(a) A lost update, caused by a read-modify-write cycle spanning two round trips. Both transactions read 1, both compute 0, and the second write overwrites the first rather than building on it. The stock figure is coincidentally correct; the number of units shipped is not.

Fix 1 — relative update with a guard. Express the change against the stored value and let the condition do the checking, so the read and the write are one atomic step:

sqlrelative_update.sql
-- The WHERE clause is evaluated at write time, inside the same
-- atomic step as the update. If stock is already 0, zero rows change
-- and the application knows the sale failed.
BEGIN;
UPDATE products
   SET unit_cost = unit_cost - 1     -- stand-in for a stock column
 WHERE product_id = 105
   AND unit_cost >= 1;
ROLLBACK;

Crucially, the application must check how many rows were affected. Zero rows means the guard rejected the sale, and that is not an error condition — it is the answer.

Fix 2 — a stricter isolation level, or an explicit lock on the row before reading it. Under serializable isolation the second transaction is aborted and can be retried, and the retry reads the updated value. This is correct but more expensive: it costs blocking or retries, and it requires the application to handle a failed transaction gracefully.

Ship fix 1. It is cheaper, needs no isolation-level change, is portable across engines, and it makes the concurrency-safe path the natural one to write. Raising the isolation level is the right tool when the logic genuinely cannot be expressed as a single relative statement — for example when the decision depends on aggregating several rows — but reach for it second, not first.

Knowledge check

What does the “C” in ACID actually guarantee?

Key takeaway

A transaction is a group of changes treated as one indivisible unit. Atomicity prevents partial writes, consistency enforces your declared rules, isolation stops concurrent transactions interfering, durability survives crashes after commit. All four are paid for in concurrency, so keep transactions short, batch your commits, expect to retry on deadlock — and wherever possible express a change relative to the stored value rather than reading it and writing it back.

Lesson 0.12·12 min read

OLTP vs OLAP: Two Opposite Shapes of Work

One system cannot be optimal for both running the business and analysing it. Learn why the split exists and you will understand every warehouse you ever meet.

An analyst at a Retail company runs a full-year revenue report against the live shop database at four in the afternoon. Checkout slows to a crawl. Nobody did anything wrong — the report is legitimate, the database is healthy, and the two workloads are simply incompatible.

The incompatibility is structural rather than accidental, and understanding it explains why nearly every organisation ends up with two systems and a pipeline between them.

Two shapes

OLTP — online transaction processing — is the system that runs the business. Place an order, update an address, take a payment. Its queries touch very few rows, identified precisely, and both read and write. There are many of them, they are concurrent, and each must return in milliseconds because a person is waiting.

OLAP — online analytical processing — is the system that studies the business. Revenue by category by month, cohort retention, campaign effectiveness. Its queries touch enormous numbers of rows but few columns, are read-only, and are allowed to take seconds.

OLTPOLAP
Typical queryFetch or update one orderAggregate millions of orders
Rows touchedOne, or a handfulVery many
Columns touchedAll of themFew of them
Reads vs writesMixed, write-heavyAlmost entirely reads
ConcurrencyVery highLow
Latency expectedMillisecondsSeconds to minutes
Data modelNormalisedOften denormalised (star schema)
Storage layoutRow-orientedColumn-oriented

Read the last two rows together with Lesson 0.2 and the conflict becomes obvious. OLTP wants a whole row available in one block, because it needs every column of one order. OLAP wants one column available across all rows, because it needs one field from millions of orders. Inference These are opposite physical layouts, and no single arrangement of bytes on disk is good at both. That, more than anything about software, is why the two systems separate.

Compare the two shapes directly. A point lookup:

sqloltp_shape.sql
-- OLTP: one row, every column, found by key. Must be instant.
SELECT order_id, customer_id, order_date, status
FROM orders
WHERE order_id = 1015;
Result
order_idcustomer_idorder_datestatus
1015102024-06-04completed

And an analytical aggregate over the same table:

sqlolap_shape.sql
-- OLAP: every relevant row, a handful of columns, grouped.
-- No single row matters; only the aggregate does.
SELECT date_trunc('month', o.order_date) AS month,
       COUNT(DISTINCT o.order_id)        AS orders,
       ROUND(SUM(oi.quantity * oi.unit_price
                 * (1 - oi.discount_pct / 100.0)), 2) AS revenue
FROM orders      AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY date_trunc('month', o.order_date)
ORDER BY month;
Result (first six months)
monthordersrevenue
2024-01-012591.20
2024-02-012617.00
2024-03-012473.15
2024-04-011219.00
2024-05-012547.30
2024-06-013945.00

Note the COUNT(DISTINCT o.order_id): the join to order_items changed the grain, exactly as Lesson 0.9 warned, so a plain COUNT(*) would have counted line items. Note also / 100.0 rather than / 100 — integer division truncates, and a 15% discount would silently become 0%.

Analogy

A shop till versus the stocktake. The till serves one customer at a time, must never be slow, and touches one basket. The stocktake counts everything in the building, takes hours, and nobody expects an answer in between. You could do the stocktake through the till — scanning every item in the shop, one at a time, at the counter — and this is precisely what running a report on a production database amounts to. The queue is the outage.

What the split looks like in practice

flowchart LR
  A["Shop app
(OLTP database)"] -->|"extract"| B["Pipeline
(clean, reshape)"] B -->|"load"| C["Warehouse
(OLAP, columnar)"] C --> D["Dashboards"] C --> E["Ad-hoc analysis"] C --> F["Models & reporting"]
The pipeline is the boundary. Everything to its left is optimised for writes and correctness; everything to its right for reads and scale. Analysts almost always work on the right.

Three consequences follow that shape every data job:

  • The warehouse is behind. Data arrives on a schedule, so the warehouse lags the production system by minutes or hours. “Why doesn't this match the app?” is usually answered by the pipeline schedule, not by a bug.
  • The warehouse is reshaped. Normalised production tables become denormalised analytical ones. Column names and even meanings can differ, which is why lineage documentation matters.
  • The warehouse is not the source of truth. If they disagree, the production system is right by definition. The warehouse is a derived copy.
Common pitfall — running analytics on the production replica

A read replica is often offered as a compromise: analysts get their own copy and cannot slow down checkout. It solves the contention problem and not the others. The replica is still row-oriented, still normalised, still indexed for point lookups — so large aggregates remain slow, and long-running analytical queries can delay replication itself, making the replica fall behind. Inference A replica addresses who the work competes with, not what shape the work is. It is a reasonable interim step and a poor destination.

When one system is enough

  • Small data. If the whole database fits comfortably in memory, analytical queries may finish fast enough on the transactional system that the split adds only complexity and lag. Do not build a warehouse for a few gigabytes.
  • Early-stage products. A pipeline is a permanent maintenance commitment. Before the questions have stabilised, querying production carefully off-peak is often the right call.
  • Genuinely hybrid engines. Open Some modern systems aim to serve both workloads from one store, keeping row and column representations in step internally. Whether this makes the split obsolete for mainstream use or remains a specialised choice is an open question and actively marketed in both directions — treat vendor claims here with more scepticism than usual.
  • Read-only reporting on a static dataset. With no writes, there is no contention to avoid.
Interview angle

“What is the difference between OLTP and OLAP?” is a standard opener for data roles. Do not simply list adjectives. Give the query shapes — few rows and all columns versus many rows and few columns — and then draw the conclusion that these demand opposite physical layouts, row-oriented against column-oriented, which is the real reason the systems separate. Strong candidates then name the practical consequences an analyst lives with: the warehouse lags, it is reshaped, and it is not the source of truth.

Exercise 0.12

Telecommunications Classify each as OLTP or OLAP and justify by query shape: (a) a customer checking their current bill in the app; (b) monthly churn rate by tariff and region for the last three years; (c) activating a new SIM; (d) finding which network cells had the highest dropped-call rate last quarter.

Show solution

(a) OLTP. One customer, one billing period, every column of those rows, and a person is waiting. Indexed point lookup, milliseconds.

(b) OLAP. Every subscriber over three years, but only four columns — tariff, region, status, date. Enormous row count, tiny column count, no writes, and nobody minds waiting ten seconds. The textbook columnar workload.

(c) OLTP. A write, and one that must be atomic: the SIM record, the subscriber link and the provisioning entry either all succeed or none do. This is precisely what transactions exist for.

(d) OLAP. Aggregation over a very large volume of call records, grouped by cell, filtered to a quarter. Note it would be actively dangerous to run on the production call-handling system — the scan would compete with live call setup, which is the scenario in this lesson's opening paragraph.

The general test to apply: how many rows, how many columns, and is anyone waiting? Few rows and all columns with someone waiting is OLTP. Many rows and few columns with nobody waiting is OLAP. The awkward middle — an operational dashboard needing near-real-time aggregates — is genuinely hard, and is what streaming systems and hybrid engines exist to address.

Knowledge check

Why can a single database not be optimal for both OLTP and OLAP workloads?

Key takeaway

OLTP runs the business; OLAP studies it. OLTP touches few rows and all their columns, is write-heavy and latency-critical; OLAP touches many rows and few columns, is read-only and throughput-critical. They demand opposite physical layouts, which is why organisations run two systems with a pipeline between them — and why the warehouse always lags, is reshaped, and is never the source of truth.

Lesson 0.13·13 min read

Row Storage vs Columnar Storage

One decision — which values sit next to each other on disk — produces every performance difference between a transactional database and a warehouse.

The same query, the same data volume, the same hardware: on one engine it takes minutes, on another it returns immediately. No index explains it. The difference is which values were stored adjacent to which.

Recall from Lesson 0.2 that storage transfers whole blocks, never individual values. That single constraint means the arrangement of values inside a block determines how much of what you read is useful — and how much you paid to move and then discard.

The two layouts

  LOGICAL TABLE
  order_id | customer_id | order_date  | status    | ship_country
  ---------+-------------+-------------+-----------+-------------
    1001   |      1      | 2024-01-08  | completed | GB
    1002   |      1      | 2024-03-12  | completed | GB
    1003   |      2      | 2024-01-25  | completed | BR
    1004   |      3      | 2024-02-09  | completed | SG

  ROW STORE  -- one row's values are contiguous
  block 1: [1001,1,2024-01-08,completed,GB][1002,1,2024-03-12,completed,GB]
  block 2: [1003,2,2024-01-25,completed,BR][1004,3,2024-02-09,completed,SG]
           \______ read one block, get WHOLE rows ______/

  COLUMN STORE  -- one column's values are contiguous
  block 1 (order_id):     [1001, 1002, 1003, 1004]
  block 2 (customer_id):  [   1,    1,    2,    3]
  block 3 (order_date):   [2024-01-08, 2024-03-12, 2024-01-25, 2024-02-09]
  block 4 (status):       [completed, completed, completed, completed]
  block 5 (ship_country): [GB, GB, BR, SG]
           \___ read one block, get ONE column for MANY rows ___/

Now trace two queries through both layouts.

“Give me everything about order 1003.” The row store reads one block and has the complete answer. The column store must read five blocks — one per column — and pick the third value out of each. Inference Row storage wins point lookups decisively, and the gap widens as the table gets wider.

“How many distinct ship countries?” The row store must read every block of the entire table, then discard four values out of five. The column store reads one block. Row storage loses badly here, and the gap widens with the number of columns you are not asking for.

That is the whole mechanism. Everything below is a consequence.

Analogy

A filing cabinet of completed forms versus a set of columnar ledgers. To answer “show me everything about applicant 4,102”, the forms cabinet wins — pull one sheet and you have it all. To answer “what is the average declared income?”, the forms cabinet is dreadful: you handle every sheet to read one line. The ledger where every income has been copied into a single column answers it by reading one page. Same information, opposite ergonomics, and neither filing system is wrong.

Why columnar also compresses far better

A second, less obvious advantage compounds the first. In a column, all values are the same type and are frequently similar or repeated — the same country codes, the same statuses, dates in near order. Fact Compression works by exploiting repetition, so a column of similar values compresses substantially better than a row mixing an integer, a date and two strings.

Three techniques recur:

  • Run-length encoding. Twenty-one consecutive “completed” values become “completed × 21”.
  • Dictionary encoding. Distinct strings are numbered once and the column stores the small numbers. A status column with three distinct values needs very few bits per row.
  • Zone maps. The minimum and maximum of each block are recorded, so a filter on a date range can skip entire blocks without decompressing them.

Inference The compounding matters more than either effect alone: reading fewer columns means less data, and better compression means those columns are smaller too. This is why analytical engines can scan very large tables quickly — not because they read faster, but because they arrange not to read most of it.

The query below touches one column out of six and produces three aggregates from it — the archetypal columnar workload:

sqlcolumnar_friendly.sql
-- A column store reads the quantity and unit_price columns and
-- ignores the rest of the table entirely. A row store reads it all.
SELECT COUNT(*)             AS rows_touched,
       SUM(quantity)        AS total_units,
       ROUND(AVG(unit_price), 2) AS avg_price
FROM order_items;
Result
rows_touchedtotal_unitsavg_price
3036235.67

At thirty rows the layout is irrelevant. At thirty billion it is the difference between a dashboard and an overnight job.

💡Intuition — why SELECT * is worse than it looks

On a row store, selecting every column costs roughly what selecting one column costs, since the whole row was fetched anyway. On a column store it can cost many times more, because each additional column is a separate set of blocks to read and decompress. Inference SELECT * is a mild style issue on a transactional database and a genuine expense on a warehouse — and since cloud warehouses frequently bill by bytes scanned, naming your columns is not merely tidy, it is directly cheaper.

The cost: writes

Inserting one row into a row store appends one contiguous piece of data to one block. Inserting one row into a column store means touching every column's storage — and since those columns are compressed in large blocks, a single-row insert may require decompressing and rewriting them.

Inference This is why columnar systems favour bulk loading and why many of them handle updates poorly or not at all, preferring append-only writes with periodic compaction. It is also why the OLTP/OLAP split from Lesson 0.12 is so durable: the layout that makes analytics fast makes transactional writes slow, and that is a property of physics and layout rather than of any vendor's implementation.

Common pitfall — row-by-row inserts into a warehouse

A pipeline is written to insert records one at a time into a columnar warehouse, because that is how the transactional database was used. It runs thousands of times slower than expected and produces an enormous number of tiny files or micro-partitions, which then degrade every subsequent read. Inference Columnar systems want batches — ideally large ones. If your source is a stream, buffer it and load periodically rather than passing each event straight through, and if the system offers a compaction step, schedule it.

When not to use columnar

  • Point lookups and single-row updates. Anything user-facing that fetches or edits one record wants a row store. This is not a close call.
  • High-frequency small writes. An application recording individual events as they happen should write to a row store, with a pipeline batching them into the warehouse.
  • Narrow tables. With four columns, all of which you always read, columnar's chief advantage — skipping columns — has nothing to skip.
  • Small data. Under a few million rows, both layouts feel instant and the choice should be made on operational convenience instead.
In the field

Two habits pay for themselves immediately on a columnar warehouse and are worth adopting from your very first query. First, name the columns you need instead of SELECT *, because unread columns are unread bytes and unread bytes are frequently unbilled money. Second, filter on the table's partitioning or clustering column whenever you can — usually a date — because that is what lets zone maps eliminate whole blocks before reading them. These two habits routinely change query cost by a large factor and neither makes the SQL harder to read.

Interview angle

“Why are columnar databases faster for analytics?” expects two reasons and most candidates give one. Reason one: only the referenced columns are read, so a query touching three columns of a fifty-column table reads a small fraction of the data. Reason two: values within a column are homogeneous and often repetitive, so compression is far more effective, and the two effects compound. The strongest answers close with the trade — single-row writes and point lookups are worse, because one logical row is scattered across every column's storage — and name run-length or dictionary encoding as the concrete mechanism.

Exercise 0.13

Digital media A streaming service stores a views table with 60 columns and billions of rows. Two queries: (a) “show me every field of view id 88,102,441”; (b) “total minutes watched per content item last month”. For each, state which layout wins and roughly what fraction of the table must be read.

Show solution

(a) Row store wins decisively. All 60 columns of exactly one row are needed. A row store fetches essentially one block. A column store must visit 60 separate column segments and extract one value from each — sixty times the work for the same answer. Fraction of table read: negligible either way in bytes, but the number of separate reads differs by a factor of sixty, and since point lookups are latency-bound rather than bandwidth-bound, that factor is what you feel.

(b) Column store wins decisively. Three columns are needed — content id, minutes, and a date to filter on — out of sixty. A column store reads roughly one twentieth of the table before compression, and rather less after it, since a content id column dictionary-encodes extremely well and a date column allows zone maps to skip every block outside last month. A row store must read all sixty columns of every row in the period and discard fifty-seven of them.

The shape against the course dataset is the same, just smaller:

sqlminutes_per_content.sql
SELECT c.title,
       SUM(v.minutes) AS minutes_watched
FROM views   AS v
JOIN content AS c ON c.content_id = v.content_id
GROUP BY c.title
ORDER BY minutes_watched DESC, c.title;

Three columns referenced from views out of eight. On a real columnar system that ratio, multiplied by compression and by block skipping on the date filter, is the entire performance story.

Knowledge check

A warehouse team replaces SELECT * with an explicit list of four columns from a fifty-column table. Why does this often make a large difference?

Key takeaway

The layout question is simply which values sit next to each other. Row storage keeps a row contiguous, so it wins point lookups and single-row writes. Columnar storage keeps a column contiguous, so it reads only the columns you asked for and compresses them far better — two effects that compound. The cost is writes: one logical row is scattered across every column's storage, so columnar systems want batches, not row-by-row inserts. Name your columns; on a warehouse it is directly cheaper.

Lesson 0.14·14 min read

Warehouses, Lakes & Lakehouses: BigQuery, Snowflake, Redshift, Databricks, ClickHouse

A map of the platforms you will actually be hired to work on — what each is genuinely for, and which parts of your SQL will need to change when you move between them.

Job adverts name platforms, not concepts. “Snowflake experience required.” “Strong BigQuery.” This makes the landscape look like five unrelated skills, and it is not — the concepts from this part apply to all of them, and the differences are narrower than the marketing suggests.

This lesson gives you the map. Nothing in it is a benchmark or a recommendation, because both would be out of date before you read them.

Three architectures

A data warehouse stores structured, cleaned, schema-on-write data. You define the schema before loading; anything that does not fit is rejected or transformed first. Strong guarantees, strong performance, and the price is that raw or awkwardly-shaped data has nowhere to go.

A data lake stores raw files in cheap object storage — Parquet, JSON, CSV, images, anything — with schema applied at read time. Cheap and flexible, but nothing enforces structure, so a lake without governance becomes a swamp: petabytes nobody trusts and nobody can find anything in.

A lakehouse is the attempt to have both: files in object storage, with a table format layered on top that adds schemas, transactions, versioning and time travel. Inference The architecture exists because organisations found themselves running a lake and a warehouse, copying between them, and paying twice for the same data with two versions of the truth. Whether the merge fully succeeds is still being decided in practice.

flowchart TD
  S["Sources: apps, events, files, APIs"] --> L["Object storage
(raw files: Parquet, JSON)"] L --> T["Table format layer
(schema, transactions, versions)"] T --> E["Query engines
(SQL)"] S --> W["Warehouse
(schema-on-write)"] W --> E E --> B["Dashboards, analysis, models"]
Two routes to the same SQL. The upper path is the lakehouse: cheap storage plus a layer that adds the guarantees. The lower path is the classic warehouse: structure enforced on the way in.
Analogy

A warehouse is a supermarket: everything catalogued, shelved and priced before it reaches the floor, so you find things instantly — and anything that will not fit the shelving system does not get stocked. A lake is a wholesale yard: pallets of everything, arriving as they come, cheap per tonne, and you need to know what you are looking for. A lakehouse is the yard with a proper inventory system bolted on — still pallets, but now with a catalogue, a receiving process, and a record of what changed yesterday.

The platforms

All five below are columnar and all speak SQL. Their differences are chiefly in how compute is provisioned and billed, and in dialect details.

PlatformShapeDistinguishing characteristic
BigQueryServerless warehouseNo cluster to size or manage; the service allocates compute per query. Commonly billed by bytes scanned, which makes column and partition pruning directly financial.
SnowflakeCloud warehouseStorage and compute fully separated; you start independently sized “virtual warehouses” against shared storage, so teams can be isolated from one another's load.
RedshiftCloud warehouseThe earliest of the mainstream cloud warehouses; originally a provisioned cluster model, with more elastic options added over time. Deep integration with the surrounding AWS ecosystem.
DatabricksLakehouseBuilt around processing engines over files in object storage with a transactional table format layered on top; SQL sits alongside notebook-based and machine-learning workflows rather than being the only interface.
ClickHouseAnalytical databaseOptimised for very fast aggregation over large tables, frequently used for real-time analytics and observability rather than as a general-purpose enterprise warehouse.

Open Any ranking of these on speed or cost is contested, workload-specific, and changes with each release. Vendor benchmarks are marketing. If you need to choose, test with your own data and your own queries; if you are learning, the choice barely matters, because the transferable part is the SQL and the concepts.

What travels, and what does not

The good news dominates. Core SQL is portable: SELECT, WHERE, GROUP BY, joins, subqueries, CTEs, window functions and set operations behave the same everywhere. This query runs unchanged on every platform in the table:

sqlportable_core.sql
-- Standard SQL. Nothing here is dialect-specific.
SELECT c.department,
       COUNT(DISTINCT o.order_id) AS orders,
       ROUND(SUM(oi.quantity * oi.unit_price
                 * (1 - oi.discount_pct / 100.0)), 2) AS revenue
FROM order_items AS oi
JOIN orders      AS o ON o.order_id    = oi.order_id
JOIN products    AS p ON p.product_id  = oi.product_id
JOIN categories  AS c ON c.category_id = p.category_id
WHERE o.status = 'completed'
GROUP BY c.department
ORDER BY revenue DESC;
Result
departmentordersrevenue
Footwear132192.45
Accessories51710.25
Outdoor51275.10
Apparel41183.05

What does not travel is the surrounding layer: date and time functions, string functions, type casting syntax, JSON handling, the exact spelling of QUALIFY or PIVOT, and everything to do with partitioning and clustering. Here is the same idea of “truncate a date to the month” in a dialect-specific form — recognisable, but not identical:

bigquerydialect_flavour.sql
-- Same intent, platform-specific spelling. Note DATE_TRUNC's
-- argument order and the unquoted MONTH keyword.
SELECT DATE_TRUNC(order_date, MONTH) AS month,
       COUNT(*) AS orders
FROM `project.dataset.orders`
WHERE status = 'completed'
GROUP BY month
ORDER BY month;

Inference The practical implication for your learning: invest in the concepts and the standard core, and look up the dialect specifics when you need them. Someone strong in standard SQL becomes productive on an unfamiliar platform in days; someone who has memorised one dialect's functions without the concepts underneath does not.

Common pitfall — forgetting that scanning costs money

On a local database an inefficient query costs you time. On a cloud warehouse it can cost the company real money on every execution — and the classic way to discover this is an exploratory SELECT * against a large unpartitioned table, or a scheduled dashboard doing the same thing every fifteen minutes for a year. Inference Two habits neutralise most of it: filter on the partitioning column, and name the columns you need. Both are free to adopt and neither makes the SQL less readable.

When you do not need any of these

  • Data that fits on one machine. A very large fraction of real analytical work involves datasets in the gigabytes, not the petabytes. An in-process engine on a laptop handles that comfortably, with no cluster, no bill and no waiting for a platform team.
  • A transactional workload. None of these platforms is the right home for your application's live data. They are analytical systems, and Lesson 0.12 explains why that is not a fixable shortcoming.
  • Small teams with simple needs. A managed relational database with a read replica serves many organisations for years. Adopting a warehouse buys scale you may not need and an operational burden you certainly will feel.
  • Genuinely unstructured work. Video, audio and free text are better handled with purpose-built tools, with only their metadata in the warehouse.
In the field

The platform on your CV matters far less than teams expect, in both directions. Interviewers who screen hard on a named platform are usually filtering for people who will not need hand-holding in week one — a real concern, but a shallow one. What actually distinguishes people six months in is whether they understand grain, joins, and where their numbers come from. When you move platforms, budget a week of irritation over date functions and then expect to be at full speed.

Interview angle

“What is the difference between a data lake and a data warehouse?” is extremely common. Warehouse: structured, schema-on-write, strong guarantees, higher cost per byte. Lake: raw files, schema-on-read, cheap and flexible, no enforcement. Lakehouse: files in object storage plus a table format adding schemas, transactions and versioning. The differentiator is explaining why the lakehouse appeared — organisations were running both, copying between them, and maintaining two versions of the truth — and being honest that the merge is a work in progress rather than a settled fact.

Exercise 0.14

Supply chain A logistics firm has: (i) 8 GB of structured shipment records, queried heavily by ten analysts; (ii) 400 TB of raw GPS telemetry, queried rarely and exploratorily; (iii) a requirement for a live operations dashboard refreshing every ten seconds. Propose an architecture for each and name what you would not build.

Show solution

(i) 8 GB, heavy analyst use. This is small. A managed relational database, or a single columnar engine, serves ten analysts comfortably — the entire dataset fits in memory on one reasonably sized machine. Do not build a distributed warehouse for it: you would pay for coordination overhead and an operational burden to solve a problem you do not have.

(ii) 400 TB of raw telemetry, rarely queried. This is the case object storage exists for. Land it as partitioned Parquet in a lake or lakehouse and query it on demand with an engine you start and stop. Because access is infrequent, storage cost dominates and compute cost is near zero between queries — exactly the profile that separated storage and compute is designed for. Do not load it into a warehouse, where you would pay warehouse rates permanently for data touched monthly.

(iii) Ten-second dashboard. Neither of the above. A batch pipeline cannot meet a ten-second refresh, and a general warehouse is not built for that latency. This wants a real-time analytical database fed by a stream, or a pre-aggregated serving table maintained incrementally. Do not attempt it by running the full analytical query every ten seconds — you will pay for a full scan six times a minute forever, which is the most expensive way to be slow.

The general lesson: three workloads, three answers, and the most common architectural mistake is choosing one platform for all of them — usually the largest one — and then fighting it in two cases out of three.

Knowledge check

What problem was the lakehouse architecture created to solve?

Key takeaway

Three architectures: warehouse (schema-on-write, structured, strong guarantees), lake (raw files, schema-on-read, cheap and unenforced), lakehouse (files plus a table format adding schemas, transactions and versioning). BigQuery, Snowflake, Redshift, Databricks and ClickHouse are all columnar and all speak SQL; they differ mainly in how compute is provisioned and billed, and in dialect details around dates, strings and JSON. The core SQL and the concepts transfer; the surrounding syntax is a lookup.

Month 1 · Foundations

Basic Syntax & Retrieval

The relational model, data types, and NULL — then the four clauses that answer most day-one questions: SELECT, WHERE, ORDER BY, and pattern matching.

Lesson 1.1·11 min read

The Relational Model, Tables, Types & NULL

Before any keyword: what a relational database actually is, why it's shaped the way it is, and the one concept (NULL) that causes the most confusion later.

A relational database stores data in tables (formally, relations). A table has named columns (each with a fixed data type) and any number of rows (also called records or tuples). That's the whole foundation — everything else in SQL is operations on tables that produce more tables.

Analogy

Think of a single spreadsheet tab: the header row names the columns, every row below is a record, and a column is “text” or “number” throughout. A relational database is a disciplined collection of such tabs that can reference each other by shared keys — with strict types, integrity rules, and a query language to combine them. The discipline is the point: it's what lets the database guarantee your data stays consistent.

The model came from E. F. Codd's 1970 paper at IBM, and its durability is striking. Fact The relational model and SQL's core operations are based on set theory and relational algebra; operations like join and union have precise mathematical definitions. Inference That formal grounding is a large part of why relational databases remain the default for transactional business systems half a century later — the guarantees are well understood.

Data types: the column's contract

Every column commits to a type. Getting types right is the first quality gate — it prevents “12” the string from being confused with 12 the integer. The families you'll use constantly:

FamilyTypical typesUse for
IntegerINTEGER, BIGINT, SMALLINT, SERIALCounts, identifiers, quantities
Exact decimalNUMERIC(p,s) / DECIMAL(p,s)Money — never use floats for currency
Floating pointREAL, DOUBLE PRECISIONScientific / approximate values
TextVARCHAR(n), TEXT, CHAR(n)Names, descriptions, codes
Date / timeDATE, TIMESTAMP, TIMESTAMPTZ, INTERVALEvents, scheduling, durations
BooleanBOOLEANTrue/false flags
Common pitfall — integer division & money

Two type traps catch everyone. First, integer division truncates: 5 / 2 is 2, not 2.5. Cast first — 5 * 1.0 / 2 or CAST(5 AS NUMERIC) / 2. Second, never store money in a float: 0.1 + 0.2 is not exactly 0.3 in binary floating point. Use NUMERIC(12,2) for currency. Fact Both behaviours follow from how the types are defined, not from a bug.

NULL: the absence of a value

NULL means “no value here” — unknown, missing, or not applicable. It is not zero, and not an empty string. This distinction is the single most common source of subtle SQL bugs, so we meet it now and return to it in Lesson 1.3.

Gaming Suppose a players table records each player's country, but some players never set one. Those rows hold NULL in country — meaningfully different from a player whose country is the empty string.

sqlplayers.sql
-- A player with an unknown country has NULL, not '' and not 'Unknown'
-- NULL propagates: any arithmetic or comparison WITH null yields NULL/unknown
SELECT 100 + NULL;        -- => NULL  (not 100)
SELECT 'Lv ' || NULL;     -- => NULL  (Postgres string concat with null)
SELECT NULL = NULL;       -- => NULL  (NOT true! two unknowns aren't "equal")

-- The ONLY correct way to test for null is IS NULL / IS NOT NULL
SELECT player_id, username
FROM players
WHERE country IS NULL;    -- players who never set a country
💡Intuition — three-valued logic

Most languages have true and false. SQL has true, false, and unknown. Any comparison involving NULL produces unknown, and a WHERE clause only keeps rows where the condition is true — not unknown. That single rule explains nearly every “why did my rows disappear?” mystery you'll hit. NULL = NULL is unknown because two unknown values needn't be the same value.

Industry lens

In real schemas, NULL is a modelling decision. Does a missing e-commerce shipped_at mean “not shipped yet” (a legitimate NULL) or “we forgot to record it” (a data-quality problem)? Good analysts always ask what a NULL means in a given column before trusting a count built on it.

LeetCode practice

Warm up on the gentlest retrieval problems — they're pure SELECT/WHERE and build confidence with the judge:

InterviewA column discount_pct is NULL for orders with no discount. What does WHERE discount_pct != 10 return?
Key takeaways
  • A relational database is tables of typed columns and rows; every SQL operation produces another table.
  • Pick types deliberately — NUMERIC for money, mind integer division.
  • NULL is “unknown,” not zero or empty; test it only with IS NULL / IS NOT NULL.
  • SQL uses three-valued logic; WHERE keeps only true, never unknown.
Lesson 1.2·9 min read

SELECT & FROM: Retrieving Columns

The two keywords that start almost every query — choosing what to return and where it comes from — plus aliases and computed columns.

SELECT chooses which columns (a projection); FROM names which table. That pair is the minimal query.

E-commerce Our running e-commerce schema has a products table: product_id, name, category, price (NUMERIC), stock, created_at. To browse the catalogue:

sqlcatalogue.sql
-- Every column (handy when exploring; avoid in production code)
SELECT * FROM products;

-- Just the columns you need (faster, clearer, stable)
SELECT name, category, price
FROM products;

-- Aliases rename output columns; computed columns derive new ones
SELECT
    name,
    price,
    price * 0.80 AS sale_price,          -- 20% off, computed per row
    price - (price * 0.80) AS you_save
FROM products;
Result (illustrative)
namepricesale_priceyou_save
Trail Runner X120.0096.0024.00
Aero Bottle25.0020.005.00
Common pitfall — SELECT * in production

SELECT * is great for exploring and risky in shipped code: it pulls columns you don't need (more I/O), and your query silently changes shape if someone adds a column. Inference In typical applications, listing explicit columns is both clearer and modestly cheaper; Fact it is also required for covering indexes to help (Month 5).

Two practical notes. SQL keywords are case-insensitive (SELECT = select), but the convention is uppercase keywords for readability. And string literals use single quotes ('Trail Runner X'); double quotes mean identifier (a column/table name), which is a frequent beginner surprise.

Comments

Two dashes start a line comment (-- like this); /* … */ brackets a block comment. Use them to label intent — future-you and reviewers will thank you.

InterviewWhy do experienced engineers avoid SELECT * in application and reporting code?
Key takeaways
  • SELECT projects columns; FROM picks the source table.
  • Alias with AS; compute new columns with expressions.
  • List explicit columns in shipped code; reserve SELECT * for exploring.
  • Single quotes = string literal; double quotes = identifier.
Lesson 1.3·11 min read

WHERE: Filtering Rows

Keeping only the rows you want — comparison and logical operators, operator precedence, and the NULL traps that bite hardest.

WHERE applies a predicate to each row and keeps those for which it is true. Gaming Our players table also has level (INT), last_login (DATE), and is_premium (BOOLEAN).

sqlfilters.sql
-- Comparison operators:  =  <>  (or !=)  <  <=  >  >=
SELECT username, level
FROM players
WHERE level >= 30;

-- Combine with AND / OR; NOT negates. Parentheses control precedence.
SELECT username, level, is_premium
FROM players
WHERE is_premium = TRUE
  AND level >= 30
  AND last_login >= DATE '2026-06-01';

-- AND binds tighter than OR -- parenthesise mixed logic or get wrong rows
SELECT username
FROM players
WHERE (level >= 50 OR is_premium = TRUE)
  AND last_login >= DATE '2026-01-01';
Common pitfall — precedence & NULL

Two classics. (1) AND binds tighter than OR, so a OR b AND c means a OR (b AND c) — almost never what a beginner intends. Parenthesise. (2) NOT IN with a NULL in the list returns no rows. If the sub-list (1, 2, NULL) contains a NULL, x NOT IN (1, 2, NULL) is never true — a notorious production trap. Prefer NOT EXISTS (Month 3) or filter the NULLs out first.

💡Intuition — the order SQL runs in

You write SELECT … FROM … WHERE, but the database logically evaluates FROMWHEREGROUP BYHAVINGSELECTORDER BY. That's why WHERE can't reference a column alias you defined in SELECT (it hasn't run yet) and why WHERE filters before aggregation. Hold this sequence in your head — it explains a dozen rules at once.

Useful predicate shorthands: BETWEEN a AND b (inclusive range), IN (…) (set membership), and IS [NOT] NULL. We cover LIKE, IN, and BETWEEN in depth in Lesson 1.5.

LeetCode practice
InterviewWHERE country NOT IN ('US', 'CA', NULL) is run against a table with many rows. What is returned?
Key takeaways
  • WHERE keeps rows where the predicate is true (not unknown).
  • AND binds tighter than OR — parenthesise mixed logic.
  • NOT IN + NULL = no rows; prefer NOT EXISTS.
  • Logical evaluation order (FROMWHERE→…→SELECT) explains why WHERE can't see SELECT aliases.
Lesson 1.4·9 min read

ORDER BY, LIMIT & DISTINCT

Sorting results deterministically, returning a top slice, and removing duplicate rows — the building blocks of every “top content” report.

Digital media A streaming platform has a content table: content_id, title, type ('video'/'article'/'podcast'), views, published_at. To build a “most-watched” rail:

sqltop_content.sql
-- Sort descending by views, then alphabetically to break ties
SELECT title, type, views
FROM content
ORDER BY views DESC, title ASC;

-- Top 10 only -- LIMIT returns a slice; OFFSET skips rows (pagination)
SELECT title, views
FROM content
ORDER BY views DESC
LIMIT 10;

-- Page 2 of 10 (rows 11-20). Always pair OFFSET with a stable ORDER BY.
SELECT title, views
FROM content
ORDER BY views DESC, content_id
LIMIT 10 OFFSET 10;

-- DISTINCT removes duplicate rows from the result
SELECT DISTINCT type FROM content;          -- the set of content types
Common pitfall — there is no default order

Fact Without ORDER BY, the order of rows is not guaranteed — not insertion order, not primary-key order, nothing. It may look stable on a small table and then change after an update, an index, or a parallel scan. Inference Any “top N” or paginated query without an explicit, total ordering is a latent bug; add a tie-breaker column (often the primary key) to make the order deterministic.

Dialect note — LIMIT

LIMIT n OFFSET m is PostgreSQL/MySQL/SQLite. SQL Server uses OFFSET m ROWS FETCH NEXT n ROWS ONLY, and older Oracle used ROWNUM. The concept is identical; the keyword differs — one of many small dialect splits we flag as they arise.

💡Intuition — DISTINCT vs GROUP BY

SELECT DISTINCT type and SELECT type FROM content GROUP BY type return the same set of types. DISTINCT says “dedupe these rows”; GROUP BY says “collapse into groups” — and the latter lets you then aggregate each group (Month 2). Reach for DISTINCT when you only need uniqueness, GROUP BY when you'll compute per-group numbers.

InterviewA pagination query uses ORDER BY views DESC LIMIT 10 OFFSET 10. Several rows share the same views value. What can go wrong across pages?
Key takeaways
  • ORDER BY col [ASC|DESC], with extra columns as tie-breakers.
  • No ORDER BY ⇒ no guaranteed order, ever.
  • LIMIT/OFFSET slice and paginate — always over a total ordering.
  • DISTINCT dedupes rows; GROUP BY additionally enables aggregation.
Lesson 1.5·9 min read

LIKE, IN & BETWEEN: Pattern & Set Matching

Searching text by pattern, testing membership in a set, and matching inclusive ranges — with the performance caveat every analyst should know.

FMCG A consumer-goods catalogue has skus: sku_id, product_name, brand, pack_size, unit_price, launched_on. Category managers constantly search and segment it.

sqlsearch.sql
-- LIKE: % matches any run of characters, _ matches exactly one
SELECT product_name FROM skus WHERE product_name LIKE 'Choco%';   -- starts with
SELECT product_name FROM skus WHERE product_name LIKE '%wafer%';  -- contains
SELECT sku_id      FROM skus WHERE sku_id      LIKE 'FM_2025_%';  -- _ = one char

-- Case-insensitive search: Postgres ILIKE, or LOWER() on both sides (portable)
SELECT product_name FROM skus WHERE LOWER(product_name) LIKE '%cola%';

-- IN: membership in a set (cleaner than chained ORs)
SELECT product_name, brand
FROM skus
WHERE brand IN ('Aqua', 'FizzCo', 'PureLeaf');

-- BETWEEN: inclusive range on numbers or dates
SELECT product_name, unit_price
FROM skus
WHERE unit_price BETWEEN 1.00 AND 3.00            -- 1.00 and 3.00 included
  AND launched_on BETWEEN DATE '2025-01-01' AND DATE '2025-12-31';
Common pitfall — leading-wildcard performance

Inference A pattern with a leading wildcard (LIKE '%wafer%') generally can't use a standard B-tree index, so on large tables it forces a full scan; a trailing-wildcard prefix search (LIKE 'Choco%') often can use one. Open question Whether that matters for your table depends on its size and on whether a specialised index (trigram, full-text) is in play — verify with EXPLAIN (Month 5) rather than assuming.

BETWEEN is inclusive

x BETWEEN a AND b means x >= a AND x <= bboth endpoints included. For date ranges this is a frequent off-by-one source: BETWEEN '2025-01-01' AND '2025-12-31' can miss timestamps on Dec 31 after midnight. For TIMESTAMP columns prefer a half-open range: ts >= '2025-01-01' AND ts < '2026-01-01'.

LeetCode practice
InterviewWhich predicate is most likely to use a plain B-tree index on product_name for a fast lookup on a large table?
Key takeaways
  • LIKE with % (any run) and _ (one char); ILIKE/LOWER() for case-insensitivity.
  • IN (…) for set membership; BETWEEN for inclusive ranges.
  • Leading wildcards typically defeat B-tree indexes; prefix searches often don't.
  • For timestamps, prefer half-open ranges over BETWEEN.
Month 2 · Combining & summarising

Aggregations & Joins

Collapsing many rows into business numbers with GROUP BY and HAVING, then connecting tables by their keys — INNER, LEFT, self-joins, and set operations.

Lesson 2.1·10 min read

Aggregate Functions: COUNT, SUM, AVG, MIN, MAX

Turning a column of values into a single business number — and the NULL and DISTINCT subtleties that decide whether that number is correct.

An aggregate function takes many rows and returns one value. The five you'll use daily: COUNT, SUM, AVG, MIN, MAX. E-commerce Against an orders table (order_id, customer_id, total NUMERIC, status, created_at):

sqlstore_kpis.sql
SELECT
    COUNT(*)              AS n_rows,        -- counts rows (incl. NULLs)
    COUNT(customer_id)    AS n_with_cust,   -- counts NON-NULL customer_id
    COUNT(DISTINCT customer_id) AS n_customers,  -- unique customers
    SUM(total)            AS gross_revenue,
    AVG(total)            AS avg_order_value,
    MIN(total)            AS smallest_order,
    MAX(total)            AS largest_order
FROM orders
WHERE status = 'completed';
Result (illustrative)
n_rowsn_with_custn_customersgross_revenueavg_order_value
812081205310612480.0075.43
Common pitfall — the three faces of COUNT

Fact COUNT(*) counts rows; COUNT(col) counts rows where col is not NULL; COUNT(DISTINCT col) counts distinct non-NULL values. These answer three different questions — “how many orders?”, “how many have a known customer?”, “how many unique customers?” Picking the wrong one is a silent reporting error. Likewise, AVG and SUM ignore NULLs, so AVG(total) is the mean over non-NULL totals, not over all rows.

💡Intuition — AVG is just SUM/COUNT… over non-NULLs

AVG(total) equals SUM(total) / COUNT(total) — note the denominator is COUNT(total) (non-NULL), not COUNT(*). If you want NULLs treated as zero in the average, you must say so: SUM(COALESCE(total,0)) / COUNT(*). Being explicit about how missing data is handled is the difference between a defensible metric and a wrong one.

LeetCode practice
InterviewA table has 1,000 order rows; 200 have NULL in coupon_code. What do COUNT(*) and COUNT(coupon_code) return?
Key takeaways
  • Aggregates collapse many rows to one value.
  • COUNT(*)COUNT(col)COUNT(DISTINCT col).
  • SUM/AVG/MIN/MAX ignore NULLs; use COALESCE to change that.
  • Average a ratio with SUM/SUM, not AVG of per-row ratios.
Lesson 2.2·11 min read

GROUP BY & HAVING

Computing a number per category instead of overall — and the precise difference between WHERE and HAVING that trips up most candidates.

GROUP BY partitions rows into groups by one or more columns; the aggregate then runs once per group. FMCG A sales table (sale_id, sku_id, category, region, units, revenue, sold_on) drives the category manager's weekly review:

sqlcategory_review.sql
-- Units and revenue per category
SELECT category,
       SUM(units)   AS units_sold,
       SUM(revenue) AS revenue
FROM sales
GROUP BY category
ORDER BY revenue DESC;

-- Group by MORE than one column: category within region
SELECT region, category, SUM(revenue) AS revenue
FROM sales
GROUP BY region, category
ORDER BY region, revenue DESC;

-- WHERE filters ROWS before grouping; HAVING filters GROUPS after.
-- "Categories selling over 10,000 units, looking only at 2026 sales"
SELECT category, SUM(units) AS units_sold
FROM sales
WHERE sold_on >= DATE '2026-01-01'     -- per-row filter, BEFORE aggregation
GROUP BY category
HAVING SUM(units) > 10000;             -- per-group filter, AFTER aggregation
💡Intuition — WHERE before, HAVING after

Recall the logical order: WHERE runs before GROUP BY, so it can't see aggregates (the groups don't exist yet). HAVING runs after grouping, so it's the only place you can filter on SUM, COUNT, etc. Rule of thumb: filter raw rows in WHERE, filter computed group totals in HAVING. Putting SUM(units) > 10000 in WHERE is an error.

Common pitfall — non-aggregated columns & engine differences

Fact In standard SQL and in PostgreSQL, every column in SELECT must be either inside an aggregate or listed in GROUP BY; otherwise it's an error. Inference MySQL historically allowed selecting un-grouped columns and returned an arbitrary value from each group — a frequent source of wrong-but-silent results; modern MySQL enables ONLY_FULL_GROUP_BY by default to match the standard. Know which engine you're on.

Industry lens

Most BI dashboards are, underneath, a GROUP BY with a date or category dimension and a handful of aggregates. The hard part in practice isn't the syntax — it's choosing the right grain (per day? per week? per SKU per region?) and being consistent about it so two charts that should agree actually do.

LeetCode practice
InterviewYou need “regions whose total 2026 revenue exceeds £1M.” Where does each condition go?
Key takeaways
  • GROUP BY makes aggregates run once per group; group by multiple columns for finer grain.
  • WHERE filters rows before grouping; HAVING filters groups after.
  • Non-aggregated SELECT columns must be in GROUP BY (strict in Postgres; engine-dependent in MySQL).
  • Choosing the right grain is the real-world hard part.
Lesson 2.3·9 min read

Keys & Relationships

Primary and foreign keys, and the cardinality of relationships — the structure that makes joins meaningful and prevents fan-out surprises.

Before joining, you must understand how tables relate. A primary key (PK) uniquely identifies each row in a table. A foreign key (FK) is a column that references a PK in another table, encoding a relationship.

E-commerce customers.customer_id is a PK; orders.customer_id is an FK pointing back to it. That FK is what lets us answer “which customer placed this order?” reliably.

RelationshipMeaningExample
One-to-many (1:N)One row here relates to many thereOne customer → many orders
Many-to-one (N:1)The mirror of 1:NMany orders → one customer
Many-to-many (M:N)Both sides relate to many; needs a junction tableOrders ↔ products via order_items
One-to-one (1:1)At most one row each sideUser → user_profile
💡Intuition — cardinality predicts row counts

Knowing a relationship's cardinality tells you what a join will do to your row count before you run it. Joining orders to customers (N:1) keeps the order count steady — each order matches one customer. Joining orders to order_items (1:N) multiplies rows — one order fans out to several items. If you then SUM(orders.total), you'll double-count. This “fan-out” is the deepest join pitfall, and cardinality is your early-warning system.

Common pitfall — the M:N junction

A many-to-many relationship can't be stored with a single FK — it needs a junction (bridge) table holding the two FKs, often with extra columns. Gaming Players belong to many guilds and guilds have many players ⇒ a guild_members(player_id, guild_id, joined_at) table. Forgetting the junction and trying to cram a list into one column violates first normal form (Month 4).

Constraints make it real

Declaring PRIMARY KEY and FOREIGN KEY constraints (Month 4) lets the database enforce these relationships — rejecting an order for a non-existent customer (referential integrity). The relationship exists conceptually either way, but constraints stop bad data at the door.

InterviewYou join orders (one row per order) to order_items (many rows per order) and then write SUM(orders.total). What happens?
Key takeaways
  • PK uniquely identifies a row; FK references another table's PK.
  • Cardinality (1:N, N:1, M:N, 1:1) predicts a join's effect on row counts.
  • M:N relationships need a junction table.
  • 1:N joins cause fan-out — the root of most double-counting bugs.
Lesson 2.4·12 min read

INNER JOIN

Combining tables on matching keys — the workhorse join — built up through the gaming mini-project: revenue per paying user.

An INNER JOIN returns only rows where the join condition matches in both tables. Fact In relational theory the (inner) join is commutative and associativeA JOIN B yields the same set as B JOIN A, and the order you join three tables doesn't change the result set (though it can change performance; see Month 5).

Gaming Three tables: users(user_id, username, signup_date), sessions(session_id, user_id, started_at, duration_min), and purchases(purchase_id, user_id, amount, purchased_at). We'll build to revenue per paying user.

sqlpaying_users.sql
-- Step 1: match each purchase to its user (only users WITH purchases appear)
SELECT u.username, p.amount, p.purchased_at
FROM users AS u
INNER JOIN purchases AS p
       ON p.user_id = u.user_id;

-- Step 2: revenue per paying user (group the joined rows by user)
SELECT u.user_id,
       u.username,
       COUNT(*)        AS n_purchases,
       SUM(p.amount)   AS revenue
FROM users AS u
INNER JOIN purchases AS p ON p.user_id = u.user_id
GROUP BY u.user_id, u.username
ORDER BY revenue DESC;

-- Step 3: three-table join -- bring in session activity too
SELECT u.username,
       COUNT(DISTINCT s.session_id) AS sessions,
       COUNT(DISTINCT p.purchase_id) AS purchases,
       SUM(p.amount)                AS revenue
FROM users AS u
INNER JOIN sessions  AS s ON s.user_id = u.user_id
INNER JOIN purchases AS p ON p.user_id = u.user_id
GROUP BY u.username;
Common pitfall — the accidental Cartesian product

Fact If you omit the ON condition (or use CROSS JOIN), you get the Cartesian product: every row of A paired with every row of B. Ten thousand users × ten thousand sessions = one hundred million rows. The symptom is a query that hangs or returns absurd counts. Always join on a key; in Step 3, note that joining both sessions and purchases to users can itself fan out — hence COUNT(DISTINCT …) to keep the counts honest.

💡Intuition — INNER JOIN as intersection on keys

Picture two tables as sets keyed by user_id. INNER JOIN keeps only the keys present in both — an intersection. A user with no purchases simply doesn't appear in Step 1's output. That's exactly what you want for “paying users,” but it also means an inner join can silently drop rows you might have expected — the motivation for LEFT JOIN next.

Industry lens — mini-project checkpoint

Month 2 mini-project (gaming): from users, sessions, and purchases, produce a table of revenue per paying user with their session count, sorted by revenue. Then compute overall ARPPU (average revenue per paying user) as SUM(amount) / COUNT(DISTINCT paying user). This is the canonical games-analytics metric and a complete end-to-end exercise in joins + aggregation.

LeetCode practice
InterviewYou INNER JOIN users to purchases on user_id to report revenue. A stakeholder asks why some users are missing from the report. Why?
Key takeaways
  • INNER JOIN … ON keeps only matching rows from both tables.
  • Inner join is commutative/associative for results (not always for performance).
  • Missing ON ⇒ Cartesian product; multi-table joins can fan out — guard with COUNT(DISTINCT).
  • Inner joins silently drop non-matching rows — sometimes desired, sometimes a bug.
Lesson 2.5·12 min read

OUTER & Self-Joins, UNION & Set Operations

LEFT/RIGHT/FULL joins keep unmatched rows; self-joins relate a table to itself; UNION and friends stack result sets.

An outer join keeps unmatched rows and fills the missing side with NULL. Digital media To find which registered subscribers have not watched anything, we keep all subscribers and look for absent views:

sqlouter_joins.sql
-- LEFT JOIN: ALL subscribers, plus matching views (NULL where none)
SELECT s.subscriber_id, s.email, v.content_id, v.watched_at
FROM subscribers AS s
LEFT JOIN views AS v ON v.subscriber_id = s.subscriber_id;

-- "Anti-join": subscribers who have never watched anything
SELECT s.subscriber_id, s.email
FROM subscribers AS s
LEFT JOIN views AS v ON v.subscriber_id = s.subscriber_id
WHERE v.subscriber_id IS NULL;          -- no matching view row

-- Revenue report that KEEPS non-buyers as zero (the 2.4 fix)
SELECT u.username, COALESCE(SUM(p.amount), 0) AS revenue
FROM users AS u
LEFT JOIN purchases AS p ON p.user_id = u.user_id
GROUP BY u.username;
💡Intuition — left, right, full

LEFT JOIN keeps all rows from the left table; RIGHT JOIN keeps all from the right; FULL OUTER JOIN keeps all from both, NULL-filling wherever there's no match. Inference In practice LEFT JOIN dominates because we usually write the “keep everything” table first; RIGHT JOIN is just a LEFT JOIN with the tables swapped. The anti-join pattern (LEFT JOIN … WHERE right IS NULL) is the idiomatic way to ask “which rows have no counterpart?”

Self-joins: a table joined to itself

A self-join treats one table as two aliased copies. Gaming To find pairs of players in the same country, or (classically) employees who earn more than their manager:

sqlself_join.sql
-- Employees earning more than their manager (LeetCode classic)
SELECT e.name AS employee
FROM employees AS e
JOIN employees AS m ON e.manager_id = m.id
WHERE e.salary > m.salary;

UNION, INTERSECT, EXCEPT

Set operators stack the results of two queries vertically (they must have matching column counts and compatible types):

sqlset_ops.sql
-- UNION removes duplicates; UNION ALL keeps them (and is faster)
SELECT email FROM newsletter_signups
UNION
SELECT email FROM trial_signups;          -- distinct emails across both

SELECT email FROM newsletter_signups
UNION ALL
SELECT email FROM trial_signups;          -- every row, duplicates kept

-- INTERSECT: in both.   EXCEPT (MySQL: not supported pre-8.0.31): in first only.
SELECT customer_id FROM orders_2025
INTERSECT
SELECT customer_id FROM orders_2026;       -- retained customers
Common pitfall — UNION vs UNION ALL

Fact UNION performs a duplicate-elimination pass; UNION ALL does not. Inference If you know the inputs are disjoint (or you want the duplicates), UNION ALL avoids the de-dupe cost and is generally faster — but confirm with EXPLAIN on your data. Reaching for plain UNION by reflex when UNION ALL would do is a small but common inefficiency.

LeetCode practice
InterviewWhich query lists customers present in orders_2025 but not in orders_2026 (churned), using a join?
Key takeaways
  • LEFT/RIGHT/FULL joins keep unmatched rows, NULL-filling the other side.
  • Anti-join (LEFT JOIN … WHERE right IS NULL) finds rows with no match.
  • Self-joins relate a table to itself via two aliases.
  • UNION dedupes; UNION ALL doesn't (and is cheaper); INTERSECT/EXCEPT give common/difference rows.
Month 3 · Analytical SQL

Subqueries & Window Functions

Queries inside queries, readable CTEs, conditional logic with CASE, and the analytical superpower — window functions for ranking, running totals, and gaps-and-islands.

Lesson 3.1·11 min read

Subqueries & EXISTS

A query nested inside another — scalar, IN-list, and the harder correlated form — plus EXISTS, the right tool for membership tests.

A subquery is a SELECT embedded in another statement. Three shapes matter:

sqlsubqueries.sql
-- 1) SCALAR subquery: returns one value, usable anywhere a value fits
-- E-commerce: orders above the overall average order value
SELECT order_id, total
FROM orders
WHERE total > (SELECT AVG(total) FROM orders);

-- 2) IN subquery: membership in a set the inner query returns
-- Customers who have ever ordered a 'Electronics' product
SELECT name FROM customers
WHERE customer_id IN (
    SELECT o.customer_id
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
    JOIN products p     ON p.product_id = oi.product_id
    WHERE p.category = 'Electronics'
);

-- 3) CORRELATED subquery: inner query references the OUTER row => runs per row
-- Orders above each customer's OWN average
SELECT o.order_id, o.customer_id, o.total
FROM orders o
WHERE o.total > (
    SELECT AVG(o2.total) FROM orders o2
    WHERE o2.customer_id = o.customer_id   -- correlation to outer 'o'
);

-- EXISTS: does AT LEAST ONE matching row exist? (stops at the first)
SELECT c.name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
Common pitfall — correlated subqueries are the hard one

Inference Correlated subqueries are widely considered the hardest core SQL concept on LeetCode, because the inner query conceptually re-runs for every outer row. They're powerful but can be slow on large tables. Open question Whether a correlated subquery or an equivalent JOIN/window-function rewrite performs better is genuinely schema- and engine-dependent — Inference modern optimizers often rewrite simple correlated subqueries into joins, but you should confirm with EXPLAIN (Month 5) rather than assume.

💡Intuition — IN vs EXISTS vs JOIN

All three can express “customers who have ordered.” IN (subquery) reads naturally but mishandles NULLs (recall NOT IN from Month 1). EXISTS is NULL-safe and short-circuits at the first match, making it the safe default for “is there at least one?” A JOIN can do it too but may duplicate outer rows if there are multiple matches (use DISTINCT or aggregation). For anti-membership (“never ordered”), NOT EXISTS is the robust choice.

LeetCode practice
InterviewYou need customers who have never placed an order. Which form is the most NULL-safe and idiomatic?
Key takeaways
  • Scalar subquery → one value; IN subquery → a set; correlated subquery → references the outer row.
  • EXISTS/NOT EXISTS are NULL-safe membership tests and short-circuit.
  • Correlated subqueries are powerful but potentially slow — verify with EXPLAIN.
  • Prefer NOT EXISTS over NOT IN when NULLs are possible.
Lesson 3.2·10 min read

CTEs (WITH) & Recursive CTEs

Naming a subquery so complex logic reads top-to-bottom — and the recursive form that walks hierarchies like category trees.

A common table expression (CTE) is a named, temporary result defined with WITH and referenced like a table. CTEs don't add power over subqueries — they add readability, which at scale is its own kind of power.

sqlcte.sql
-- The same "above each customer's own average" logic, as a readable pipeline
WITH customer_avg AS (
    SELECT customer_id, AVG(total) AS avg_total
    FROM orders
    GROUP BY customer_id
)
SELECT o.order_id, o.customer_id, o.total, ca.avg_total
FROM orders o
JOIN customer_avg ca ON ca.customer_id = o.customer_id
WHERE o.total > ca.avg_total;

-- Multiple CTEs chain into a clear, debuggable sequence of steps
WITH paying AS (
    SELECT user_id, SUM(amount) AS revenue
    FROM purchases GROUP BY user_id
),
ranked AS (
    SELECT user_id, revenue,
           revenue >= 100 AS is_whale       -- boolean flag per user
    FROM paying
)
SELECT * FROM ranked WHERE is_whale;
💡Intuition — CTEs read like a recipe

A deeply nested subquery is read inside-out; a chain of CTEs is read top-to-bottom, each step named for what it does (paying, then ranked, then the final filter). Inference For complex analytics, CTEs are widely recommended for maintainability. Open question Performance parity is engine-dependent: older PostgreSQL versions materialised every CTE (an “optimization fence”), but PostgreSQL 12+ can inline non-recursive CTEs — so the old “CTEs are always slower” folklore no longer holds universally. Check your version.

Recursive CTEs: walking a hierarchy

E-commerce A category tree (categories(id, name, parent_id)) is a self-referencing hierarchy. A recursive CTE walks it from a root down to all descendants:

sqlrecursive.sql
WITH RECURSIVE subtree AS (
    -- anchor: start at 'Electronics'
    SELECT id, name, parent_id, 1 AS depth
    FROM categories WHERE name = 'Electronics'
    UNION ALL
    -- recursive step: children of rows already in subtree
    SELECT c.id, c.name, c.parent_id, s.depth + 1
    FROM categories c
    JOIN subtree s ON c.parent_id = s.id
)
SELECT * FROM subtree ORDER BY depth, name;
Anatomy of a recursive CTE

Every recursive CTE has an anchor member (the starting rows), UNION ALL, and a recursive member that joins back to the CTE itself. It repeats until the recursive step yields no new rows. Always ensure the recursion terminates — cyclic data without a guard can loop indefinitely.

LeetCode practice
InterviewWhat is the primary reason to refactor a deeply nested subquery into a chain of CTEs?
Key takeaways
  • WITH name AS (…) names a subquery; chain several for a clear pipeline.
  • Non-recursive CTEs = readability, not new power; perf is version-dependent.
  • WITH RECURSIVE walks hierarchies via anchor + recursive members.
  • Guard recursion against cycles so it terminates.
Lesson 3.3·9 min read

CASE WHEN & Conditional Aggregation

SQL's if/else expression — for bucketing values, and for the pivot trick that turns rows into columns.

CASE WHEN … THEN … ELSE … END evaluates conditions in order and returns the first match's value — SQL's portable if/else, usable in SELECT, WHERE, ORDER BY, and inside aggregates.

sqlcase_when.sql
-- Bucket customers by spend (e-commerce)
SELECT customer_id, total,
       CASE
           WHEN total >= 500 THEN 'high'
           WHEN total >= 100 THEN 'medium'
           ELSE 'low'
       END AS spend_tier
FROM orders;

-- CONDITIONAL AGGREGATION: counts/sums that only include matching rows.
-- This is the "pivot" pattern -- rows become columns.
-- FMCG: revenue per category, split into year columns
SELECT category,
       SUM(CASE WHEN EXTRACT(YEAR FROM sold_on) = 2025 THEN revenue ELSE 0 END) AS rev_2025,
       SUM(CASE WHEN EXTRACT(YEAR FROM sold_on) = 2026 THEN revenue ELSE 0 END) AS rev_2026,
       COUNT(*) FILTER (WHERE units = 0) AS zero_unit_rows   -- Postgres FILTER
FROM sales
GROUP BY category;
Result (illustrative)
categoryrev_2025rev_2026zero_unit_rows
Beverages410200.00455900.003
Snacks288100.00301750.000
💡Intuition — pivoting with SUM(CASE…)

To turn row-values into columns (a pivot), put a CASE inside an aggregate: each SUM(CASE WHEN year=X …) becomes one output column. This is the portable way to pivot across all engines. Inference PostgreSQL's FILTER (WHERE …) clause and MySQL's lack of a native PIVOT mean the CASE trick remains the lingua franca; dedicated PIVOT syntax exists in SQL Server but isn't standard.

Common pitfall — ELSE and ordering

Two snags. CASE stops at the first true branch, so order matters — put the most specific conditions first. And an omitted ELSE defaults to NULL, which then silently disappears from a SUM or skews an AVG. For conditional sums, an explicit ELSE 0 is usually what you want.

LeetCode practice
InterviewYou write SUM(CASE WHEN status='refunded' THEN amount END) with no ELSE. For non-refunded rows, what does the CASE contribute to the sum?
Key takeaways
  • CASE WHEN is SQL's if/else; first true branch wins — order conditions carefully.
  • SUM(CASE…) / COUNT(CASE…) does conditional aggregation and pivoting.
  • Omitting ELSE yields NULL; choose NULL vs 0 deliberately.
  • Postgres FILTER is a cleaner alternative where available.
Lesson 3.4·13 min read

Window Functions: OVER, PARTITION BY & Ranking

Computing across a set of related rows without collapsing them — the analytical superpower, taught through top-N content per category.

A window function computes a value over a set of rows related to the current row — but, unlike GROUP BY, it keeps every row. The window is defined by OVER (PARTITION BY … ORDER BY …).

💡Intuition — GROUP BY collapses, windows annotate

GROUP BY category returns one row per category. A window function with PARTITION BY category returns every original row, each annotated with a per-category calculation (a rank, a running total, the category average). The PARTITION BY is the “group”; the difference is that the rows survive. Grasping partitioning is the whole game — everything else is which function you apply.

Digital media To rank content within each category by views — the foundation of every “top 3 in this genre” rail:

sqlranking.sql
SELECT
    category, title, views,
    ROW_NUMBER() OVER (PARTITION BY category ORDER BY views DESC) AS rn,
    RANK()       OVER (PARTITION BY category ORDER BY views DESC) AS rnk,
    DENSE_RANK() OVER (PARTITION BY category ORDER BY views DESC) AS dense_rnk,
    NTILE(4)     OVER (PARTITION BY category ORDER BY views DESC) AS quartile
FROM content;

-- Top 3 per category: rank in a subquery/CTE, then filter
WITH ranked AS (
    SELECT category, title, views,
           DENSE_RANK() OVER (PARTITION BY category ORDER BY views DESC) AS rnk
    FROM content
)
SELECT category, title, views
FROM ranked
WHERE rnk <= 3
ORDER BY category, views DESC;
Result (illustrative — one category)
categorytitleviewsrnrnkdense_rnk
ComedyPilot Night91000111
ComedyOpen Mic91000211
ComedyRoast Hour74000332
Common pitfall — ROW_NUMBER vs RANK vs DENSE_RANK on ties

Fact They differ only on ties. ROW_NUMBER always gives distinct 1,2,3 (ties broken arbitrarily). RANK gives ties the same rank then skips (1,1,3). DENSE_RANK gives ties the same rank with no gap (1,1,2). For “top 3 including ties” you usually want DENSE_RANK; for “exactly 3 rows” you want ROW_NUMBER. Choosing the wrong one is the most common windowing bug — and a favourite interview probe.

Why filter in an outer query?

You can't put a window function in WHERE (windows are computed after WHERE, alongside SELECT). So the idiom is: compute the rank in a CTE/subquery, then filter on it in the outer query. This two-step is the canonical “top-N-per-group” shape — memorise it.

LeetCode practice
InterviewFor “top 3 videos per category, including all ties at 3rd place,” which ranking function should the filter rnk <= 3 use?
Key takeaways
  • Window functions compute over related rows but keep every row.
  • OVER (PARTITION BY … ORDER BY …) defines the window; partitioning is the key idea.
  • ROW_NUMBER/RANK/DENSE_RANK differ only on ties — pick by intent.
  • Top-N-per-group = rank in a CTE, then filter outside (windows can't go in WHERE).
Lesson 3.5·13 min read

LAG, LEAD, Running Totals & Gaps-and-Islands

Looking at neighbouring rows, accumulating over an ordering, and the gaps-and-islands pattern behind streaks and contiguous-range problems.

The same window machinery powers offset and cumulative calculations. LAG/LEAD peek at previous/next rows; a running total is SUM(…) OVER (ORDER BY …).

sqlrunning.sql
-- FMCG: cumulative (running) revenue and week-over-week change
SELECT
    sold_week,
    revenue,
    SUM(revenue) OVER (ORDER BY sold_week)               AS running_revenue,
    LAG(revenue) OVER (ORDER BY sold_week)               AS prev_week,
    revenue - LAG(revenue) OVER (ORDER BY sold_week)     AS wow_change,
    AVG(revenue) OVER (ORDER BY sold_week
                       ROWS BETWEEN 3 PRECEDING AND CURRENT ROW) AS moving_avg_4w
FROM weekly_sales
ORDER BY sold_week;
Common pitfall — the window frame default

Fact When you add ORDER BY to a window without an explicit frame, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — which is what makes SUM() OVER (ORDER BY …) a running total rather than a grand total. But RANGE with duplicate ORDER BY values lumps peers together, which can surprise you; for row-precise windows (like a 4-row moving average) specify ROWS BETWEEN … explicitly. Defaults are a frequent source of “my running total jumps” confusion.

Gaps-and-islands: streaks of consecutive activity

Gaming A login-streak question — “how many consecutive days did each player log in?” — is the classic gaps-and-islands problem. The trick: subtract a ROW_NUMBER from the date; consecutive dates share a constant difference, identifying each “island.”

sqlgaps_islands.sql
-- One row per player per active day => find consecutive-day streaks
WITH numbered AS (
    SELECT player_id, login_date,
           login_date
             - (ROW_NUMBER() OVER (PARTITION BY player_id ORDER BY login_date))::int
             AS grp                       -- constant within a consecutive run
    FROM daily_logins
)
SELECT player_id,
       MIN(login_date) AS streak_start,
       MAX(login_date) AS streak_end,
       COUNT(*)        AS streak_length
FROM numbered
GROUP BY player_id, grp
ORDER BY player_id, streak_start;
💡Intuition — why date minus row-number works

Walk consecutive dates 10,11,12 and number them 1,2,3. Subtract: 9,9,9 — constant. A gap (skip to the 15th, numbered 4) gives 11 — a new constant, hence a new island. Grouping by that difference collapses each run into one streak. Inference This pattern (and its cousins using LAG to flag breaks) underlies a whole family of LeetCode hards — stadium attendance, contiguous dates, winning streaks — so it's worth drilling until it's automatic.

LeetCode practice
InterviewIn the streak query, why subtract ROW_NUMBER() (ordered by date) from the login date?
Key takeaways
  • LAG/LEAD read neighbouring rows; great for deltas and retention.
  • SUM() OVER (ORDER BY …) is a running total; specify ROWS BETWEEN for moving windows.
  • The default frame is RANGE … CURRENT ROW — know it, override when needed.
  • Gaps-and-islands (date − row-number) powers streaks and contiguous-range hards.
Month 4 · Building the database

Database Design & Modification

From querying tables to creating them: CREATE/ALTER/DROP, safely changing data with INSERT/UPDATE/DELETE, normalization 1NF–3NF, and your first indexes.

Lesson 4.1·11 min read

DDL: CREATE TABLE, Types & Constraints

Defining a table's structure and the rules that protect its integrity — the constraints that turn a spreadsheet into a database.

DDL (Data Definition Language) defines structure. CREATE TABLE names columns, types, and constraints — rules the database enforces on every write. E-commerce A properly-constrained orders schema:

sqlschema.sql
CREATE TABLE customers (
    customer_id  SERIAL       PRIMARY KEY,           -- unique, auto-incrementing
    email        VARCHAR(255) NOT NULL UNIQUE,       -- required, no duplicates
    full_name    VARCHAR(120) NOT NULL,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE TABLE orders (
    order_id     SERIAL       PRIMARY KEY,
    customer_id  INTEGER      NOT NULL
                 REFERENCES customers(customer_id),   -- FOREIGN KEY
    total        NUMERIC(12,2) NOT NULL CHECK (total >= 0),  -- no negatives
    status       VARCHAR(20)  NOT NULL DEFAULT 'pending'
                 CHECK (status IN ('pending','paid','shipped','cancelled')),
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now()
);
ConstraintGuarantees
PRIMARY KEYUnique & not null — the row's identity
FOREIGN KEY / REFERENCESReferential integrity — can't reference a non-existent parent
NOT NULLA value must be provided
UNIQUENo duplicate values in the column
CHECK (…)A custom rule (e.g. total >= 0)
DEFAULTA value to use when none is supplied
💡Intuition — constraints are correctness you can't forget to check

Application code can validate data — but every code path must remember to. A database constraint validates once, centrally, for every writer, including ad-hoc scripts and other services. Inference Pushing invariants into the schema is widely regarded as more robust than relying on application checks alone, because the database becomes the single source of truth for “what data is even allowed.”

Industry lens

Open question How strict to be is a real design tension. Tight CHECK constraints and foreign keys catch bad data early but can slow high-volume writes and complicate migrations; some high-scale systems deliberately relax foreign keys and enforce integrity in the application instead. There's no universal answer — it depends on workload, scale, and team. We default to enforcing integrity in the schema for the OLTP systems this course targets.

InterviewWhat does a FOREIGN KEY on orders.customer_id referencing customers prevent?
Key takeaways
  • CREATE TABLE defines columns, types, and constraints.
  • PRIMARY KEY, FOREIGN KEY, NOT NULL, UNIQUE, CHECK, DEFAULT each enforce an invariant.
  • Constraints centralise correctness for every writer.
  • How strict to be is a workload-dependent design choice.
Lesson 4.2·10 min read

DML: INSERT, UPDATE, DELETE & UPSERT

Changing the data itself — safely. The WHERE clause you forget here is the one that rewrites your whole table.

DML (Data Manipulation Language) changes rows. FMCG Maintaining an inventory table:

sqldml.sql
-- INSERT one or many rows
INSERT INTO skus (product_name, brand, unit_price)
VALUES ('Choco Wafer 45g', 'SnackCo', 1.20),
       ('Sparkling Water 1L', 'Aqua', 0.90);

-- UPDATE -- the WHERE clause is mandatory in practice, not syntax
UPDATE skus
SET unit_price = unit_price * 1.05      -- 5% price rise
WHERE brand = 'SnackCo';                -- WITHOUT this, EVERY row changes

-- DELETE -- same warning, with higher stakes
DELETE FROM skus
WHERE launched_on < DATE '2020-01-01';  -- retire ancient SKUs

-- UPSERT: insert, or update if it already exists (Postgres syntax)
INSERT INTO inventory (sku_id, on_hand)
VALUES (1001, 50)
ON CONFLICT (sku_id)
DO UPDATE SET on_hand = inventory.on_hand + EXCLUDED.on_hand;
Common pitfall — the missing WHERE

Fact UPDATE skus SET unit_price = … with no WHERE updates every row; DELETE FROM skus with no WHERE empties the table. There is no “undo” once committed. Inference The widely-taught safety habit: run the matching SELECT first to see which rows you'll hit, and wrap risky changes in a transaction (BEGIN; … ROLLBACK/COMMIT;, Month 5) so you can inspect before committing.

UPSERT dialects

“Insert-or-update” is spelled differently per engine: PostgreSQL/SQLite use INSERT … ON CONFLICT … DO UPDATE; MySQL uses INSERT … ON DUPLICATE KEY UPDATE; the SQL standard offers MERGE (supported by SQL Server, Oracle, and recent Postgres). Same idea, three spellings — a good example of why “know your dialect” matters.

LeetCode practice
InterviewWhich habit best prevents an accidental mass UPDATE/DELETE in production?
Key takeaways
  • INSERT adds rows; UPDATE changes them; DELETE removes them.
  • A missing WHERE hits the whole table — preview with SELECT, use transactions.
  • UPSERT (ON CONFLICT/ON DUPLICATE KEY/MERGE) inserts-or-updates atomically.
  • The exact syntax is dialect-specific.
Lesson 4.3·8 min read

ALTER, DROP & TRUNCATE

Evolving a schema in flight, and the three different ways to “remove” things — with very different consequences.

Schemas change as products evolve. ALTER TABLE modifies structure without recreating the table:

sqlalter.sql
-- Add, modify, rename, and drop columns
ALTER TABLE customers ADD COLUMN loyalty_tier VARCHAR(20) DEFAULT 'standard';
ALTER TABLE customers ALTER COLUMN full_name TYPE VARCHAR(160);
ALTER TABLE customers RENAME COLUMN full_name TO display_name;
ALTER TABLE customers DROP COLUMN loyalty_tier;

-- Add a constraint or index after the fact
ALTER TABLE orders ADD CONSTRAINT chk_total_pos CHECK (total >= 0);
Common pitfall — DROP vs DELETE vs TRUNCATE

Three very different operations that beginners conflate:

  • DELETE FROM t WHERE … — removes selected rows; row-by-row, logged, transaction-safe, fires triggers.
  • TRUNCATE TABLE t — removes all rows fast by deallocating storage; the table structure stays. Fact It's typically much faster than an unqualified DELETE but can't be filtered and (in many engines) can't be rolled back the same way.
  • DROP TABLE t — removes the entire table, structure and all. It's gone.
Migrations in the real world

Inference In production, schema changes are normally applied through versioned migration tools (Flyway, Liquibase, Alembic, Rails migrations) rather than hand-typed ALTERs, so changes are reviewable, repeatable, and reversible across environments. Open question Some ALTERs lock the table or rewrite it (cost grows with table size), so the safe procedure for large tables is engine- and version-specific — always check the docs before altering a big production table.

InterviewYou need to empty a 50-million-row staging table quickly, keeping its structure, with no need to filter rows. Best choice?
Key takeaways
  • ALTER TABLE adds/modifies/renames/drops columns and constraints.
  • DELETE (selected rows) ≠ TRUNCATE (all rows, fast) ≠ DROP (whole table).
  • Use migration tools for production schema changes.
  • Large-table ALTERs may lock/rewrite — check engine specifics.
Lesson 4.4·12 min read

Normalization 1NF–3NF (& Denormalization)

Organising columns so each fact lives in exactly one place — eliminating redundancy and update anomalies — and when to deliberately break the rules.

Normalization structures tables to reduce redundancy and prevent anomalies — the bugs that arise when the same fact is stored in many places. E-commerce Consider a denormalized orders sheet that repeats customer and product data on every line:

order_idcustomer_emailcustomer_cityproducts_boughtline_total
1ana@mail.comMadrid“Bottle, Shoes”145.00
2ana@mail.comMadrid“Socks”12.00

This breaks down quickly. The three normal forms fix it step by step:

First normal form (1NF) — atomic values

Each cell holds a single value; no lists, no repeating groups. Fact “Bottle, Shoes” in one column violates 1NF — you can't filter or join on it cleanly. Fix: one row per order line in an order_items table.

Second normal form (2NF) — no partial dependencies

Already in 1NF, and every non-key column depends on the whole primary key, not part of it. Relevant when the PK is composite: in order_items(order_id, product_id, …), a product's name depends only on product_id, not the full key — so it belongs in a products table.

Third normal form (3NF) — no transitive dependencies

Already in 2NF, and non-key columns depend only on the key — not on other non-key columns. customer_city depends on customer_email (a non-key), not on order_id; so customer attributes move to a customers table. The result: customer facts live once, in customers.

💡Intuition — one fact, one place

The whole spirit of 1NF–3NF compresses to: every fact lives in exactly one place. If Ana changes city, a normalized design updates one customers row; the denormalized sheet needs every one of her order rows updated, and misses cause an update anomaly (rows disagreeing about her city). Normalization trades a little query-time joining for a lot of write-time safety.

Common pitfall — over-normalizing for analytics

Open question The “right” normal form is workload-dependent. Inference For OLTP (lots of small writes), 3NF is the usual default — it keeps writes safe. For analytics/reporting (lots of big reads), teams often deliberately denormalize — star schemas, pre-joined wide tables, materialized views — to avoid expensive joins at query time, accepting controlled redundancy. This course assumes a typical OLTP schema unless noted; Month 5 shows how these design choices change query plans.

Industry lens — the synthesis

Normalization (Month 4) and performance (Month 5) are two ends of one conversation. A 3NF schema is safe to write but may need many joins to report on; a denormalized one is fast to read but risky to keep consistent. Senior engineers don't pick a side dogmatically — they normalize the transactional core and denormalize specific read paths where measurement justifies it.

InterviewStoring customer_city directly on every orders row (rather than in a customers table) most directly violates which normal form, and risks what?
Key takeaways
  • 1NF: atomic cells; 2NF: no partial-key dependencies; 3NF: no transitive dependencies.
  • The spirit: every fact lives in exactly one place — preventing update anomalies.
  • 3NF is the OLTP default; analytics often denormalizes deliberately.
  • Design (Month 4) and performance (Month 5) are one trade-off, not two topics.
Lesson 4.5·10 min read

Indexing Fundamentals

The data structure that turns a full-table scan into a fast lookup — what an index is, when it helps, and what it costs.

An index is an auxiliary data structure (commonly a B-tree) that lets the database find rows by a column's value without scanning the whole table — like a book's index versus reading every page.

sqlindexes.sql
-- Speeds up lookups and joins on these columns
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE UNIQUE INDEX idx_customers_email ON customers (email);

-- Composite index: column ORDER matters (left-to-right prefix rule)
CREATE INDEX idx_sales_cat_date ON sales (category, sold_on);
-- Helps WHERE category = ? [AND sold_on ...]; does NOT help WHERE sold_on = ? alone
💡Intuition — the phone-book model

A B-tree index keeps values sorted, so the engine can binary-search to a value or scan a contiguous range — turning an O(n) scan into roughly O(log n) for selective lookups. A composite index (category, sold_on) is like a phone book sorted by surname then first name: great for “Smith” or “Smith, John,” useless for finding all “John”s regardless of surname. That's the leftmost-prefix rule.

Common pitfall — indexes aren't free, and aren't always used

Fact Every index must be updated on INSERT/UPDATE/DELETE and consumes storage — so indexes speed reads but slow writes. Inference On small tables, or for low-selectivity predicates (e.g. a boolean that's true for half the rows), the planner may correctly choose a full scan over an index. Open question Whether a specific index helps a specific query is genuinely data-dependent — it hinges on table size, selectivity, and the optimizer's cost model. The only reliable verdict comes from EXPLAIN (Month 5), not intuition.

Industry lens

The practical heuristic: index columns you frequently filter or join on (foreign keys are prime candidates), but resist indexing everything — each index taxes every write and bloats storage. A table with a dozen rarely-used indexes can have slower writes and a confused planner. Indexing is a measured trade-off, revisited as query patterns emerge.

Practice resource

LeetCode's SQL judge doesn't grade indexing directly, but Use The Index, Luke is the definitive free, engine-agnostic guide to how indexes really work — pair it with this lesson and Month 5.

InterviewYou have a composite index on (category, sold_on). Which query can use it efficiently as an index seek?
Key takeaways
  • An index (usually a B-tree) enables fast value lookups and range scans.
  • Composite indexes follow the leftmost-prefix rule — column order matters.
  • Indexes speed reads but slow writes and cost storage; not always chosen by the planner.
  • Index frequently filtered/joined columns; verify benefit with EXPLAIN.
Month 5 · Performance & portfolio

Advanced Optimization & Portfolio

Reading execution plans, indexing strategically, guaranteeing correctness with transactions, automating logic with procedures and triggers — then a cross-vertical capstone.

Lesson 5.1·12 min read

EXPLAIN & Execution Plans

Asking the database how it intends to run your query — the single most important optimization skill, because it replaces guessing with evidence.

EXPLAIN shows the execution plan — the steps the optimizer chose (scans, joins, sorts) with estimated costs. EXPLAIN ANALYZE actually runs the query and reports real timings and row counts. Digital media A slow “views per content” report:

sqlexplain.sql
EXPLAIN ANALYZE
SELECT c.title, COUNT(*) AS views
FROM content c
JOIN views v ON v.content_id = c.content_id
WHERE c.published_at >= DATE '2026-01-01'
GROUP BY c.title;
textplan.txt
HashAggregate  (cost=24180..24320 rows=1400)  (actual time=98.2..101.0 rows=1387)
  Group Key: c.title
  ->  Hash Join  (cost=410..23110 rows=210000)  (actual time=4.1..72.5 rows=208912)
        Hash Cond: (v.content_id = c.content_id)
        ->  Seq Scan on views v  (cost=0..18100 rows=900000)
        ->  Hash  (cost=395..395 rows=1400)
              ->  Index Scan using idx_content_pub on content c
                    Index Cond: (published_at >= '2026-01-01')
Planning Time: 0.4 ms
Execution Time: 101.9 ms
💡Intuition — read plans inside-out, bottom-up

Plans are trees: the most indented nodes run first and feed their parents. Here, content is filtered by an index scan (good — the index on published_at is used), then hash-joined to a sequential scan of views (all 900k rows read). Fact A Seq Scan reads every row; an Index Scan seeks via an index. Inference The likely win here is an index on views.content_id so the join can probe instead of scanning — but only EXPLAIN ANALYZE before and after will confirm it actually helped on your data.

Common pitfall — estimates vs reality

Plain EXPLAIN shows the planner's estimated rows and cost; these can be wrong if the table's statistics are stale. A large gap between estimated rows and the actual rows from EXPLAIN ANALYZE is a classic smell — often fixed by refreshing statistics (ANALYZE, Lesson 5.2). Optimize against ANALYZE's real numbers, never against estimates alone.

Dialect note

Plan output differs by engine: PostgreSQL's EXPLAIN (ANALYZE, BUFFERS) is richly detailed; MySQL has EXPLAIN, EXPLAIN ANALYZE (8.0.18+), and EXPLAIN FORMAT=JSON; SQLite has EXPLAIN QUERY PLAN. Open question Because the optimizers and cost models differ, the same query can get different plans on MySQL vs PostgreSQL — one reason “which is faster” has no general answer.

Practice resource

LeetCode grades correctness, not plans — but you can still run EXPLAIN on your own copy of any solved problem. The PostgreSQL EXPLAIN docs are the authoritative reference for reading the output.

InterviewWhat is the key difference between EXPLAIN and EXPLAIN ANALYZE?
Key takeaways
  • EXPLAIN shows the plan; EXPLAIN ANALYZE runs it and reports actuals.
  • Read plans bottom-up; know Seq Scan vs Index Scan.
  • Big estimate-vs-actual gaps signal stale statistics.
  • Plan shapes are engine-specific — optimize against real numbers.
Lesson 5.2·11 min read

Index Strategy, Covering Indexes & Maintenance

From “what an index is” to “which index, in what order, and how to keep the database healthy” — covering indexes, VACUUM, and ANALYZE.

Month 4 introduced indexes; now we use them strategically. A covering index includes every column a query needs, so the query is answered from the index alone — the table heap is never touched (an “index-only scan”).

sqlcovering.sql
-- Query: SELECT total FROM orders WHERE customer_id = 42;
-- A covering index carries customer_id (to seek) AND total (to return)
CREATE INDEX idx_orders_cust_total ON orders (customer_id) INCLUDE (total);  -- Postgres
-- MySQL equivalent: a composite index (customer_id, total)

-- Maintenance: keep planner statistics fresh and reclaim dead space
ANALYZE orders;          -- refresh stats the optimizer relies on
VACUUM orders;           -- reclaim space from updated/deleted rows (Postgres MVCC)
VACUUM ANALYZE orders;   -- both at once
💡Intuition — why SELECT * hurts covering

A covering index works only if it contains all the columns the query touches. This is exactly why SELECT * (Month 1) defeats it — asking for every column forces the engine back to the table for the ones the index doesn't carry. Inference Selecting only the columns you need is what makes index-only scans possible; it's the same habit paying off again.

VACUUM & MVCC (PostgreSQL)

Fact PostgreSQL uses MVCC (multi-version concurrency control): updates and deletes leave behind “dead” row versions that VACUUM reclaims, and autovacuum normally does this automatically. ANALYZE updates the statistics the planner uses for cost estimates. Other engines manage space differently (MySQL/InnoDB has its own purge and ANALYZE TABLE), so the maintenance specifics are engine-dependent — the principle (keep stats fresh, reclaim space) is universal.

Industry lens — a tuning workflow

A repeatable loop: (1) find the slow query (logs / pg_stat_statements); (2) EXPLAIN ANALYZE it; (3) form a hypothesis (missing index? stale stats? fan-out?); (4) change one thing; (5) re-measure. Inference Adding a well-chosen index to a selective filter on a large table is one of the highest-leverage fixes — but only measurement tells you whether it helped, and whether the write cost is acceptable.

InterviewA query is SELECT total FROM orders WHERE customer_id = 42. Why might an index on (customer_id) INCLUDE (total) outperform a plain index on (customer_id)?
Key takeaways
  • A covering index carries all columns a query needs → index-only scan.
  • SELECT * defeats covering — select only what you need.
  • ANALYZE refreshes planner statistics; VACUUM reclaims dead-row space (Postgres MVCC).
  • Tune in a measured loop: find → EXPLAIN ANALYZE → change one thing → re-measure.
Lesson 5.3·11 min read

Transactions & ACID

Grouping statements so they all succeed or all fail — the guarantee that makes money and inventory safe, plus isolation levels.

A transaction bundles statements into an all-or-nothing unit. E-commerce Checkout must decrement stock and create the order — never one without the other:

sqltransaction.sql
BEGIN;                                   -- start the transaction

UPDATE inventory SET on_hand = on_hand - 1
WHERE sku_id = 1001 AND on_hand >= 1;     -- guard against overselling

INSERT INTO orders (customer_id, total, status)
VALUES (42, 19.99, 'paid');

-- If both succeeded, make the changes permanent and visible to others:
COMMIT;
-- If anything went wrong instead, undo everything:
-- ROLLBACK;

ACID names the four guarantees a transaction provides:

PropertyGuarantee
AtomicityAll statements commit, or none do — no partial checkout
ConsistencyConstraints hold before and after — the DB moves between valid states
IsolationConcurrent transactions don't corrupt each other's view
DurabilityOnce committed, changes survive a crash
💡Intuition — isolation levels trade safety for speed

When many transactions run at once, isolation levels control which concurrency anomalies are possible — dirty reads, non-repeatable reads, phantom reads. From loosest to strictest: READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE. Fact Stricter levels prevent more anomalies but generally reduce concurrency. Inference The right level is a workload trade-off; Open question even the default differs by engine — PostgreSQL defaults to READ COMMITTED, MySQL/InnoDB to REPEATABLE READ — so never assume; check.

Common pitfall — long-running transactions

Holding a transaction open for a long time can block other writers (via locks) and, in MVCC engines, prevent VACUUM from reclaiming space. Keep transactions short and focused: do the minimal set of related changes, then commit. The BEGIN; … ROLLBACK; habit from Lesson 4.2 for previewing destructive DML is the safe, short use of this same machinery.

InterviewDuring checkout, the stock UPDATE succeeds but the order INSERT fails. Inside a single transaction, what happens at ROLLBACK (or on error)?
Key takeaways
  • BEGIN … COMMIT makes statements all-or-nothing; ROLLBACK undoes them.
  • ACID = Atomicity, Consistency, Isolation, Durability.
  • Isolation levels trade anomaly-prevention against concurrency; defaults differ by engine.
  • Keep transactions short — long ones block writers and hinder VACUUM.
Lesson 5.4·10 min read

Stored Procedures, Functions & Triggers

Logic that lives in the database — reusable routines and automatic reactions to data changes — and the standing debate about when to use them.

The database can store executable logic. A function returns a value and is callable in queries; a stored procedure performs actions (and can manage transactions); a trigger runs automatically in response to an INSERT/UPDATE/DELETE.

sqltrigger.sql
-- A trigger that writes an audit row whenever an order's status changes
CREATE FUNCTION log_status_change() RETURNS TRIGGER AS $$
BEGIN
    IF NEW.status <> OLD.status THEN
        INSERT INTO order_audit (order_id, old_status, new_status, changed_at)
        VALUES (NEW.order_id, OLD.status, NEW.status, now());
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_status_audit
AFTER UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION log_status_change();
Industry lens — a genuine open debate

Open question Whether complex business logic belongs in stored procedures/triggers or in application code is a long-standing, unresolved design debate — not a settled best practice. For in-database logic: it runs close to the data (less round-tripping), is enforced for every client, and can be transactional. Against: it's harder to version-control, test, and debug than application code, and triggers create “spooky action at a distance” where a simple UPDATE silently does more than it appears. We present both; the right call depends on team, scale, and tooling.

Common pitfall — hidden trigger side-effects

Because triggers fire invisibly, they can surprise: an innocuous-looking bulk UPDATE might fire a per-row trigger thousands of times, or cascade into other triggers. Inference Triggers are best reserved for cross-cutting concerns like auditing or maintaining a derived column — and documented prominently — rather than core business rules a developer would expect to find in code.

Dialect note

Procedural SQL is one of the least standardised areas: PostgreSQL uses PL/pgSQL, MySQL its own stored-routine syntax, SQL Server T-SQL, Oracle PL/SQL. Concepts transfer, but the code does not — a strong reason to keep heavy logic portable when you can.

InterviewA teammate proposes putting all order-validation logic into database triggers. What's the most balanced response?
Key takeaways
  • Functions return values; procedures perform actions; triggers fire automatically on DML.
  • In-DB logic vs application logic is an unresolved trade-off — present both.
  • Triggers suit auditing/derived data; beware hidden, cascading side-effects.
  • Procedural SQL is highly dialect-specific.
Lesson 5.5·10 min read

Capstone Project: Cross-Vertical Analytics

A portfolio-grade build that pulls together everything — schema design, integrity, analytical queries, and an optimization write-up.

The capstone synthesises all five months into one defensible artifact. Pick one vertical you find motivating — e-commerce, gaming, FMCG, or digital media — and build the following in a Git repository. Inference A clean, documented schema plus a set of analytical queries is, in practice, one of the most credible portfolio pieces for analytics and data-engineering roles.

The brief

  • Design (M4). A normalized (3NF) schema of 5–8 tables with primary keys, foreign keys, and CHECK constraints. Include a short note justifying each normalization choice — and any deliberate denormalization, with its rationale.
  • Populate (M1, M4). Seed realistic sample data with INSERT (or generate it). Enough rows that query plans are meaningful (tens of thousands+).
  • Analytics (M1–M3). At least eight business questions answered as queries, spanning: a multi-table JOIN; a GROUP BY/HAVING with the right grain; a correlated subquery or CTE; a ranking window function (top-N per group); a running total or moving average; and a gaps-and-islands streak/retention query.
  • Performance (M5). Take your slowest query, capture EXPLAIN ANALYZE before, add a justified index, capture it after, and write a paragraph interpreting the change — clearly labelling what's a measured fact versus an inference.
  • Integrity (M5). Wrap one multi-step mutation (e.g. a checkout, a guild transfer, a stock movement) in a transaction, and explain which ACID property each step relies on.
Industry lens — what reviewers look for

Beyond correctness: a clear README with the schema diagram and the business questions; queries that are readable (CTEs over deep nesting); honest commentary that distinguishes what you measured from what you inferred; and evidence you understand the trade-offs (why 3NF here, why this index, why this isolation level). The optimization write-up — a real before/after with EXPLAIN ANALYZE — is what most separates a portfolio from a tutorial.

Scope & limitations

Build it on PostgreSQL 16 for a consistent target (the optimization specifics assume it). Open question Your indexing and plan results won't transfer unchanged to MySQL or a columnar warehouse — note that limitation in your write-up rather than over-claiming generality. That honesty is itself a senior signal.

InterviewIn the capstone's optimization write-up, you add an index and the query gets faster. What's the most defensible way to state the result?
Key takeaways
  • The capstone integrates design, integrity, analytics, and optimization in one repo.
  • Answer 8+ business questions spanning joins, grouping, subqueries/CTEs, and window functions.
  • Include a real before/after EXPLAIN ANALYZE optimization write-up.
  • State results as measured facts vs inferences, and note where they won't generalize.
Part 7

Data Modelling & Warehousing

Normalisation, star schemas, grain, slowly changing dimensions and the ELT layer cake: how to design the tables everyone else will query.

Lesson 7.1·14 min read

Normalisation Deep Dive: BCNF, 4NF & When to Stop

You will be able to spot a redundancy anomaly in a real table, name the normal form it violates, decompose it — and argue convincingly when decomposing further would be a mistake.

Banking A retail bank stores one row per loan application. Each row carries the applicant's name, their branch, the branch's postcode, the branch manager's name, and the product they applied for. It works. Then the branch on Kingsway moves two streets and the postcode changes. Someone runs an update, it touches 40,000 rows, and it times out halfway. The bank now has two postcodes for one branch and no way to tell which is current.

That is not a bug in the update. It is a bug in the table. The postcode is a fact about the branch, and it has been stored once per application. Normalisation is the discipline that makes that class of mistake impossible by construction, and its normal forms are simply a ladder of increasingly strict answers to one question: is every non-key column a fact about the whole key, and nothing but the key?

The three anomalies you are defending against

Before the formalism, the symptoms. A badly-shaped table produces exactly three failure modes:

  • Update anomaly. One real-world fact lives in many rows. Change some and not others and the table contradicts itself. The Kingsway postcode.
  • Insertion anomaly. You cannot record a fact until an unrelated fact exists. You cannot register a new branch until someone applies for a loan there, because the only place a branch is recorded is the applications table.
  • Deletion anomaly. Removing one fact silently destroys another. Delete the last application at a branch and the branch's address disappears with it.

Each normal form eliminates one structural cause of these. Fact The forms are cumulative: a table in BCNF is necessarily in 3NF, which is necessarily in 2NF, which is necessarily in 1NF.

The ladder, briefly, then the interesting part

A functional dependency, written A → B, means “knowing A tells you B, always and uniquely”. customer_id → country: given the customer, the country is determined. A candidate key is a minimal set of columns that functionally determines every other column.

FormRuleAnomaly it kills
1NFEvery cell holds a single, atomic value. No repeating groups, no comma-separated lists.You cannot query or constrain what is buried inside a string.
2NFNo non-key column depends on only part of a composite key.Partial-key redundancy, e.g. product name repeated on every line of every order.
3NFNo non-key column depends on another non-key column (no transitive dependency).The Kingsway postcode: application_id → branch → postcode.
BCNFFor every dependency A → B, A must be a superkey. No exceptions for prime columns.The rare case 3NF permits: a key column determined by a non-key column.
4NFNo non-trivial multi-valued dependency unless its left side is a superkey.Two independent lists jammed into one table, producing a cross-product of rows.

Here is a transitive dependency made visible in the canonical dataset. Flattening customer attributes onto orders duplicates each country across every order that customer placed:

sqltransitive_dependency.sql
-- Build the denormalised shape deliberately, then measure the damage.
CREATE TABLE order_flat AS
SELECT o.order_id, o.customer_id, c.full_name, c.country, o.order_date
FROM orders    AS o
JOIN customers AS c ON c.customer_id = o.customer_id;

-- country is a fact about customer_id, not about order_id.
-- Every row beyond the first is a copy waiting to disagree.
SELECT customer_id,
       COUNT(DISTINCT country) AS distinct_countries,
       COUNT(*)                AS rows_storing_it
FROM order_flat
GROUP BY customer_id
HAVING COUNT(*) > 1
ORDER BY customer_id;
Result
customer_iddistinct_countriesrows_storing_it
113
312
503
812
1013
1212
1512

Customer 1's country is stored three times. Today all three agree, so distinct_countries is 1. Nothing in the table enforces that. Note customer 5: COUNT(DISTINCT) returns 0 because the value is NULL and COUNT(DISTINCT) ignores nulls — a reminder that “zero distinct values” and “consistent” are not the same statement.

Analogy

Normalisation is writing a phone number in one address book instead of on forty Post-it notes. The Post-its are faster to read — the number is right there next to whatever you were doing. But when the number changes you must find all forty, and the one you miss will be the one you use next month. The address book costs you a walk to the shelf every time. That walk is the join. Everything in this part is a negotiation about how many walks you are willing to take.

BCNF: the case 3NF lets through

3NF has a loophole. Its rule forgives a dependency whose right-hand side is a prime column — one that participates in some candidate key. BCNF removes the forgiveness: for every non-trivial dependency A → B, A must be a superkey, full stop.

Healthcare A clinic books appointments. The table is (patient_id, appointment_slot, clinician_id). Business rules: a patient in a given slot sees exactly one clinician, so (patient_id, appointment_slot) → clinician_id. But each clinician works only one specialty room, and the clinic has decided that a clinician is only ever booked into slots of a single type — so clinician_id → appointment_slot.

Candidate keys are (patient_id, appointment_slot) and (patient_id, clinician_id). Every column is prime, so the table satisfies 3NF. Yet clinician_id → appointment_slot has a non-superkey on the left, so BCNF fails — and the anomaly is real: you cannot record which slot a clinician works until some patient books them, and the clinician–slot pairing is repeated on every booking. Decompose into (clinician_id, appointment_slot) and (patient_id, clinician_id).

Note — the price of BCNF

BCNF decomposition is always lossless (you can rejoin and get the original rows back) but it is not always dependency-preserving: some original constraint may no longer be checkable on a single table, only across the join. 3NF decomposition can always preserve dependencies. That trade-off is why 3NF, not BCNF, is the usual practical stopping point for transactional systems.

4NF: two independent lists in one table

A multi-valued dependency arises when one entity has two independent one-to-many relationships and someone stores them side by side. Marketing Suppose a product can be promoted through several channels and shipped to several regions, and the two facts have nothing to do with each other. Storing them in one table forces every combination to exist:

sqlmvd_cross_product.sql
-- Two independent lists (channels, regions) in one table forces a cross-product.
CREATE TABLE product_promo AS
SELECT p.product_id, ch.channel, rg.region
FROM products p
CROSS JOIN (SELECT DISTINCT channel FROM ad_spend) AS ch
CROSS JOIN (SELECT DISTINCT region  FROM sales)    AS rg
WHERE p.product_id IN (101, 102);

SELECT product_id, COUNT(*) AS rows_needed
FROM product_promo
GROUP BY product_id
ORDER BY product_id;
Result
product_idrows_needed
1018
1028

Two channels and four regions produce eight rows per product to express six independent facts. Add a fifth region and you must insert two more rows per product or the table starts implying that region is only valid for some channels. 4NF says: split into product_channel and product_region. The cross-product, if anyone wants it, is a CROSS JOIN at query time — which is exactly what it always was.

Common pitfall — normalising on today's data instead of the business rule

The most frequent modelling error is inferring dependencies by inspecting rows. You query the table, see that every postcode maps to exactly one city, and declare postcode → city. It holds in the sample and is false in general — some postcodes straddle boundaries, and reorganisations reassign them. A functional dependency is a rule the business guarantees, not a pattern the current data happens to show. Data can disprove a dependency; it can never prove one. Ask a person, not a GROUP BY.

When to stop — and when not to start

Normalisation optimises for write correctness. Every level of decomposition buys integrity and charges a join. In an analytical warehouse the write pattern is a controlled nightly load by one pipeline, so update anomalies barely arise — while the read pattern is thousands of ad-hoc aggregations, so joins are the dominant cost. That inversion is the whole reason dimensional modelling exists (Lesson 7.3).

  • Stop at 3NF for transactional systems. It removes essentially all redundancy you will meet in practice while remaining dependency-preserving and easy to explain to the next engineer.
  • Go to BCNF only when you have identified a concrete anomaly that 3NF permits — overlapping candidate keys are the tell.
  • Go to 4NF/5NF when you catch yourself maintaining a cross-product by hand.
  • Do not normalise at all in a reporting layer, an event log, or an immutable snapshot. Redundancy is only dangerous when something can update it. An append-only log cannot suffer an update anomaly, because nothing updates.

Open How much normalisation an analytical layer deserves is genuinely contested, and the answer has drifted as storage got cheap and columnar engines got good at joins. Practitioners who value governance push normalisation deeper into the warehouse; practitioners who value query simplicity flatten aggressively. Both ship working systems.

Interview angle

“Explain 3NF” is a warm-up; reciting “the key, the whole key, and nothing but the key” scores nothing on its own. The differentiating follow-up is “give me a table in 3NF but not BCNF” — you need overlapping candidate keys, and the appointment example above is a compact one to keep ready. The strongest answers volunteer the cost: BCNF may not preserve dependencies, and warehouses deliberately denormalise. An interviewer is testing whether you treat normal forms as engineering trade-offs or as commandments.

Exercise 7.1

Insurance An insurer keeps one table: (policy_id, customer_id, customer_email, agent_id, agent_office, agent_office_city, cover_type, premium). Business rules: a policy has one customer and one agent; an agent works from one office; an office is in one city.

(a) Identify the key and every functional dependency. (b) Name the highest normal form it satisfies and the anomaly it permits. (c) Write the decomposition, and show a query against the canonical dataset that demonstrates the same class of transitive dependency.

Show solution

(a) Key: policy_id. Dependencies: policy_id → customer_id, agent_id, cover_type, premium; customer_id → customer_email; agent_id → agent_office; agent_office → agent_office_city.

(b) It is in 2NF (the key is a single column, so no partial dependencies are possible) but violates 3NF three times over. The chain policy_id → agent_id → agent_office → agent_office_city is transitive: the city is stored once per policy though it is a fact about an office. Move an office and you must rewrite every policy row it touches; close the last policy at an office and the office's city is gone.

(c) Four tables: policies(policy_id, customer_id, agent_id, cover_type, premium), customers(customer_id, customer_email), agents(agent_id, agent_office), offices(agent_office, agent_office_city). The same shape exists in the canonical data as product_id → category_id → department:

sqlexercise_7_1.sql
-- department is a fact about the category, reached transitively from the product.
-- Storing it on products would repeat it once per product in the category.
SELECT c.department,
       COUNT(*)                    AS categories_in_department,
       SUM(p.n)                    AS products_that_would_repeat_it
FROM categories AS c
JOIN (SELECT category_id, COUNT(*) AS n FROM products GROUP BY category_id) AS p
  ON p.category_id = c.category_id
GROUP BY c.department
ORDER BY c.department;

The count in the last column is the number of rows an update to a department name would have to touch if the transitive dependency were flattened — and the number of chances that update has to go half-finished.

Knowledge check

A table is in 3NF but not BCNF. What must be true of it?

Knowledge check

You query a table and find that every postcode value maps to exactly one city. What have you established?

Key takeaway

Normal forms are a ladder for eliminating one cause of contradiction: a fact stored more than once where something can update the copies independently. 3NF removes transitive dependencies and is the right stopping point for most transactional systems; BCNF closes 3NF's prime-column loophole at the cost of dependency preservation; 4NF stops you hand-maintaining a cross-product. Derive dependencies from business rules, never from a sample — and remember the whole ladder optimises writes, which is why analytics deliberately climbs back down.

Lesson 7.2·13 min read

Denormalisation: Trading Purity for Speed

You will be able to denormalise deliberately — naming which guarantee you are giving up, what now maintains consistency in its place, and how you will detect drift.

Digital media A streaming service has a beautifully normalised warehouse. Answering “minutes watched by genre, by country, by week, for subscribers on the annual plan” requires joining seven tables. The dashboard takes ninety seconds. Analysts stop using it and start keeping their own spreadsheets, and within a quarter there are four different numbers for weekly watch time circulating in the business.

Nothing in that story is a database problem. The model is correct. It is unusable, and an unusable correct model loses to a usable approximate one every single time, because people route around it. Denormalisation is the deliberate reintroduction of redundancy to make reads cheap — and the word that matters is deliberate.

Why the trade-off flipped for analytics

Normalisation assumes many small concurrent writes and few reads. That is a transactional system. An analytical warehouse inverts every term: writes arrive as a controlled batch from one pipeline, and reads are thousands of unpredictable aggregations by people who did not design the schema. When one process owns all the writes, update anomalies stop being an emergent risk and become a testable property of that process.

 Normalised (3NF)Denormalised (wide)
Each fact storedOnceMany times
Cost of a changeOne rowEvery copy — or a full rebuild
Cost of a questionSeveral joinsOne scan
Who enforces consistencyThe database (keys, constraints)The pipeline, plus tests
Failure modeSlow queriesSilently wrong numbers

That last row is the one to memorise. Normalisation fails loudly — the dashboard is slow, everyone complains, you fix it. Denormalisation fails quietly — a copy drifts, the total shifts by two percent, and nobody notices for a year.

The four things people mean by “denormalise”

  1. Column duplication. Copy an attribute from a parent table onto the child so the join disappears — the product's category on the order line.
  2. Pre-joined wide tables. Materialise the whole join graph into one table, one row per grain.
  3. Pre-aggregation. Store the answer, not the ingredients — daily revenue by category rather than every line item.
  4. Repeating groups / nesting. Store an array or struct in one column instead of a child table. Legitimate on columnar engines that can query inside them; see Lesson 7.11.

Here is the second form built end to end. Every attribute an analyst might filter by is carried on the row, so the question needs no joins at all:

sqlwide_fact.sql
-- One row per order line, carrying every attribute anyone filters by.
CREATE TABLE fct_order_item_wide AS
SELECT oi.order_item_id,
       o.order_id,
       o.order_date,
       o.status,
       c.customer_id,
       c.country        AS customer_country,
       c.channel        AS acquisition_channel,
       p.product_id,
       p.product_name,
       cat.category_name,
       cat.department,
       oi.quantity,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders      AS o   ON o.order_id    = oi.order_id
JOIN products    AS p   ON p.product_id  = oi.product_id
JOIN categories  AS cat ON cat.category_id = p.category_id
JOIN customers   AS c   ON c.customer_id = o.customer_id;

-- The question that used to need four joins now needs none.
SELECT department, ROUND(SUM(net_revenue), 2) AS revenue
FROM fct_order_item_wide
WHERE status = 'completed'
GROUP BY department
ORDER BY revenue DESC;
Result
departmentrevenue
Footwear2192.45
Accessories1710.25
Outdoor1275.10
Apparel1183.05

Note what was smuggled in besides speed: net_revenue is now computed once, in one place, by one team. In the normalised world every analyst re-derives it, and one of them will forget the discount. Inference A large share of denormalisation's real value is not I/O at all — it is centralising a definition so the business stops arguing about which revenue figure is correct.

Analogy

Normalisation is a library: one copy of each book, perfectly catalogued, and you walk to four different shelves to answer a question. Denormalisation is a photocopied handout: everything you need on one page, instantly. The handout is better right up until the source book is revised — then every handout in the building is wrong, and unlike the library there is no catalogue telling you where they all went. Which is why the only safe way to run on handouts is to reprint them all, on a schedule, from the book.

What makes a denormalised table safe

The dangerous version is an UPDATE that patches copies in place. The safe version is full or partition-level rebuild from the normalised source: nothing is ever edited, so nothing can drift. If the source changes, the next rebuild reflects it; if the rebuild fails, you have yesterday's table, which is wrong-but-consistent rather than half-updated.

The three properties a denormalised table needs:

  • A single writer. Exactly one pipeline produces it. Nobody patches it by hand, ever.
  • An explicit freshness contract. Consumers know it is as of last night, not as of now.
  • A reconciliation test that recomputes a headline number from the normalised source and fails the build on mismatch.

Finance That third point is not optional in regulated settings. A test that compares the wide table's revenue against the source of record, and refuses to publish on any discrepancy, is what converts denormalisation from a gamble into an engineering practice:

sqlreconciliation_test.sql
-- A drift test: the wide table must agree with the normalised source.
CREATE TABLE fct_wide AS
SELECT oi.order_item_id, o.status, cat.department,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders     AS o   ON o.order_id     = oi.order_id
JOIN products   AS p   ON p.product_id   = oi.product_id
JOIN categories AS cat ON cat.category_id = p.category_id;

WITH from_wide AS (
  SELECT ROUND(SUM(net_revenue), 2) AS revenue
  FROM fct_wide WHERE status = 'completed'
),
from_source AS (
  SELECT ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0)), 2) AS revenue
  FROM order_items AS oi
  JOIN orders AS o ON o.order_id = oi.order_id
  WHERE o.status = 'completed'
)
SELECT w.revenue AS wide_revenue,
       s.revenue AS source_revenue,
       CASE WHEN w.revenue = s.revenue THEN 'PASS' ELSE 'FAIL' END AS drift_check
FROM from_wide AS w CROSS JOIN from_source AS s;
Result
wide_revenuesource_revenuedrift_check
6360.856360.85PASS

Run that on every build. The day someone adds a filter to the wide table's definition and forgets the source, it turns red before the number reaches a slide.

Common pitfall — denormalising a changing attribute without deciding its time semantics

Copying customer_country onto an order row looks harmless. It hides a question nobody asked: is that the country the customer lived in when the order was placed, or the country they live in now? A nightly full rebuild silently chooses “now” — so last year's regional revenue quietly changes every time a customer relocates, and last quarter's board pack no longer reproduces. If you want “as at order time” you must freeze the value at load, which is exactly the Slowly Changing Dimension problem in Lesson 7.7. Copying an attribute always makes a temporal choice; the only mistake is making it by accident.

When NOT to denormalise

  • In an operational database. Many concurrent writers plus duplicated facts is precisely the anomaly scenario. Keep OLTP normalised and denormalise downstream.
  • When the join is already cheap. A columnar engine joining a large fact to a small dimension on an integer key is fast. Denormalising to remove it buys little and costs storage, rebuild time and a drift risk. Measure before you widen.
  • When the attribute changes often. High-churn attributes make the copy stale between rebuilds. Leave them in the dimension and join.
  • When you cannot rebuild. If the source is unavailable or the table is too large to reproduce within the load window, you are committed to patching copies in place — and drift becomes a matter of time.
  • When the requirement is really an index, a partition, or a cached result. Those give you speed without a second copy of the truth. Exhaust them first.

Open How wide a fact table should be is not settled. One camp builds one very wide table per business process so analysts never join; another keeps narrow facts and small dimensions and trusts the engine. The trade-off shifts with your engine's join performance, your storage costs and how much SQL your consumers write themselves.

In the field

The tell for accidental denormalisation is a table whose name ends in _final, _v2 or someone's initials. Deliberate denormalisation has a documented grain, a single owning pipeline, a freshness SLA and a reconciliation test. Accidental denormalisation has a person who remembers roughly how it was built. Both are one wide table; only one of them survives that person changing jobs.

Interview angle

“When would you denormalise?” invites the shallow answer “for performance”. Interviewers want the second half of the sentence: what you give up and what replaces it. A strong answer names the swap explicitly — the database no longer guarantees consistency, so the pipeline must, via idempotent full rebuilds plus a reconciliation test — and adds the temporal trap: a copied attribute silently becomes “current value” unless you freeze it. Mentioning that you would first check whether an index or a cached aggregate solves it signals you reach for the cheap fix before the structural one.

Exercise 7.2

Supply chain A logistics team wants a single table answering “units and revenue by department and month” without joins. Build a pre-aggregated table at monthly × department grain from completed orders, then write the reconciliation query that proves it matches the line-level source. Explain what breaks if a returned order is later re-classified as completed.

Show solution
sqlexercise_7_2.sql
-- Pre-aggregate: one row per month per department.
CREATE TABLE agg_dept_month AS
SELECT DATE_TRUNC('month', o.order_date)            AS month_start,
       cat.department                               AS department,
       SUM(oi.quantity)                             AS units,
       ROUND(SUM(oi.quantity * oi.unit_price
                 * (1 - oi.discount_pct / 100.0)), 2) AS revenue
FROM order_items AS oi
JOIN orders     AS o   ON o.order_id      = oi.order_id
JOIN products   AS p   ON p.product_id    = oi.product_id
JOIN categories AS cat ON cat.category_id = p.category_id
WHERE o.status = 'completed'
GROUP BY 1, 2;

-- Reconcile the rolled-up total against the line-level source.
WITH agg AS (
  SELECT ROUND(SUM(revenue), 2) AS revenue, SUM(units) AS units FROM agg_dept_month
),
src AS (
  SELECT ROUND(SUM(oi.quantity * oi.unit_price
                   * (1 - oi.discount_pct / 100.0)), 2) AS revenue,
         SUM(oi.quantity) AS units
  FROM order_items AS oi
  JOIN orders AS o ON o.order_id = oi.order_id
  WHERE o.status = 'completed'
)
SELECT agg.revenue AS agg_revenue, src.revenue AS src_revenue,
       agg.units   AS agg_units,   src.units   AS src_units,
       CASE WHEN agg.revenue = src.revenue AND agg.units = src.units
            THEN 'PASS' ELSE 'FAIL' END AS drift_check
FROM agg CROSS JOIN src;

What breaks on a status change. The aggregate was filtered on status = 'completed' at build time. If order 1005 flips from returned to completed tomorrow, its month's row is now understated — and crucially the error is in a historical month, not the current one. An incremental pipeline that only rebuilds the latest month will never correct it.

Two defences. Either rebuild a trailing window (say the last 90 days) on every run so late corrections are absorbed, or drop the filter and aggregate by status, letting consumers filter at read time. The second keeps the aggregate a faithful summary of the source rather than an opinion about it — generally the better default, because a pre-aggregate that has baked in a business rule is a pre-aggregate that goes stale the day the rule changes.

Knowledge check

Which property most reliably makes a denormalised table safe to depend on?

Key takeaway

Denormalisation trades a guarantee for speed: the database stops enforcing that a fact has one value, and your pipeline takes over that job. Pay the price properly — one owning writer, idempotent rebuilds rather than in-place patches, a reconciliation test, and an explicit decision about whether a copied attribute means “as at the event” or “as of now”. Normalised models fail loudly and slowly; denormalised ones fail quietly and wrongly.

Lesson 7.3·14 min read

Dimensional Modelling: Facts & Dimensions

You will be able to split any business process into measurements and context, build the fact and dimension tables, and explain why this shape has outlived every BI tool built on it.

Retail Every question a retail business asks has the same shape. “Revenue by category last quarter.” “Units per store per week.” “Margin by supplier by region since the price change.” Strip the nouns and one skeleton remains: a number, sliced by some context, filtered to a period.

Dimensional modelling takes that skeleton seriously and makes it the schema. Numbers you add up go in fact tables. Context you slice by goes in dimension tables. That single split is the entire idea, and almost everything else in this part is a consequence of it.

Facts measure; dimensions describe

A fact table holds one row per occurrence of a business event, at a stated grain. Its columns are foreign keys to dimensions plus measures — numeric, and almost always additive. Fact tables are long and narrow: millions or billions of rows, few columns.

A dimension table holds one row per real-world thing — a customer, a product, a day, a store. Its columns are the adjectives you filter and group by. Dimensions are short and wide: thousands of rows, dozens of textual columns. Redundancy inside a dimension is expected and fine, because a dimension is small and rebuilt wholesale.

 Fact tableDimension table
One row perBusiness event, at the declared grainReal-world entity
ShapeMany rows, few columnsFew rows, many columns
Typical columnsForeign keys + numeric measuresDescriptive text, codes, hierarchies
GrowthContinuous — grows with the businessSlow — grows with the catalogue
In a queryAppears in SUM()Appears in WHERE and GROUP BY

The practical test: if summing the column produces a meaningful number, it is a measure; if it does not, it is a dimension attribute. Summing quantity gives units sold. Summing product ID gives nonsense. Summing a unit price gives nonsense too — which is why price is usually a dimension attribute while extended price (price × quantity) is a measure.

erDiagram
  DIM_DATE ||--o{ FCT_SALES : "when"
  DIM_CUSTOMER ||--o{ FCT_SALES : "who"
  DIM_PRODUCT ||--o{ FCT_SALES : "what"
  FCT_SALES {
    int date_key
    int customer_key
    int product_key
    int order_id
    int quantity
    numeric net_revenue
  }
  DIM_DATE {
    int date_key
    date full_date
    int year
    int month_num
    string month_name
  }
  DIM_CUSTOMER {
    int customer_key
    int customer_id
    string full_name
    string country
    string channel
  }
  DIM_PRODUCT {
    int product_key
    int product_id
    string product_name
    string category_name
    string department
  }
One fact table in the middle, dimensions radiating out, every join exactly one hop. Notice order_id sitting in the fact with no dimension attached — that is a degenerate dimension (Lesson 7.6), and it is deliberate, not an oversight.

Building one

Here is the whole model constructed from the normalised source. The dimensions come first, each with a generated surrogate key; the fact then references them.

sqlbuild_star.sql
-- ---------- Dimensions ----------
CREATE TABLE dim_customer AS
SELECT ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,
       customer_id, full_name,
       COALESCE(country, 'Unknown') AS country,   -- never leave a NULL in a dimension
       channel, signup_date
FROM customers;

CREATE TABLE dim_product AS
SELECT ROW_NUMBER() OVER (ORDER BY p.product_id) AS product_key,
       p.product_id, p.product_name,
       c.category_name, c.department, p.unit_cost
FROM products   AS p
JOIN categories AS c ON c.category_id = p.category_id;

CREATE TABLE dim_date AS
SELECT CAST(STRFTIME(d, '%Y%m%d') AS INTEGER) AS date_key,
       CAST(d AS DATE)                        AS full_date,
       CAST(STRFTIME(d, '%Y') AS INTEGER)     AS year,
       CAST(STRFTIME(d, '%m') AS INTEGER)     AS month_num,
       STRFTIME(d, '%B')                      AS month_name,
       STRFTIME(d, '%A')                      AS day_name
FROM generate_series(DATE '2024-01-01', DATE '2025-12-31', INTERVAL 1 DAY) AS t(d);

-- ---------- Fact: one row per order line ----------
CREATE TABLE fct_sales AS
SELECT dd.date_key,
       dc.customer_key,
       dp.product_key,
       o.order_id,                                  -- degenerate dimension
       o.status,
       oi.quantity,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders       AS o  ON o.order_id      = oi.order_id
JOIN dim_date     AS dd ON dd.full_date    = o.order_date
JOIN dim_customer AS dc ON dc.customer_id  = o.customer_id
JOIN dim_product  AS dp ON dp.product_id   = oi.product_id;

-- The payoff: any slice is one join per dimension, no chains.
SELECT dp.department,
       dd.month_name,
       ROUND(SUM(f.net_revenue), 2) AS revenue
FROM fct_sales    AS f
JOIN dim_product  AS dp ON dp.product_key = f.product_key
JOIN dim_date     AS dd ON dd.date_key    = f.date_key
WHERE f.status = 'completed' AND dd.year = 2024 AND dd.month_num = 1
GROUP BY dp.department, dd.month_name
ORDER BY revenue DESC;
Result
departmentmonth_namerevenue
FootwearJanuary412.10
OutdoorJanuary179.10
Analogy

A fact table is a till roll; dimensions are the signs above the aisles. The till roll records what actually happened — quantities and amounts, one line at a time, never edited. The signs tell you that this item is in Bakery, on the ground floor, in the ambient range. Nobody adds up the signs, and nobody re-reads the till roll to find out where Bakery is. Ask “how much did Bakery take on Tuesday?” and you are pairing a till roll with two signs — which is precisely what a star join does.

Additivity: the property that decides everything

Measures come in three flavours, and confusing them is the most expensive mistake in dimensional modelling.

  • Fully additive — sums correctly across every dimension. Revenue, units, cost. These are what you want.
  • Semi-additive — sums across some dimensions but not time. An account balance or a headcount: adding January's and February's balances is meaningless; averaging or taking the latest is not. Banking Every balance-sheet metric is semi-additive, which is why snapshot facts (Lesson 7.8) exist.
  • Non-additive — never sums. Ratios, percentages, unit prices. Store the numerator and denominator as separate additive measures and compute the ratio after aggregating. Storing a margin percentage and averaging it across products gives an unweighted average that is simply the wrong number.
Common pitfall — storing ratios as measures

Someone adds margin_pct to the fact table because the dashboard needs it. It works at row level and is wrong at every level above. AVG(margin_pct) weights a £5 sale the same as a £5,000 one, and SUM(margin_pct) is meaningless. The fix is mechanical: store revenue and cost, both fully additive, and define margin in the semantic layer as SUM(revenue - cost) / NULLIF(SUM(revenue), 0). The rule — aggregate the components, then divide; never divide, then aggregate — is worth remembering as a sentence.

Conformed dimensions

The idea that makes dimensional modelling scale past one team: a conformed dimension is a dimension shared, identically, by several fact tables. If dim_product is used by sales, returns and inventory facts, then “units sold minus units returned by department” is a straightforward query. If each process built its own product table with its own idea of what a department is, that question needs a negotiation rather than a join.

Conformance is an organisational commitment, not a technical one. Inference Most warehouse failures attributed to modelling are really failures to agree on shared dimensions — three teams each own a customer table, and the business gets three customer counts.

When NOT to use a dimensional model

  • Operational transaction processing. Star schemas are built for scan-and-aggregate, not for “update this one row now”. Keep OLTP normalised.
  • Raw event exploration. Semi-structured clickstream, logs and telemetry are better kept close to their original shape until the questions stabilise. Forcing a star on data whose meaning is still moving means rebuilding it monthly.
  • Genuinely graph-shaped questions — fraud rings, social reachability, bill-of-materials explosion. Dimensional models express “measure sliced by attributes” superbly and “how many hops from A to B” badly.
  • One-off analyses. Building conformed dimensions to answer a question once is expensive ceremony. Write the query.
  • Very small data. If the whole domain is a few million rows on a columnar engine, joining the normalised tables directly may be fast enough and simpler to maintain.
Interview angle

A near-universal question is “how do you decide whether something is a fact or a dimension?” The answer that lands is the additivity test — if summing it across all rows means something to the business, it is a measure — followed by the honest complication: unit price fails that test and is a dimension attribute, while extended price passes and is a measure. Expect a follow-up on semi-additive measures; being able to say “a balance can be summed across accounts but not across days” without prompting is a strong signal. Weak candidates describe a star schema as “denormalised for speed” and stop there, missing that its real product is a shared vocabulary.

Exercise 7.3

Advertising The media team wants to analyse spend by channel and month, and to compare it against revenue. Build a small dimensional model over ad_spend: a date dimension, a channel dimension, and a fact at one row per channel per campaign per spend date. Then report cost and clicks by channel for 2024. Finally, state which of cost, clicks, impressions and a derived cost-per-click are safe to store as measures.

Show solution
sqlexercise_7_3.sql
CREATE TABLE dim_channel AS
SELECT ROW_NUMBER() OVER (ORDER BY channel) AS channel_key, channel
FROM (SELECT DISTINCT channel FROM ad_spend) AS s;

CREATE TABLE dim_spend_date AS
SELECT CAST(STRFTIME(d, '%Y%m%d') AS INTEGER) AS date_key,
       CAST(d AS DATE)                        AS full_date,
       CAST(STRFTIME(d, '%Y') AS INTEGER)     AS year,
       STRFTIME(d, '%B')                      AS month_name
FROM generate_series(DATE '2024-01-01', DATE '2024-12-31', INTERVAL 1 DAY) AS t(d);

-- Grain: one row per channel per campaign per spend date.
CREATE TABLE fct_ad_spend AS
SELECT dd.date_key, dc.channel_key,
       a.campaign,                    -- degenerate dimension
       a.impressions, a.clicks, a.cost
FROM ad_spend        AS a
JOIN dim_spend_date  AS dd ON dd.full_date = a.spend_date
JOIN dim_channel     AS dc ON dc.channel   = a.channel;

SELECT dc.channel,
       SUM(f.cost)   AS cost,
       SUM(f.clicks) AS clicks,
       ROUND(SUM(f.cost) / NULLIF(SUM(f.clicks), 0), 4) AS cost_per_click
FROM fct_ad_spend AS f
JOIN dim_channel  AS dc ON dc.channel_key = f.channel_key
JOIN dim_spend_date AS dd ON dd.date_key = f.date_key
WHERE dd.year = 2024
GROUP BY dc.channel
ORDER BY cost DESC;
Result
channelcostclickscost_per_click
paid_search19730.00350600.5627
paid_social18040.00195500.9228

Additivity. cost, clicks and impressions are fully additive — summing them across any combination of channel, campaign and date yields a real quantity. Cost-per-click is non-additive and must not be stored: notice the query computes it as SUM(cost) / SUM(clicks) after grouping, which correctly weights each row by its clicks. Had it been stored per row and averaged, a single low-volume row with a freak CPC would move the channel figure as much as a row with thousands of clicks. NULLIF guards the division so a channel with zero clicks yields NULL rather than an error.

Knowledge check

A modeller wants to add unit_price to a sales fact table as a measure. What is the objection?

Knowledge check

What makes a dimension “conformed”?

Key takeaway

Dimensional modelling splits the world into measurements (facts) and context (dimensions), because that is the shape of every business question. Facts are long, narrow, additive and never edited; dimensions are short, wide, textual and rebuilt wholesale. Test every candidate measure by asking whether summing it means anything — and keep ratios out of fact tables, computing them from additive components after aggregation. The model's deepest product is not speed but a shared vocabulary: conformed dimensions are what let two teams compare numbers without a meeting.

Lesson 7.4·12 min read

Star Schema vs Snowflake Schema

You will be able to choose between a flat dimension and a normalised one on evidence rather than taste, and explain what each choice costs the people who query it.

Telecommunications A telco models its handset catalogue. One modeller builds dim_device with manufacturer, model family, operating system, screen class and price band all on one row. Another builds dim_device holding only a manufacturer key, with dim_manufacturer holding the manufacturer's name, country and parent group. Both are dimensional models. Both answer every question. They will produce noticeably different experiences for the analysts who use them, and the argument between their authors will last a decade.

This is the star-versus-snowflake question, and it is narrower than it is usually made to sound. The fact table is identical in both. The only thing under discussion is whether dimensions are normalised. Everything else — grain, conformance, additivity, slowly changing dimensions — is unaffected.

The two shapes

In a star schema, every dimension is one flat table joined directly to the fact. Hierarchies are flattened: the product dimension carries product name, category name and department, repeating the department once per product. Every query is fact-plus-one-hop.

In a snowflake schema, dimensions are normalised into their natural hierarchy. The product dimension holds a category key; dim_category holds the category name and a department key; dim_department holds the department name. Getting from a sale to a department is now three hops.

erDiagram
  DIM_DATE ||--o{ FCT_SALES : "star: 1 hop"
  DIM_CUSTOMER ||--o{ FCT_SALES : "star: 1 hop"
  DIM_PRODUCT_FLAT ||--o{ FCT_SALES : "star: 1 hop"
  DIM_PRODUCT_SNOW ||--o{ FCT_SALES : "snowflake: hop 1"
  DIM_CATEGORY ||--o{ DIM_PRODUCT_SNOW : "hop 2"
  DIM_DEPARTMENT ||--o{ DIM_CATEGORY : "hop 3"
  DIM_PRODUCT_FLAT {
    int product_key
    string product_name
    string category_name
    string department
  }
  DIM_PRODUCT_SNOW {
    int product_key
    string product_name
    int category_key
  }
  DIM_CATEGORY {
    int category_key
    string category_name
    int department_key
  }
  DIM_DEPARTMENT {
    int department_key
    string department
  }
Same fact table, same grain, same measures. The flat dimension answers “revenue by department” with one join; the snowflaked one needs three. Note the department name is stored once on the right and repeated per product on the left — that repetition is the entire cost of the star.

Both models, built from the same source, side by side:

sqlstar_vs_snowflake.sql
-- ---------- STAR: one flat product dimension ----------
CREATE TABLE dim_product_flat AS
SELECT ROW_NUMBER() OVER (ORDER BY p.product_id) AS product_key,
       p.product_id, p.product_name,
       c.category_name, c.department            -- hierarchy flattened in
FROM products   AS p
JOIN categories AS c ON c.category_id = p.category_id;

-- ---------- SNOWFLAKE: the same hierarchy, normalised ----------
CREATE TABLE dim_department AS
SELECT ROW_NUMBER() OVER (ORDER BY department) AS department_key, department
FROM (SELECT DISTINCT department FROM categories) AS d;

CREATE TABLE dim_category AS
SELECT c.category_id AS category_key, c.category_name, d.department_key
FROM categories      AS c
JOIN dim_department  AS d ON d.department = c.department;

CREATE TABLE dim_product_snow AS
SELECT ROW_NUMBER() OVER (ORDER BY p.product_id) AS product_key,
       p.product_id, p.product_name, p.category_id AS category_key
FROM products AS p;

-- Storage of the hierarchy, compared.
SELECT 'star (flat dim)'  AS model,
       COUNT(*)           AS rows_holding_department_name
FROM dim_product_flat
UNION ALL
SELECT 'snowflake (dim_department)', COUNT(*) FROM dim_department;
Result
modelrows_holding_department_name
star (flat dim)12
snowflake (dim_department)4

Twelve copies of a department name versus four. On a catalogue of twelve products that difference is meaningless; on a catalogue of twelve million it is still, on a columnar engine with dictionary encoding, close to meaningless — a repeated short string compresses to almost nothing. Inference The storage argument for snowflaking, which was the original argument, has largely been eroded by columnar compression. The arguments that remain are about maintenance and clarity.

Analogy

A star dimension is a product label that prints the full chain on the packet: Biscuits → Ambient → Grocery. A snowflake is a barcode that prints only a code, and you look up the chain in a catalogue behind the counter. The label is instantly readable by anyone in the shop; the barcode means that when Grocery is renamed you change one page rather than reprinting every packet. Neither is wrong. Which you prefer depends on whether more of your time goes on reading packets or on renaming departments.

What each choice actually costs

 Star (flat dimensions)Snowflake (normalised dimensions)
Joins for a hierarchy roll-upOneOne per level
Query readabilityHigh — attributes are where you expectLower — you must know the chain
BI-tool fitExcellent; most tools assume itWorkable, often needs modelling effort
Renaming a hierarchy levelRewrite the dimension (it is small)One row
Attribute reuse across dimensionsCopy itReference the shared sub-dimension
Risk of internal inconsistencyReal, if patched rather than rebuiltStructurally prevented
StorageLarger, but compresses wellSmaller

Read the “renaming” row carefully, because it is where the snowflake case is strongest and weakest at once. Yes, renaming a department is one row in the snowflake and a rebuild in the star. But a dimension is small, and the correct way to maintain a star dimension is a full rebuild anyway — at which point the rename costs nothing extra. The snowflake's advantage only materialises if you are patching dimensions in place, which is the practice you should be avoiding for the reasons in Lesson 7.2.

Here is the readability cost made concrete — the same business question against both models:

sqlsame_question_both_models.sql
CREATE TABLE dim_department AS
SELECT ROW_NUMBER() OVER (ORDER BY department) AS department_key, department
FROM (SELECT DISTINCT department FROM categories) AS d;

CREATE TABLE dim_category AS
SELECT c.category_id AS category_key, c.category_name, d.department_key
FROM categories AS c JOIN dim_department AS d ON d.department = c.department;

CREATE TABLE dim_product_snow AS
SELECT p.product_id AS product_key, p.product_name, p.category_id AS category_key
FROM products AS p;

CREATE TABLE fct_sales AS
SELECT oi.order_item_id, o.order_date, o.status, oi.product_id AS product_key,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id;

-- SNOWFLAKE: revenue by department needs three joins to reach the name.
SELECT dd.department, ROUND(SUM(f.net_revenue), 2) AS revenue
FROM fct_sales        AS f
JOIN dim_product_snow AS dp ON dp.product_key   = f.product_key
JOIN dim_category     AS dc ON dc.category_key  = dp.category_key
JOIN dim_department   AS dd ON dd.department_key = dc.department_key
WHERE f.status = 'completed'
GROUP BY dd.department
ORDER BY revenue DESC;
Result
departmentrevenue
Footwear2192.45
Accessories1710.25
Outdoor1275.10
Apparel1183.05

Identical numbers to the flat version in Lesson 7.2, obtained with two extra joins that the analyst had to know about. Multiply that by every hierarchy in the warehouse and every analyst in the company, and you have the practical case for the star: it moves complexity from thousands of queries into one build job.

Common pitfall — snowflaking by accident

Most snowflakes are not designed; they are inherited. A team loads the source system's normalised tables into the warehouse, points the BI tool at them, and calls the result a dimensional model. It has the snowflake's join burden without the snowflake's deliberate benefit, plus the source system's naming and its quirks. The tell is dimension tables whose names match the OLTP schema exactly. A dimensional model is something you design around business processes; a copy of the operational schema is a staging layer that never got promoted.

When a snowflake is genuinely right

  • A sub-hierarchy is shared by several dimensions. If geography is used by customer, store and supplier dimensions, one dim_geography keeps a single definition of “region”. Copying it three times invites three definitions.
  • A branch of the dimension is huge and rarely used. A device dimension with hundreds of technical attributes needed by one team can be split off so the common path stays narrow.
  • An attribute group changes on a different cadence. If the department hierarchy is reorganised quarterly and products change daily, separating them lets you version the slow-changing part independently — which interacts directly with Lesson 7.7.
  • Governance requires a single authoritative list. Insurance Regulatory reporting categories often must be maintained as a controlled reference table with its own approval process. That is a real table, not a set of repeated strings.
  • Outriggers. A small normalised table hanging off a dimension — a date dimension referenced from a customer dimension's signup date, say — is a common, accepted compromise. Used sparingly it is fine; used everywhere it is a snowflake with extra steps.

Open The field genuinely disagrees here. The Kimball-influenced tradition argues for flat dimensions almost unconditionally, on the grounds that simplicity for consumers beats elegance for modellers. Others argue that with modern engines the join cost is negligible and normalised dimensions are easier to govern and evolve. Both positions are held by experienced practitioners running large warehouses, and the honest answer is that the decision depends more on who writes the SQL in your organisation than on any property of the data.

In the field

A pragmatic pattern used widely: model the warehouse layer with normalised dimensions where governance benefits, and expose a flattened view or table on top for consumption. Analysts and BI tools see a star; the maintainers keep their reference tables. This costs one extra build step and ends most of the argument — which is a hint that the star-versus-snowflake debate is partly a disagreement about which audience the schema is for, and that you can serve both.

Interview angle

“Star or snowflake?” is a trap if you answer with a preference. The strong answer opens by narrowing the question — the fact table and grain are identical, only dimension normalisation is at stake — then gives the modern reason storage is no longer the deciding factor (columnar compression makes repeated strings cheap), then names the cases where snowflaking earns its keep: shared sub-hierarchies, governed reference lists, and attribute groups on different change cadences. Finishing with the flattened-view compromise shows you have shipped one rather than read about one.

Exercise 7.4

Gaming A studio has a player dimension carrying country, and wants to add region and continent. Marketing also wants region on a separate acquisition-source dimension. Build the shared dim_geography outrigger, attach it to a player dimension, and report players per region. Then argue whether this snowflake is justified.

Show solution
sqlexercise_7_4.sql
-- A single governed geography table, referenced rather than copied.
CREATE TABLE dim_geography AS
SELECT * FROM (VALUES
  ('GB','Northern Europe','Europe'),
  ('DE','Western Europe','Europe'),
  ('BR','South America','Americas'),
  ('US','North America','Americas'),
  ('SG','South-East Asia','Asia'),
  ('JP','East Asia','Asia'),
  ('Unknown','Unknown','Unknown')
) AS t(country, region, continent);

CREATE TABLE dim_player AS
SELECT p.player_id AS player_key, p.username,
       COALESCE(p.country, 'Unknown') AS country,   -- no NULL keys into the outrigger
       p.is_premium
FROM players AS p;

SELECT g.continent, g.region, COUNT(*) AS players
FROM dim_player    AS dp
JOIN dim_geography AS g ON g.country = dp.country
GROUP BY g.continent, g.region
ORDER BY players DESC, g.region;
Result
continentregionplayers
UnknownUnknown2
AsiaEast Asia1
AmericasNorth America1
EuropeNorthern Europe1
AmericasSouth America1
AsiaSouth-East Asia1
EuropeWestern Europe1

Is it justified? Yes, on the sharing criterion. Two or more dimensions need the same country→region→continent mapping, and that mapping is a business decision someone owns — is Mexico in North America or Latin America? Copying it into each dimension guarantees the two copies eventually diverge, and the divergence surfaces as two different regional player counts in two different decks.

Note the COALESCE to 'Unknown' and the matching row in the geography table. Two players have a NULL country; without that treatment an inner join would silently drop them and the region totals would not reconcile to the player count. Dimensions should not contain NULL keys — give unknowns an explicit member so they stay visible and countable rather than vanishing.

What would not justify snowflaking here: splitting is_premium or username into their own tables. Those are attributes of exactly one entity, used by one dimension, and normalising them buys nothing but joins.

Knowledge check

Which of these is the strongest modern justification for snowflaking a dimension?

Key takeaway

Star and snowflake differ in one thing only: whether dimensions are normalised. The fact table, the grain and the measures are unchanged. Storage no longer decides it — columnar compression makes repeated strings nearly free — so choose on maintenance and clarity: flatten by default because it moves complexity out of thousands of queries into one build, and snowflake deliberately for shared sub-hierarchies, governed reference lists, and attribute groups that change on their own cadence. When both audiences matter, model normalised and publish flattened.

Lesson 7.5·13 min read

Choosing the Grain: The Most Important Modelling Decision

You will be able to state a fact table's grain in one sentence, test that the data obeys it, and recognise the double-counting bugs that follow when it is violated.

Advertising An agency's reporting table shows campaign revenue 60% above what the client's finance team reports. Three analysts spend a fortnight on it. The cause: the table has one row per order line, but each row also carries the order-level shipping revenue. An order with three lines counts its shipping three times.

Nobody wrote a wrong query. The table promised one thing per row and delivered two. That is a grain violation, and it is the single most common cause of silently wrong numbers in a warehouse — more common than bad joins, bad filters and bad arithmetic combined, because unlike those it produces plausible answers.

What grain is

The grain of a fact table is the business meaning of a single row, stated in one sentence, before you choose a single column. Not “the sales table”. Rather:

  • “One row per product on an order.”
  • “One row per account per calendar month-end.”
  • “One row per insurance claim, updated as it progresses.”
  • “One row per advertisement impression.”

Two properties make the sentence useful. First, it is testable — you can write a query that fails if a duplicate exists. Second, it determines everything else: which dimensions can attach (only those that are single-valued at that grain), which measures are additive, and how large the table will be.

💡Intuition — grain is a promise, not a description

“One row per order line” is a contract the table offers its consumers. It licenses them to write SUM(net_revenue) without asking you anything, and to COUNT(*) and get a count of lines. Every column on the row must be a fact about that line and nothing coarser. The moment an order-level value appears on a line-level row, the contract is broken — and the consumer, who is trusting it, has no way to detect the breach from the data alone.

Declare the atomic grain

The default should be the lowest grain the source can produce — the atomic event, not a summary. This is counter-intuitive, since summaries are smaller and faster, and it is nevertheless right, for two reasons.

Aggregation is one-way. From line-level rows you can produce any summary. From daily-category summaries you can never recover which products sold together, which customers bought them, or what happened at 3pm. Every question nobody has asked yet is answerable at atomic grain and unanswerable above it.

Summaries embed assumptions. A daily revenue-by-category table has already decided what counts as revenue, which statuses are included and where the day boundary falls. When any of those definitions changes — and one will — the whole history must be rebuilt from a source you may no longer have.

Aggregates are still worth building. Build them from the atomic fact, as a performance layer, and keep the atomic table as the source of truth.

Testing the grain

A declared grain that is not enforced is a comment. The test is one query, and it belongs in your build:

sqlgrain_test.sql
-- Grain: one row per product per order.
CREATE TABLE fct_sales_line AS
SELECT o.order_id, oi.product_id, o.order_date, o.status,
       oi.quantity,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id;

-- The test: the declared key must be unique. Zero rows returned = grain holds.
SELECT order_id, product_id, COUNT(*) AS rows_at_this_key
FROM fct_sales_line
GROUP BY order_id, product_id
HAVING COUNT(*) > 1;

-- A second, cheaper form of the same assertion.
SELECT COUNT(*)                                       AS total_rows,
       COUNT(DISTINCT order_id || '-' || product_id)  AS distinct_grain_keys,
       CASE WHEN COUNT(*) = COUNT(DISTINCT order_id || '-' || product_id)
            THEN 'GRAIN OK' ELSE 'DUPLICATES' END     AS verdict
FROM fct_sales_line;
Result
total_rowsdistinct_grain_keysverdict
3030GRAIN OK
Analogy

Grain is the unit on a recipe. “Add two” is useless; “add two eggs” is a recipe. And if a recipe says eggs but the tray is actually half-dozen boxes, you will produce something confidently, follow every instruction correctly, and be six times wrong. Nobody in that kitchen made an error — the unit was mislabelled, and every downstream step inherited it. That is exactly how a grain violation propagates through a warehouse: quietly, through people doing their jobs properly.

Mixed grain: the failure mode

The agency's bug was a measure at a coarser grain than the row. Here is the pattern, and the two correct fixes:

sqlmixed_grain.sql
-- An order-level value (order_line_count) placed on line-level rows.
CREATE TABLE fct_mixed AS
SELECT oi.order_item_id, o.order_id, oi.product_id,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue,
       COUNT(*) OVER (PARTITION BY o.order_id) AS order_line_count   -- WRONG grain
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id
WHERE o.status = 'completed';

SELECT SUM(order_line_count)                          AS naive_sum_double_counts,
       COUNT(DISTINCT order_id)                       AS actual_orders,
       ROUND(SUM(order_line_count) * 1.0
             / NULLIF(COUNT(DISTINCT order_id), 0), 4) AS inflation_factor
FROM fct_mixed;
Result
naive_sum_double_countsactual_ordersinflation_factor
36211.7143

Twenty-one completed orders, but the order-level column sums to 36 — each order contributed its line count once for every line it has, so a three-line order added nine rather than three. Inference Note that the inflation is not a constant you could spot and divide back out: it grows with the square of basket size, so large baskets are over-weighted far more than small ones. A bug that distorts unevenly across segments is much harder to notice than one that is uniformly wrong, because every subtotal still looks individually plausible.

Two legitimate fixes:

  1. Separate the fact tables by grain. Order-level measures (shipping, order-level discount, delivery cost) go in an order-grain fact; line-level measures stay in the line-grain fact. Join or union them when a report needs both.
  2. Allocate the coarse measure down to the fine grain. Split order shipping across its lines in proportion to line revenue. Now the value is genuinely line-level and sums correctly — at the cost of an allocation rule someone must own and defend.
Common pitfall — changing the grain by adding a dimension

A fact table is at order-line grain. Someone needs promotion analysis, and an order line can carry several promotions. They join the promotions table in and the row count grows. The grain has now silently become “one row per line per promotion” and every existing SUM(net_revenue) in the company is inflated — including in dashboards nobody has opened this month. Attaching a many-valued dimension to a fact always changes its grain. The fix is a bridge table between the line fact and the promotion dimension, leaving the fact's grain untouched. When a change makes the row count go up, stop.

When NOT to model at atomic grain

  • The atomic event is not retained. Telecommunications Network telemetry is often pre-aggregated at the device before transmission. You cannot model below what you receive; declare the grain you actually have and say so plainly.
  • Privacy or regulation forbids it. Individual-level health or payment records may legally have to be stored aggregated or pseudonymised. The constraint is external and non-negotiable.
  • Volume genuinely exceeds budget. Ad impression logs can be enormous. A common compromise: keep atomic data for a short retention window, aggregates forever. Be explicit that questions requiring atomic detail have a time limit.
  • The consumer is a fixed, contractual report. A regulatory return with a frozen definition can be modelled directly at its reporting grain — though you will usually still want the atomic table underneath to prove the figure.
Interview angle

“How do you design a fact table?” has a correct opening move: declare the grain first, in a sentence, before naming any column. Candidates who start listing columns have skipped the only step that matters. Expect the double-counting scenario as a follow-up — often phrased as “the revenue number is too high, what do you check?” — where the expected answer is a fan-out from a join or a coarse measure on a fine row, tested with a GROUP BY / HAVING COUNT(*) > 1 on the declared key. Mentioning bridge tables for many-to-many dimensions marks you as someone who has hit this in production.

Exercise 7.5

Product analytics Build a session-grain fact table (one row per session) carrying the session's device and channel plus counts of views, cart-adds and purchases. Write the grain test. Then explain why joining events to sessions without aggregating would have broken the grain, and quantify the inflation.

Show solution
sqlexercise_7_5.sql
-- Grain: one row per session. Event counts are collapsed to that grain.
CREATE TABLE fct_session AS
SELECT s.session_id, s.customer_id, s.device, s.channel,
       CAST(s.started_at AS DATE)                                   AS session_date,
       COUNT(e.event_id)                                            AS events_total,
       COUNT(*) FILTER (WHERE e.event_name = 'view_item')           AS views,
       COUNT(*) FILTER (WHERE e.event_name = 'add_to_cart')         AS cart_adds,
       COUNT(*) FILTER (WHERE e.event_name = 'purchase')            AS purchases
FROM sessions AS s
LEFT JOIN events AS e ON e.session_id = s.session_id
GROUP BY s.session_id, s.customer_id, s.device, s.channel, CAST(s.started_at AS DATE);

-- Grain test.
SELECT COUNT(*) AS rows_in_fact,
       COUNT(DISTINCT session_id) AS distinct_sessions,
       CASE WHEN COUNT(*) = COUNT(DISTINCT session_id)
            THEN 'GRAIN OK' ELSE 'DUPLICATES' END AS verdict
FROM fct_session;

-- What the naive join would have produced instead.
SELECT COUNT(*) AS rows_if_not_aggregated FROM sessions AS s
LEFT JOIN events AS e ON e.session_id = s.session_id;
Result
rows_in_factdistinct_sessionsverdict
1515GRAIN OK

Why the naive join breaks it. A session has many events, so sessions LEFT JOIN events produces one row per event, not per session — 26 rows against 15 sessions. Any session-level attribute on those rows is now repeated once per event: counting distinct devices still works, but COUNT(*) returns events while claiming to return sessions, and any session-level measure would be inflated by roughly 1.7×, unevenly, with the busiest sessions inflated most. That unevenness is what makes fan-out so hard to spot — the error is not a constant factor you might notice, it is a silent reweighting toward high-activity rows.

The LEFT JOIN matters too: only sessions 1, 3, 4, 9, 14 and 15 have any events — the other nine sessions have none. An inner join would drop them and the fact would no longer contain every session, quietly changing the denominator of every conversion rate computed from it.

Knowledge check

A fact table is at “one row per order line” grain. A modeller adds a many-to-many promotions dimension by joining it in. What has happened?

Knowledge check

Why prefer atomic grain over a pre-aggregated summary as the core fact table?

Key takeaway

Declare the grain in one sentence, before any column, and make it the lowest the source can give you — aggregation is one-way, and every future question depends on detail you cannot recover. Then enforce it: a uniqueness test on the declared key in every build. Grain violations are the deadliest warehouse bug because they return plausible numbers, and the two ways they appear are a coarse measure sitting on a fine row and a many-valued dimension joined directly to a fact. If a change makes the row count go up, the grain changed.

Lesson 7.6·12 min read

Surrogate Keys, Natural Keys & Degenerate Dimensions

You will be able to choose the right key for every table in a warehouse, and explain why the fact table almost never joins on the identifier the business actually uses.

Insurance An insurer keys its policy dimension on the policy number, because that is what everyone in the business says. Then three things happen over four years. A merger brings in a second book of business whose policy numbers overlap. A regulator requires the numbering format to change, and every legacy policy is renumbered. And the business starts wanting to see a policy's history — what cover it had in 2022, not just today.

Each of those independently breaks a model keyed on the policy number, and collectively they destroy it. The fix is old, slightly unglamorous, and close to universal in warehousing: put a meaningless integer between the fact table and the business identifier.

Three kinds of key

  • A natural key (or business key) is the identifier the real world uses: an email address, an ISBN, a policy number, a national insurance number. It has meaning outside your database.
  • A surrogate key is an integer your warehouse invents. It means nothing, encodes nothing, and belongs to no source system. Its only job is to identify a row.
  • A degenerate dimension is a natural key that lives on the fact table with no dimension table behind it — because once you strip off the attributes that moved into other dimensions, nothing is left but the identifier itself.

The canonical example of the third: order_id. An order has a date (that is dim_date), a customer (that is dim_customer), a status — and after those are extracted, the order number remains, useful for grouping lines together and for tracing back to the source system, but with no attributes of its own. Building dim_order to hold one column would be ceremony. It stays on the fact.

Why surrogate keys, specifically

Four reasons, in rough order of how often they decide the matter:

  1. History. This is the big one. If dim_customer holds three versions of customer 5 — before the move, after the move, after the rename — then customer_id is no longer unique in the dimension and cannot be the join key. The surrogate key identifies the version, and it is what makes Type 2 slowly changing dimensions possible at all (Lesson 7.7).
  2. Insulation from source systems. Sources reuse identifiers, change formats, merge and get replaced. A surrogate key means such a change is a mapping problem in one dimension rather than a rewrite of a billion-row fact table.
  3. Integration. When two systems both have “customer 1234” and they are different people, a surrogate key gives you somewhere to record that. A natural key gives you a collision.
  4. Efficiency. A four-byte integer join beats a composite of three strings on storage, memory and comparison cost. Real, but the weakest of the four — do not lead with it.

Critically, the natural key does not disappear. It stays in the dimension as an attribute, so you can look up a customer by their real identifier, trace a row back to its source, and reconcile with the operational system. Surrogate keys replace the natural key as the join key, not as information.

sqlsurrogate_keys.sql
-- The dimension: warehouse-generated key first, natural key retained beside it.
CREATE TABLE dim_customer AS
SELECT ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,   -- surrogate
       customer_id                              AS customer_id,    -- natural key, kept
       full_name,
       COALESCE(country, 'Unknown')             AS country,
       channel
FROM customers
UNION ALL
-- Every dimension needs an explicit "unknown" member so facts with a missing
-- or late-arriving key can still join, instead of vanishing or holding NULL.
SELECT -1, -1, 'Unknown', 'Unknown', 'Unknown';

CREATE TABLE dim_product AS
SELECT ROW_NUMBER() OVER (ORDER BY p.product_id) AS product_key,
       p.product_id, p.product_name, c.category_name, c.department
FROM products AS p JOIN categories AS c ON c.category_id = p.category_id;

-- The fact joins on surrogates, and carries order_id as a degenerate dimension.
CREATE TABLE fct_sales AS
SELECT COALESCE(dc.customer_key, -1) AS customer_key,
       dp.product_key,
       o.order_id,                              -- degenerate dimension
       o.order_date, o.status, oi.quantity,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items  AS oi
JOIN orders       AS o  ON o.order_id     = oi.order_id
JOIN dim_product  AS dp ON dp.product_id  = oi.product_id
LEFT JOIN dim_customer AS dc ON dc.customer_id = o.customer_id;

-- The degenerate dimension earns its place: it is how you get back to order level.
SELECT COUNT(*)                  AS order_lines,
       COUNT(DISTINCT order_id)  AS orders,
       ROUND(AVG(net_revenue), 2) AS avg_line_value
FROM fct_sales
WHERE status = 'completed';
Result
order_linesordersavg_line_value
2621244.65
Analogy

A natural key is your name; a surrogate key is your employee number. Names change on marriage, two people share one, and the payroll system was not built to cope with either. The employee number was chosen precisely because it means nothing — nobody will ever demand it be updated to reflect a change in circumstance, because there is no circumstance it reflects. And the company still keeps your name on file; it just stops using it as the thing that identifies your record.

The unknown member, and why NULL keys are forbidden

Look again at the -1 row inserted into the dimension. It is not a curiosity; it is a rule. Fact rows sometimes arrive whose dimension key is missing, unrecognised, or has not loaded yet. You have three options and only one is acceptable:

HandlingConsequence
Drop the fact rowRevenue silently disappears. Totals no longer reconcile to the source, and nobody knows why.
Store NULL in the foreign keyInner joins drop the row anyway — the same loss, now unpredictable and dependent on which join a given analyst wrote.
Point at an unknown member (-1)The row survives every join, the money is counted, and the problem is visible as a growing “Unknown” bucket on every report.

Inference The third option is better not because it is more accurate — the customer is equally unknown in all three — but because it converts a silent data-loss bug into a loud, countable, obviously-wrong line on a dashboard. Making failures visible is most of data engineering.

Common pitfall — generating surrogate keys with ROW_NUMBER() on every rebuild

The examples in this course use ROW_NUMBER() because it is clear and portable. In production it is a trap: the keys are assigned by sort order, so inserting one new customer can shift every subsequent key by one — and any fact table already loaded now points at the wrong rows. Surrogate keys must be stable across rebuilds. Use a database sequence or identity column, or a deterministic hash of the natural key plus (for Type 2) the version's effective date. If you cannot guarantee stability, you must rebuild the facts and the dimensions together, atomically, every time.

Hash keys as an alternative

A common middle path is a deterministic hash of the natural key — the same input always yields the same key, with no sequence and no coordination. That property matters when several pipelines, or several environments, must produce identical keys independently; it is also the foundation of Data Vault (Lesson 7.10).

sqlhash_keys.sql
-- Deterministic: the same natural key always produces the same surrogate,
-- with no sequence, no ordering dependency and no cross-pipeline coordination.
CREATE TABLE dim_customer_hashed AS
SELECT MD5(CAST(customer_id AS VARCHAR))   AS customer_hk,
       customer_id, full_name,
       COALESCE(country, 'Unknown')        AS country
FROM customers;

-- Re-running the derivation gives identical keys, which is the whole point.
SELECT d.customer_id,
       d.customer_hk = MD5(CAST(d.customer_id AS VARCHAR)) AS is_reproducible
FROM dim_customer_hashed AS d
ORDER BY d.customer_id
LIMIT 3;
Result
customer_idis_reproducible
1true
2true
3true

The costs: hash keys are wider than integers (so joins and storage cost more), they are unreadable in debugging, and they need care over input normalisation — 'ACME ' and 'acme' hash differently, so trimming and case-folding must be decided once and applied everywhere. Collisions are a theoretical concern that practitioners generally accept for wide hashes; the normalisation problem is the one that actually bites.

When NOT to use surrogate keys

  • Transactional systems. Adding a warehouse-style surrogate to an OLTP table where a good natural key exists creates a second identifier to keep in step. Use the database's own primary key.
  • Genuinely stable, single-source dimensions with no history requirement. A currency-code dimension keyed on ISO 4217 is fine. The codes are externally governed, globally unique and do not version.
  • Date dimensions. The near-universal convention is a “smart” integer key like 20240315. It breaks the meaningless-key rule deliberately, because it is human-readable in query results, sorts correctly, and partitions naturally. Some practitioners object that this is exactly the meaning-in-a-key problem the surrogate rule exists to prevent; the pragmatic majority use it anyway, because dates genuinely never get renumbered.
  • Small, disposable analyses. A one-off model does not need the machinery.
Interview angle

“Why use surrogate keys?” is answered weakly with “integers join faster” — true, and the least important reason. The answer that lands leads with history: once a dimension holds multiple versions of the same entity, the natural key is no longer unique and physically cannot serve as the join key. Follow with insulation from source-system changes, and the point that the natural key is retained as an attribute rather than discarded. A likely follow-up is “what is a degenerate dimension?” — a natural key kept on the fact because every other attribute has migrated elsewhere, order_id being the standard example. Bringing up the unknown member unprompted is a strong signal, because it shows you have thought about what happens when the data misbehaves.

Exercise 7.6

SaaS Build a subscription fact keyed on a surrogate customer key, including an unknown member so that no subscription is lost if its customer is missing from the dimension. Then prove that the fact's total MRR reconciles exactly to the source, and report how much MRR is attributed to unknown customers.

Show solution
sqlexercise_7_6.sql
-- Dimension deliberately built WITHOUT customers 9 and 15, to simulate
-- a late-arriving or unmatched dimension row.
CREATE TABLE dim_cust AS
SELECT ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,
       customer_id, full_name, COALESCE(country, 'Unknown') AS country
FROM customers
WHERE customer_id NOT IN (9, 15)
UNION ALL
SELECT -1, -1, 'Unknown', 'Unknown';

-- LEFT JOIN + COALESCE to the unknown member: nothing is ever dropped.
CREATE TABLE fct_subscription AS
SELECT s.subscription_id,
       COALESCE(dc.customer_key, -1) AS customer_key,
       s.plan, s.mrr, s.started_on, s.cancelled_on
FROM subscriptions AS s
LEFT JOIN dim_cust AS dc ON dc.customer_id = s.customer_id;

-- Reconciliation, plus how much value is sitting in the unknown bucket.
SELECT (SELECT ROUND(SUM(mrr), 2) FROM subscriptions)      AS source_mrr,
       (SELECT ROUND(SUM(mrr), 2) FROM fct_subscription)   AS fact_mrr,
       ROUND(SUM(CASE WHEN customer_key = -1 THEN mrr ELSE 0 END), 2) AS unknown_mrr,
       COUNT(*) FILTER (WHERE customer_key = -1)           AS unknown_rows
FROM fct_subscription;
Result
source_mrrfact_mrrunknown_mrrunknown_rows
670.00670.0019.001

What the numbers show. Total MRR reconciles exactly — 670.00 in both — because the LEFT JOIN plus COALESCE(..., -1) guarantees every subscription survives. One subscription (customer 15, on the basic plan) could not be matched, and its £19 is visible in the unknown bucket rather than silently absent. Customer 9 has no subscription at all, so their absence from the dimension causes no loss.

Had this used an inner join, fact_mrr would read 651.00 against a source of 670.00, and someone would eventually have to work out where £19 went. The unknown member turns a forensic investigation into a row on a report — and a monitor on unknown_rows turns it into an alert.

Knowledge check

What is the strongest reason a warehouse fact table joins on a surrogate key rather than the business identifier?

Knowledge check

A fact row's customer cannot be matched to the customer dimension. What should the pipeline do?

Key takeaway

Put a meaningless, warehouse-owned integer between your facts and the business identifier — primarily because a dimension that stores history has the natural key on many rows, and secondarily to insulate you from source systems that renumber, merge and collide. Keep the natural key as an attribute; you still need it to reconcile. Give every dimension an explicit unknown member so unmatched facts stay countable instead of vanishing. And when a natural key has no attributes left after modelling — order_id, an invoice number, a ticket reference — leave it on the fact as a degenerate dimension rather than building a one-column table.

Lesson 7.7·18 min read

Slowly Changing Dimensions (Types 0-6)

You will be able to choose an SCD type per attribute rather than per table, write a correct Type 2 upsert, and explain why last year's regional revenue keeps changing.

Retail In March, the Nordics team reports £2.1m of revenue for last year. In April, they report £1.9m for the same period, from the same warehouse, with no code changes and no data corrections. Finance opens an investigation.

The cause is mundane. Several customers moved. The customer dimension was overwritten with their new countries, and every historical order they ever placed silently moved with them. The warehouse is not reporting what happened; it is reporting what would have happened if everyone had always lived where they live now. Nothing is corrupt. Something much worse has occurred: the numbers are self-consistent, plausible, and unreproducible.

This is the problem slowly changing dimensions solve. A dimension attribute changes — a customer relocates, a product is reclassified, a salesperson changes territory — and you must decide what happens to the history that referenced it. The SCD types are a catalogue of answers, and the central skill is knowing that the decision is made per attribute, not per table.

The catalogue

TypeWhat it doesHistoryRow countTypical use
0Never changes the value after first loadOriginal preserved by fiat1 per entityDate of birth, original signup channel, first order date
1Overwrites in placeDestroyed1 per entityCorrections — a misspelt name, a fixed typo
2Adds a new row, closes the old one with validity datesFull, queryable at any point in time1 per versionThe default for anything you slice history by
3Adds a “previous value” columnOne prior value only1 per entityA single reorganisation you want to report both ways
4Current values in the main dimension, history in a separate tableFull, but split across tables1 current + n historyFast-changing attributes on an otherwise stable dimension
6Type 2 rows that also carry current-value columns (1 + 2 + 3)Full, plus instant “as-is” access1 per versionWhen users need both “as it was” and “as it is now”

Fact There is no Type 5 in common usage as a separate technique; the numbering runs 0–4 and 6, with 6 named because 1 + 2 + 3 = 6 (and it combines exactly those three).

flowchart TD
  A["An attribute's value changed"] --> B{"Does anyone need
the old value?"} B -- "No, it was simply wrong" --> C["Type 1
overwrite in place"] B -- "No, and it must never change" --> D["Type 0
reject the update"] B -- "Yes" --> E{"How much history?"} E -- "Just the one prior value" --> F["Type 3
add a previous_ column"] E -- "All of it" --> G{"Does it change
very often?"} G -- "Rarely" --> H["Type 2
new row + validity dates"] G -- "Constantly" --> I["Type 4
split into a history table"] H --> J{"Do users also need
'as it is today'?"} J -- "Yes" --> K["Type 6
Type 2 rows + current_ columns"] J -- "No" --> L["Type 2 is enough"]
Run this per attribute. One dimension routinely mixes types: a customer's name is Type 1, their signup channel Type 0, their country Type 2.

Type 2: the mechanism

Type 2 is the one worth mastering, because it is the default for anything historically meaningful and because the other types are easy once you understand it.

The dimension gains four columns:

  • customer_sk — the surrogate key, unique per version. This is why Lesson 7.6 exists: customer_id now appears on several rows.
  • valid_from — the date this version became true.
  • valid_to — the date it stopped. For the live version, a far-future sentinel like 9999-12-31 rather than NULL, so range predicates work without special-casing.
  • is_current — a convenience flag, strictly redundant with valid_to but cheap and heavily used.

Loading a fact means looking up the dimension row that was valid at the moment of the event, not the one that is valid now. That single change is what makes last year's revenue stop moving.

customer_id = 1  (Amara Osei)

 sk  country  valid_from   valid_to    is_current
 --  -------  -----------  ----------  ----------
  1  GB       2024-01-05   2025-01-31  false      <- orders in 2024 join HERE
 18  IE       2025-02-01   9999-12-31  true       <- orders from Feb 2025 join here

  |------------ GB ------------|-------- IE -------->
2024-01-05                2025-01-31/02-01      (open)

The Type 2 upsert, worked end to end

The load has exactly three steps, and they must happen in this order. E-commerce Two customers have changed country in the incoming feed: Amara Osei has moved to Ireland, and Elif Demir — who previously had no country recorded at all — now has Turkey.

sqlscd2_upsert.sql
-- ============ Initial load: one open version per customer ============
CREATE TABLE dim_customer_scd (
  customer_sk  BIGINT,
  customer_id  INTEGER,
  full_name    VARCHAR,
  country      VARCHAR,
  valid_from   DATE,
  valid_to     DATE,
  is_current   BOOLEAN
);

INSERT INTO dim_customer_scd
SELECT ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_sk,
       customer_id, full_name, country,
       signup_date        AS valid_from,
       DATE '9999-12-31'  AS valid_to,     -- sentinel, never NULL
       TRUE               AS is_current
FROM customers;

-- ============ Today's feed: Amara moved to IE, Elif's country is now known ============
CREATE TABLE customer_feed AS
SELECT customer_id, full_name,
       CASE WHEN customer_id = 1 THEN 'IE'
            WHEN customer_id = 5 THEN 'TR'
            ELSE country END AS country
FROM customers;

-- ---- STEP 1: close the versions whose tracked attributes changed ----
-- IS DISTINCT FROM is essential: a plain <> comparison is NULL when either
-- side is NULL, so Elif's NULL -> 'TR' change would be missed entirely.
UPDATE dim_customer_scd AS d
   SET valid_to   = DATE '2025-02-01' - INTERVAL 1 DAY,
       is_current = FALSE
FROM customer_feed AS f
WHERE f.customer_id = d.customer_id
  AND d.is_current
  AND f.country IS DISTINCT FROM d.country;

-- ---- STEP 2: insert the new open version for anything now uncovered ----
-- This catches both changed customers (just closed above) and brand-new ones.
INSERT INTO dim_customer_scd
SELECT (SELECT MAX(customer_sk) FROM dim_customer_scd)
         + ROW_NUMBER() OVER (ORDER BY f.customer_id) AS customer_sk,
       f.customer_id, f.full_name, f.country,
       DATE '2025-02-01'  AS valid_from,
       DATE '9999-12-31'  AS valid_to,
       TRUE               AS is_current
FROM customer_feed AS f
WHERE NOT EXISTS (
        SELECT 1 FROM dim_customer_scd AS d
        WHERE d.customer_id = f.customer_id AND d.is_current);

-- ---- STEP 3: inspect the result for the two affected customers ----
SELECT customer_sk, customer_id, full_name,
       COALESCE(country, 'NULL') AS country,
       valid_from, valid_to, is_current
FROM dim_customer_scd
WHERE customer_id IN (1, 5)
ORDER BY customer_id, valid_from;
Result
customer_skcustomer_idfull_namecountryvalid_fromvalid_tois_current
11Amara OseiGB2024-01-052025-01-31false
181Amara OseiIE2025-02-019999-12-31true
55Elif DemirNULL2024-03-032025-01-31false
195Elif DemirTR2025-02-019999-12-31true

Two things deserve attention. First, valid_to on the closed row is the day before the new row's valid_from, so the ranges neither overlap nor leave a gap. Second, IS DISTINCT FROM is doing real work: Elif's country went from NULL to 'TR', and f.country <> d.country would have evaluated to NULL — not true — so the change would have been silently skipped and her history quietly falsified.

Common pitfall — comparing with <> instead of IS DISTINCT FROM

This is the single most common Type 2 bug, and it is invisible in testing because test data rarely contains nulls. In SQL, NULL <> 'TR' is NULL, which is not true, so a WHERE clause built on <> ignores every change into or out of NULL. The symptom, months later: customers who filled in a previously blank field never got a new dimension version, so their entire history shows the value as blank. Use IS DISTINCT FROM, or coalesce both sides to a sentinel before comparing. If your dialect lacks IS DISTINCT FROM, COALESCE(a,'~') <> COALESCE(b,'~') works, provided the sentinel cannot occur in real data.

Querying a Type 2 dimension

Two questions, two different joins, and confusing them is the second most common bug:

sqlscd2_query.sql
CREATE TABLE dim_cust_scd AS
-- Two versions for customer 1: GB until 2024-06-30, IE afterwards.
SELECT 1 AS customer_sk, 1 AS customer_id, 'Amara Osei' AS full_name,
       'GB' AS country, DATE '2024-01-05' AS valid_from,
       DATE '2024-06-30' AS valid_to, FALSE AS is_current
UNION ALL SELECT 2, 1, 'Amara Osei', 'IE', DATE '2024-07-01', DATE '9999-12-31', TRUE
UNION ALL
SELECT ROW_NUMBER() OVER (ORDER BY customer_id) + 100, customer_id, full_name,
       COALESCE(country, 'Unknown'), signup_date, DATE '9999-12-31', TRUE
FROM customers WHERE customer_id <> 1;

-- (a) "AS IT WAS": join on the version valid at the time of the order.
SELECT 'as it was' AS view_type, d.country, COUNT(*) AS orders
FROM orders AS o
JOIN dim_cust_scd AS d
  ON d.customer_id = o.customer_id
 AND o.order_date BETWEEN d.valid_from AND d.valid_to
WHERE o.customer_id = 1
GROUP BY d.country

UNION ALL

-- (b) "AS IT IS NOW": join only to the current version.
SELECT 'as it is now', d.country, COUNT(*)
FROM orders AS o
JOIN dim_cust_scd AS d
  ON d.customer_id = o.customer_id AND d.is_current
WHERE o.customer_id = 1
GROUP BY d.country
ORDER BY view_type, country;
Result
view_typecountryorders
as it is nowIE3
as it wasGB2
as it wasIE1

Amara's three orders split two-to-one across her two countries when asked historically, and all three land in Ireland when asked currently. Both answers are correct. They answer different questions — “where was the revenue earned?” versus “where do our current Irish customers' lifetime orders total?” — and a warehouse that offers only one of them will have the other reconstructed badly in spreadsheets.

In production the “as it was” join is not done at query time. The fact table stores customer_sk, resolved once at load against the version valid at the event date, so analysts get point-in-time correctness with an ordinary equi-join and cannot get it wrong.

Analogy

Type 1 is correcting a typo in your address book; Type 2 is keeping every address you have ever had, with the dates you lived there. If a friend asks “where should I post this?” the address book is enough. If a court asks “which country were you resident in during 2023?” only the dated list will do — and if you overwrote it, no amount of care later can reconstruct the answer. That asymmetry is the whole argument: Type 1 discards information irreversibly, and you rarely know in advance which questions you will be asked.

The other types, briefly

Type 0 is a constraint, not a mechanism: the load simply refuses to update the column. Use it where a later value would be actively misleading — original acquisition channel, date of birth, the cohort someone belongs to. Inference Type 0 attributes are often the most valuable in a dimension precisely because they are immune to the drift that erodes everything else.

Type 1 destroys history, so reserve it for corrections — changes where the old value was never true. A misspelt surname was always wrong; overwriting it loses nothing. A relocation is not a correction: the old country was true at the time, and Type 1 on it is how the Nordics report moved.

Type 3 adds previous_country beside country. It handles exactly one change and is chiefly used for a planned reorganisation — a sales-territory redraw where the business wants a season of reporting both ways. Its limitation is not a flaw but a design: a second change overwrites the first “previous” value.

Type 4 splits a fast-changing attribute into its own history table (sometimes called a mini-dimension), keeping the main dimension small and stable. Telecommunications A subscriber's tariff band might change monthly while their name and address change once a decade; Type 2 on one table would multiply every subscriber's rows twelvefold a year, most of it duplicated attributes.

Type 6 is Type 2 rows that additionally carry current_ columns, refreshed across all versions of an entity whenever it changes. One row then answers both questions: country gives “as it was”, current_country gives “as it is”, with no second join. The cost is that every version of an entity must be rewritten on every change — acceptable for a dimension, unacceptable for a fact.

In the field

The conversation that decides this is not technical. Ask the business one question about each attribute: “when this changes, should last year's report change too?” “Obviously not” means Type 2. “Obviously yes, it was just wrong” means Type 1. Hesitation means Type 2, because Type 2 can always be presented as Type 1 by filtering to the current version, and Type 1 can never be turned back into Type 2 — the information is gone. When in doubt, keep the history. Storage is cheap; a year of unreproducible reports is not.

When NOT to use Type 2

  • Fast-changing attributes. Anything updating daily will explode the dimension. Use Type 4 mini-dimensions, or if the attribute is genuinely measured rather than descriptive, move it to a periodic snapshot fact (Lesson 7.8).
  • Attributes nobody slices by. Versioning a free-text notes field costs rows and buys nothing. Track what appears in GROUP BY clauses.
  • Pure corrections. Versioning a typo creates a permanent record asserting the customer was once called “Amrra”. That is not history, it is noise.
  • Where regulation requires erasure. A right-to-be-forgotten request against a Type 2 dimension means finding and removing every version. Plan for it before you have ten years of rows.
  • When the source already versions. If the operational system emits a full change log with effective dates, consuming it beats reconstructing versions by comparing snapshots — snapshot comparison can only ever detect changes that survived until the next snapshot, so anything that changed twice between loads is lost.

Open Whether Type 2 should be the blanket default is contested. One view treats it as the safe default for every descriptive attribute, accepting the row growth. Another argues that most attributes are never sliced historically and that indiscriminate versioning makes dimensions unmanageably large and queries subtly wrong for the many people who just want the current value. Both are defensible; what is not defensible is choosing by habit rather than per attribute.

Interview angle

SCD is close to guaranteed in any data-warehouse interview, and reciting the type list scores little. The differentiators are three. First, saying explicitly that the type is chosen per attribute, not per table, and giving a dimension that mixes them. Second, writing the Type 2 upsert with IS DISTINCT FROM and being able to explain why <> silently breaks on nulls — interviewers plant a nullable column to see whether you notice. Third, articulating the “as it was” versus “as it is now” distinction and noting that the fact table should store the point-in-time surrogate key so analysts cannot get it wrong. A very strong close: “when the business is unsure, choose Type 2 — it can be presented as Type 1 later, but not the reverse.”

Exercise 7.7

FMCG A brand reorganises its biscuit range: FM_2025_3 moves from the Kellow brand to Marlow on 1 October 2024. Build a Type 2 SKU dimension, apply the change, then produce revenue by brand as it was and as it is now, and explain the difference. State which SCD type you would use for list_price and why.

Show solution
sqlexercise_7_7.sql
-- Initial versions, all open.
CREATE TABLE dim_sku_scd (
  sku_sk BIGINT, sku_id VARCHAR, product_name VARCHAR, brand VARCHAR,
  valid_from DATE, valid_to DATE, is_current BOOLEAN
);

INSERT INTO dim_sku_scd
SELECT ROW_NUMBER() OVER (ORDER BY sku_id), sku_id, product_name, brand,
       DATE '2024-01-01', DATE '9999-12-31', TRUE
FROM skus;

-- The rebrand feed.
CREATE TABLE sku_feed AS
SELECT sku_id, product_name,
       CASE WHEN sku_id = 'FM_2025_3' THEN 'Marlow' ELSE brand END AS brand
FROM skus;

-- Step 1: close changed versions.
UPDATE dim_sku_scd AS d
   SET valid_to = DATE '2024-10-01' - INTERVAL 1 DAY, is_current = FALSE
FROM sku_feed AS f
WHERE f.sku_id = d.sku_id AND d.is_current
  AND f.brand IS DISTINCT FROM d.brand;

-- Step 2: open new versions for anything now uncovered.
INSERT INTO dim_sku_scd
SELECT (SELECT MAX(sku_sk) FROM dim_sku_scd) + ROW_NUMBER() OVER (ORDER BY f.sku_id),
       f.sku_id, f.product_name, f.brand,
       DATE '2024-10-01', DATE '9999-12-31', TRUE
FROM sku_feed AS f
WHERE NOT EXISTS (SELECT 1 FROM dim_sku_scd AS d
                  WHERE d.sku_id = f.sku_id AND d.is_current);

-- Step 3: the two views of brand revenue.
SELECT 'as it was' AS view_type, d.brand, ROUND(SUM(s.revenue), 2) AS revenue
FROM sales AS s
JOIN dim_sku_scd AS d
  ON d.sku_id = s.sku_id AND s.sold_on BETWEEN d.valid_from AND d.valid_to
GROUP BY d.brand
UNION ALL
SELECT 'as it is now', d.brand, ROUND(SUM(s.revenue), 2)
FROM sales AS s
JOIN dim_sku_scd AS d ON d.sku_id = s.sku_id AND d.is_current
GROUP BY d.brand
ORDER BY view_type, brand;
Result
view_typebrandrevenue
as it is nowAshby5122.50
as it is nowMarlow16850.50
as it wasAshby5122.50
as it wasKellow1984.00
as it wasMarlow14866.50

Reading the difference. Historically, Kellow earned £1,984 — the September sale of FM_2025_3, made while the SKU still belonged to that brand. The October sale of the same SKU falls after the rebrand and counts to Marlow in both views. In the “as it is now” view Kellow disappears entirely and its £1,984 is absorbed into Marlow, whose total rises from £14,866.50 to £16,850.50. The grand total is identical in both views — only the attribution moves, which is exactly the property that makes the discrepancy so hard to spot on a single-brand report.

Both figures are legitimate and answer different questions. Brand performance reviews and any historical trend want “as it was”, or the trend line will step whenever a SKU is reassigned. Current portfolio planning — “what is Marlow's total footprint today?” — wants “as it is now”. A Type 6 dimension would serve both from a single row.

What about list_price? Not a Type 2 attribute. Price changes frequently, which would multiply dimension rows; and more importantly the price actually charged already belongs on the fact as a measure, where it is a record of the transaction rather than a description of the SKU. Keep the current list price as Type 1 in the dimension for reference, and if price history genuinely matters for analysis, model it as its own dated fact table — one row per SKU per price-effective date — rather than inflating the dimension.

Knowledge check

A Type 2 load uses WHERE feed.country <> dim.country to detect changes. What goes wrong?

Knowledge check

The business cannot decide whether a relocated customer's past orders should move with them. Which SCD type should you implement?

Key takeaway

Choose an SCD type per attribute, not per table, by asking one question: when this changes, should last year's report change too? Type 1 overwrites and destroys history — keep it for genuine corrections. Type 2 adds a dated version row and is the default for anything you slice history by; write its change detection with IS DISTINCT FROM, close the old row the day before the new one opens, and resolve the point-in-time surrogate key at fact-load time so analysts get history right without thinking about it. Type 0 freezes, Type 3 remembers one prior value, Type 4 splits off fast-changing attributes, Type 6 serves “as it was” and “as it is now” from one row. When unsure, keep the history: you can always hide it, never recover it.

Lesson 7.8·13 min read

Fact Table Types: Transaction, Periodic Snapshot, Accumulating Snapshot

You will be able to recognise which of the three fact patterns a business question needs, and stop trying to answer “how long does this take?” with an event log.

Manufacturing A factory's warehouse records every production event: order received, materials issued, machine started, quality checked, shipped. Ask “how many units shipped last week?” and it answers instantly. Ask “how much work-in-progress is sitting on the floor right now?” or “what is our average order-to-ship time, and is it improving?” and every attempt turns into a self-join nightmare that nobody trusts.

The event log is not deficient. It is the wrong shape for those two questions, and no amount of SQL skill fixes a shape problem. There are three fact table patterns, they answer three different classes of question, and mature warehouses carry all three over the same business process.

The three patterns

 TransactionPeriodic snapshotAccumulating snapshot
One row perEvent, as it happensEntity per periodProcess instance, from start to finish
Rows areInserted, never touched againInserted per period, never touchedUpdated as the process advances
GrowsWith activityPredictably — entities × periodsWith process instances, then stops
DatesOneOne (the period end)Many — one per milestone
MeasuresFully additiveOften semi-additive (balances)Durations and milestone amounts
Answers“How much happened?”“What was the state at period end?”“How long does it take, and where does it stall?”
Empty whenNothing happenedNever — a zero is still a rowNever — unfinished steps are NULL

The last row is the one people miss. A transaction fact has no row for a customer who did nothing this month, so “customers with zero orders” requires an outer join to a dimension. A periodic snapshot has a row for every entity every period regardless, which is exactly why it can answer “how many accounts had a zero balance” without gymnastics.

Transaction facts

The default and by far the most common: one immutable row per business event, at atomic grain, with fully additive measures. order_items joined to orders is a transaction fact. Everything in Lessons 7.3 to 7.6 assumed this shape.

Its strength is completeness — every question about what happened is answerable. Its weakness is that state must be reconstructed by replaying events, which gets expensive and, where events can be missed or arrive late, unreliable.

Periodic snapshots

One row per entity per period, recording state at the period boundary whether anything changed or not. SaaS Monthly recurring revenue is the textbook case: MRR is not a sum of events, it is a level, and the question “what was MRR at the end of March?” has no natural answer in an event log.

sqlperiodic_snapshot.sql
-- Grain: one row per calendar month, recording the subscription base as at
-- the first of that month. A month with no signups and no churn still gets a row.
CREATE TABLE fct_mrr_monthly AS
WITH months AS (
  SELECT CAST(m AS DATE) AS month_start
  FROM generate_series(DATE '2024-01-01', DATE '2024-12-01', INTERVAL 1 MONTH) AS t(m)
)
SELECT m.month_start,
       COUNT(s.subscription_id)                                   AS active_subs,
       COALESCE(SUM(s.mrr), 0)                                    AS mrr,
       COUNT(*) FILTER (WHERE s.plan = 'enterprise')              AS enterprise_subs
FROM months AS m
LEFT JOIN subscriptions AS s
       ON s.started_on <= m.month_start
      AND (s.cancelled_on IS NULL OR s.cancelled_on > m.month_start)
GROUP BY m.month_start;

SELECT month_start, active_subs, mrr, enterprise_subs
FROM fct_mrr_monthly
ORDER BY month_start;
Result
month_startactive_subsmrrenterprise_subs
2024-01-0100.000
2024-02-01268.000
2024-03-013117.000
2024-04-015335.001
2024-05-016384.001
2024-06-016384.001
2024-07-018632.002
2024-08-018681.002
2024-09-017662.002
2024-10-018681.002
2024-11-018681.002
2024-12-017662.002

Note January: zero subscriptions, and a row anyway. That row is what lets a chart start at zero rather than beginning mysteriously in February, and what lets “months with no enterprise customers” be a simple filter.

Common pitfall — summing a semi-additive snapshot across time

SUM(mrr) across the twelve rows above gives 5,287 — a number with no meaning whatsoever. MRR is a level, not a flow: it can be summed across plans, countries and segments, but never across periods. The correct time aggregations are the latest value (closing MRR), the average, or the change between periods. Fact This is what “semi-additive” means, and every balance, headcount, inventory level and open-position count behaves this way. The defence is naming: call the column mrr_at_month_start rather than mrr, so that SUM(mrr_at_month_start) reads as obviously wrong to anyone typing it.

Accumulating snapshots

The unusual one: one row per process instance, updated in place as it progresses. This breaks the rule that facts are immutable, and does so deliberately, because it is the only shape that makes pipeline questions easy.

The row is created when the process starts, with a column for every milestone date, all NULL but the first. As the process advances, the row is updated. When it finishes, the row is complete and never touched again. Durations between milestones become simple column arithmetic instead of self-joins.

sqlaccumulating_snapshot.sql
-- Grain: one row per session that entered the funnel, with a column per milestone.
-- NULL means "has not happened (yet)" — which is itself the answer to
-- "where do people drop out?".
CREATE TABLE fct_funnel AS
SELECT s.session_id,
       s.customer_id,
       s.device,
       MIN(CASE WHEN e.event_name = 'view_item'      THEN e.event_at END) AS viewed_at,
       MIN(CASE WHEN e.event_name = 'add_to_cart'    THEN e.event_at END) AS carted_at,
       MIN(CASE WHEN e.event_name = 'begin_checkout' THEN e.event_at END) AS checkout_at,
       MIN(CASE WHEN e.event_name = 'purchase'       THEN e.event_at END) AS purchased_at
FROM sessions AS s
JOIN events   AS e ON e.session_id = s.session_id
GROUP BY s.session_id, s.customer_id, s.device;

-- Durations are now column subtraction, not a self-join.
SELECT session_id, device,
       DATE_DIFF('minute', viewed_at,  carted_at)    AS mins_view_to_cart,
       DATE_DIFF('minute', carted_at,  purchased_at) AS mins_cart_to_purchase,
       CASE WHEN purchased_at IS NOT NULL THEN 'purchased'
            WHEN checkout_at  IS NOT NULL THEN 'abandoned at checkout'
            WHEN carted_at    IS NOT NULL THEN 'abandoned at cart'
            ELSE 'browsed only' END                  AS furthest_stage
FROM fct_funnel
ORDER BY session_id;
Result
session_iddevicemins_view_to_cartmins_cart_to_purchasefurthest_stage
1mobile37purchased
3mobile68purchased
4desktop55purchased
9mobile6NULLabandoned at cart
14mobileNULLNULLbrowsed only
15desktop5NULLabandoned at checkout

Every funnel question is now trivial. Conversion rate is a COUNT of non-null purchased_at over total rows. Drop-off by stage is a GROUP BY furthest_stage. Median time-to-purchase is a percentile over a column. None of it requires understanding event sequencing, which is precisely the point: the modelling work was done once, so the analysis is ordinary SQL.

Analogy

Think of a hospital. The transaction fact is the log of everything that happened today — admissions, tests, discharges. The periodic snapshot is the 6am ward round: how many patients are in each ward right now, whether or not anyone moved overnight. The accumulating snapshot is a single patient's chart, one per admission, updated as they progress from A&E to ward to discharge, with the times written in as each happens. You could in principle answer “average length of stay” from the event log — but the chart makes it a subtraction, and nobody gets it wrong.

Choosing, and using more than one

These are not alternatives. Banking A lending business will properly carry all three over the same process: a transaction fact of every payment and fee; a monthly snapshot of every loan's outstanding balance; and an accumulating snapshot of each application from submission through underwriting to drawdown. The three answer “how much did we collect?”, “what do we hold?” and “how fast do we approve?” respectively, and none of them substitutes for another.

Ask which question is being posed:

  • “How much / how many happened?” → transaction.
  • “What was the state at a point in time?” → periodic snapshot.
  • “How long does it take and where does it stall?” → accumulating snapshot.

When NOT to use each

  • Not a periodic snapshot when the entity count is huge and mostly static. Snapshotting fifty million dormant accounts nightly generates enormous volumes of unchanged rows. Consider snapshotting only changed entities plus a periodic full baseline, or querying a Type 2 dimension as-at a date instead.
  • Not an accumulating snapshot when the process has no fixed set of milestones, or when milestones can repeat or occur out of order. A support ticket that bounces between teams five times does not fit a fixed column-per-stage layout — that is a transaction fact of status changes with a derived duration model on top.
  • Not an accumulating snapshot on immutable storage. It requires updates. On append-only formats or streaming sinks, updates are expensive or unavailable; rebuild the table per load instead, which is what the query above effectively does.
  • Not a transaction fact for questions about levels. Reconstructing a balance by summing every movement since inception works until one movement is missed or arrives late, after which every subsequent balance is wrong. Snapshots bound the blast radius of that error to one period.
Interview angle

Being able to name all three types puts you ahead of most candidates; being able to say which one a scenario needs puts you ahead of nearly all. Expect a prompt like “the business wants to know average time from order to delivery and where orders get stuck” — the expected answer is an accumulating snapshot with a date column per milestone, and the expected elaboration is that it is updated in place, breaking the usual immutability rule for a good reason. A second common probe is semi-additivity: if asked how to report account balances, saying “monthly snapshot, and never sum across time” is the answer they are listening for.

Exercise 7.8

Supply chain Build a monthly periodic snapshot of inventory position by warehouse from the inventory table, flagging SKUs below their reorder point. Then explain (a) which measures are semi-additive and (b) why a transaction fact of stock movements would be a poor substitute for this question.

Show solution
sqlexercise_7_8.sql
-- Grain: one row per warehouse per snapshot month.
CREATE TABLE fct_inventory_snapshot AS
WITH months AS (
  SELECT CAST(m AS DATE) AS snapshot_month
  FROM generate_series(DATE '2024-09-01', DATE '2024-11-01', INTERVAL 1 MONTH) AS t(m)
)
SELECT m.snapshot_month,
       i.warehouse,
       COUNT(*)                                              AS skus_held,
       SUM(i.on_hand)                                        AS units_on_hand,
       COUNT(*) FILTER (WHERE i.on_hand < i.reorder_point)   AS skus_below_reorder
FROM months AS m
CROSS JOIN inventory AS i
GROUP BY m.snapshot_month, i.warehouse;

SELECT snapshot_month, warehouse, skus_held, units_on_hand, skus_below_reorder
FROM fct_inventory_snapshot
ORDER BY snapshot_month, warehouse;
Result (first month)
snapshot_monthwarehouseskus_heldunits_on_handskus_below_reorder
2024-09-01BRI235501
2024-09-01LEE253801
2024-09-01MAN232901

(a) Semi-additive measures. units_on_hand and skus_held sum correctly across warehouses and across SKUs — “total units across all warehouses in September” is meaningful. They must never be summed across snapshot months: adding September's and October's stock levels would imply you own the same physical box twice. For time, use the latest snapshot, the average, or the period-on-period change. skus_below_reorder behaves the same way. (In this exercise the underlying table holds only current values, so the three months repeat — in production each month would capture that month's real position.)

(b) Why a movement log is a poor substitute. Deriving stock on hand from a transaction fact means summing every receipt and issue since the warehouse opened. That is correct only if the log is perfect and complete forever. One missed movement, one late-arriving correction, one duplicated load, and every balance from that moment onward is wrong — and because the error compounds forward silently, you discover it at stock-take, months later, with no way to say when it started. A snapshot re-establishes ground truth every period, so an error is bounded to a single period and shows up as a discontinuity you can see on a chart. The movement log is still worth keeping: it answers “why did it change?”, which the snapshot cannot. The two are complements.

Knowledge check

Which fact type is deliberately updated after its rows are first written, and why?

Knowledge check

A monthly snapshot has an account_balance column. An analyst writes SUM(account_balance) grouped by year. What is wrong?

Key takeaway

Three fact shapes answer three questions. Transaction facts record events immutably and answer “how much happened”. Periodic snapshots record state every period whether or not anything changed, answer “what did we hold”, and carry semi-additive measures you must never sum across time. Accumulating snapshots keep one updated row per process instance with a date per milestone, and turn “how long does it take, where does it stall” from a self-join problem into column subtraction. They are complements, not alternatives — a mature model over one business process usually has all three.

Lesson 7.9·12 min read

Kimball vs Inmon: Two Philosophies of the Warehouse

You will be able to describe both architectures fairly, name what each optimises for, and recognise which one your organisation has accidentally built.

Every warehouse must answer one question before any table is created: do you build the enterprise model first and derive reporting structures from it, or build reporting structures first and integrate them as you go?

Two schools of thought answered this differently, and their names — associated with Bill Inmon and Ralph Kimball respectively — still label the two positions. The disagreement is real and remains unresolved, which is a signal that it is a genuine trade-off rather than a mistake one side is making.

The Inmon approach: integrate first

Build a single, enterprise-wide, normalised (typically 3NF) repository of atomic, historised data. It is the corporation's system of record: every subject area — customer, product, contract, claim — modelled once, integrated across all sources, subject-oriented and non-volatile. Departmental data marts, often dimensional, are then derived from that repository for consumption.

The reasoning: consistency is an architectural property. If every mart is generated from one integrated model, two marts cannot disagree about what a customer is, because neither owns the definition. The enterprise layer is the constraint that makes conformance structural rather than a matter of goodwill.

The cost is front-loaded. Modelling a whole enterprise before delivering much reporting takes time and organisational patience, and it demands agreement between departments who have historically disagreed about definitions. Business value arrives later.

The Kimball approach: deliver first, conform as you go

Build dimensional models directly, one business process at a time — sales first, then returns, then inventory. Integration is achieved not by a central normalised layer but by conformed dimensions: shared dimension tables used identically by every process, agreed through an enterprise bus matrix that maps which processes use which dimensions.

The reasoning: a warehouse that has not delivered anything cannot be validated, and requirements discovered by building are more reliable than requirements gathered by asking. Delivering a usable star schema for one process in weeks earns the credibility and the feedback needed to do the next one.

The cost is discipline debt. Conformance depends on teams genuinely reusing dimensions rather than forking their own. When that discipline slips — and under delivery pressure it does — you get exactly the disconnected marts the approach was designed to avoid, with no central model to fall back on.

 Inmon-styleKimball-style
Build orderEnterprise model, then martsBusiness process by business process
Core layerNormalised, integrated, historisedDimensional from the start
Integration byThe central model's structureConformed dimensions and a bus matrix
First deliveryLaterSooner
Adding a sourceExtend the enterprise model, regenerate martsMap to existing conformed dimensions
Failure modeNever finishes; business routes around itMarts drift apart; conformance erodes
Optimises forLong-run consistency and auditabilityTime-to-value and usability
Analogy

Inmon is town planning: survey the whole area, lay the sewers, roads and power, then let buildings go up on a grid that will still work in fifty years. Kimball is developing plot by plot to an agreed street pattern: houses appear this year, and the pattern is what stops the town becoming a maze. Planned towns can take a decade before anyone lives there. Plot-by-plot towns are inhabited quickly, and stay coherent exactly as long as everyone keeps to the street pattern. Neither is negligence; they weight time-to-shelter against fifty-year infrastructure differently.

The two shapes in SQL

Healthcare The difference is visible in what sits between the source and the report. Here both paths produce the same figure from the same source:

sqltwo_architectures.sql
-- ============ INMON-STYLE: integrated normalised core, then a derived mart ============
CREATE TABLE edw_customer AS            -- enterprise entity, normalised, historised
SELECT customer_id, full_name, email, country, channel, signup_date, is_active
FROM customers;

CREATE TABLE edw_order AS               -- enterprise entity, atomic, no reporting shape
SELECT order_id, customer_id, order_date, status
FROM orders;

CREATE TABLE edw_order_line AS
SELECT order_item_id, order_id, product_id, quantity, unit_price, discount_pct
FROM order_items;

-- The mart is DERIVED from the enterprise layer, never from the source.
CREATE TABLE mart_sales_inmon AS
SELECT c.country,
       DATE_TRUNC('month', o.order_date) AS month_start,
       ROUND(SUM(l.quantity * l.unit_price * (1 - l.discount_pct / 100.0)), 2) AS revenue
FROM edw_order_line AS l
JOIN edw_order      AS o ON o.order_id    = l.order_id
JOIN edw_customer   AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY 1, 2;

-- ============ KIMBALL-STYLE: straight to a star, integrating via conformed dims ======
CREATE TABLE dim_customer_k AS
SELECT ROW_NUMBER() OVER (ORDER BY customer_id) AS customer_key,
       customer_id, full_name, COALESCE(country, 'Unknown') AS country, channel
FROM customers;

CREATE TABLE fct_sales_k AS
SELECT dc.customer_key, o.order_date, o.status,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders         AS o  ON o.order_id     = oi.order_id
JOIN dim_customer_k AS dc ON dc.customer_id = o.customer_id;

-- Same question, same answer, different route.
SELECT 'inmon mart' AS route, COALESCE(country, 'Unknown') AS country,
       ROUND(SUM(revenue), 2) AS revenue
FROM mart_sales_inmon GROUP BY 2
UNION ALL
SELECT 'kimball star', dc.country, ROUND(SUM(f.net_revenue), 2)
FROM fct_sales_k AS f
JOIN dim_customer_k AS dc ON dc.customer_key = f.customer_key
WHERE f.status = 'completed'
GROUP BY 2
ORDER BY country, route
LIMIT 8;

The SQL is unremarkable, which is the point: the architectures differ in governance and sequence, not in syntax. What differs is that the Inmon mart may only be built from edw_* tables — a rule enforced by review, not by the database — while the Kimball star reads the source directly and relies on dim_customer_k being the one customer dimension anybody builds.

Common pitfall — claiming an architecture you do not have

Most warehouses in the wild are neither. They have a staging layer nobody calls an enterprise model, a handful of star-ish tables built by different people at different times, and three customer dimensions with different row counts. Announcing “we follow Kimball” without a bus matrix or genuinely shared dimensions gets you the informality without the discipline that makes it work. Both approaches deliver consistency through a specific mechanism — a central integrated model, or enforced conformed dimensions. If you cannot point at your mechanism, you do not have one, whichever name is on the slide.

What changed, and what did not

Open Both camps remain active and the field genuinely disagrees. Some of the original terms of debate have shifted, though.

Cheap storage and separated compute weakened the argument that a normalised core is too expensive to maintain, and equally weakened the argument that dimensional models are necessary for acceptable query performance. ELT — loading raw data first and transforming inside the warehouse — means most organisations now keep a raw layer that is neither an enterprise model nor a star, which is a genuinely new third thing both original architectures predate. And transformation frameworks with version control, testing and lineage have made layered pipelines cheap enough that the sequencing argument (“we cannot afford to model the enterprise first”) carries less weight than it did.

What did not change is the underlying tension. Consistency across an organisation requires that somewhere, someone defines what a customer is and everyone else uses that definition. You can locate that agreement in a central normalised model or in a set of conformed dimensions. What you cannot do is skip it and expect the numbers to agree. Inference Most modern stacks are hybrids — a raw layer, an integrated and tested intermediate layer that is normalised in spirit, and dimensional marts on top — which is closer to a synthesis than a victory for either side.

In the field

Ask two diagnostic questions of any warehouse. First: “if two reports disagree about revenue, what is the mechanism that resolves it?” Second: “where is customer defined, and who owns that definition?” A healthy Inmon-style shop points at the enterprise model. A healthy Kimball-style shop points at the conformed dimension and its steward. An unhealthy shop of either kind points at a person, and that person is usually on holiday.

When neither is right

  • A single source system and one team. Both architectures exist to integrate across sources and coordinate across teams. With neither problem, the ceremony is pure cost — model the source sensibly and move on.
  • Exploratory or research work. Data science on raw event data does not want a curated layer between it and the truth; it wants the raw layer plus tooling.
  • Highly volatile domains. Where the business model changes faster than a model can be agreed, heavy up-front modelling is obsolete on delivery. This is part of the motivation for Data Vault (Lesson 7.10), which absorbs structural change more gracefully than either.
  • Operational or real-time serving. Neither architecture targets sub-second application lookups. That is a different system.
Interview angle

“Kimball or Inmon?” is a judgement test, not a knowledge test, and picking a side enthusiastically is the wrong move. Describe both fairly in one sentence each — top-down integrated normalised core feeding derived marts, versus bottom-up dimensional models integrated through conformed dimensions — then name what each optimises for and its characteristic failure. The strongest close is situational: many sources and strong governance needs favour the integrated core; urgent delivery pressure and a small number of well-understood processes favour dimensional-first; and most modern stacks land on a layered hybrid. Do not attribute specific claims or quotes to either author unless you are certain; describing the architectures is what is being assessed.

Exercise 7.9

Finance A bank has six source systems, a regulator that audits definitions, and a board demanding a dashboard this quarter. Sketch an architecture that serves both pressures, and demonstrate the key structural idea in SQL: a mart that can only be built from an integrated layer, with a test proving no mart bypasses it.

Show solution

The architecture. Layer it. A raw landing zone per source (no transformation, full history — this is what makes audit possible). An integrated, tested, normalised core where each entity is defined exactly once and source-specific quirks are reconciled. Dimensional marts on top, built only from the core. Deliver the board's dashboard as the first mart, but build it through the core rather than around it — so the urgent deliverable pays for the first slice of the integrated layer instead of competing with it.

sqlexercise_7_9.sql
-- Layer 1: integrated core. One definition of a customer, reconciled here.
CREATE TABLE core_customer AS
SELECT customer_id,
       full_name,
       COALESCE(country, 'Unknown')                  AS country,
       channel,
       signup_date,
       COALESCE(is_active, FALSE)                    AS is_active
FROM customers;

CREATE TABLE core_revenue_line AS
SELECT oi.order_item_id, o.order_id, o.customer_id, o.order_date, o.status,
       ROUND(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0), 2) AS net_revenue
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id;

-- Layer 2: the mart reads ONLY from core_* tables.
CREATE TABLE mart_country_revenue AS
SELECT c.country,
       ROUND(SUM(r.net_revenue), 2) AS revenue,
       COUNT(DISTINCT r.order_id)   AS orders
FROM core_revenue_line AS r
JOIN core_customer     AS c ON c.customer_id = r.customer_id
WHERE r.status = 'completed'
GROUP BY c.country;

-- The governance test: the mart must reconcile to the core, exactly.
SELECT (SELECT ROUND(SUM(revenue), 2) FROM mart_country_revenue)        AS mart_total,
       (SELECT ROUND(SUM(net_revenue), 2) FROM core_revenue_line
         WHERE status = 'completed')                                    AS core_total,
       CASE WHEN (SELECT ROUND(SUM(revenue), 2) FROM mart_country_revenue)
               = (SELECT ROUND(SUM(net_revenue), 2) FROM core_revenue_line
                   WHERE status = 'completed')
            THEN 'RECONCILED' ELSE 'BYPASS DETECTED' END                AS audit_check;
Result
mart_totalcore_totalaudit_check
6360.856360.85RECONCILED

Why this satisfies both pressures. The regulator's requirement is met by the core: one place where “customer” and “revenue” are defined, with a reconciliation test that fails the build if a mart drifts. The board's requirement is met by shipping one mart now rather than waiting for all six sources to be modelled.

The important discipline is architectural rather than technical: a mart that reads orders directly instead of core_revenue_line would still produce a number, and the number might even be right today. The reconciliation test above is what turns “we agreed not to do that” into something the build enforces — and enforcement, not intention, is what distinguishes both named architectures from the informal mess that usually results.

Knowledge check

In a Kimball-style architecture, what provides consistency across business processes?

Key takeaway

Two coherent answers to one question. Inmon-style: build an integrated normalised enterprise core first, derive marts from it, and get consistency structurally at the cost of slower delivery. Kimball-style: build dimensional models per business process and integrate through conformed dimensions, getting value sooner at the cost of depending on sustained discipline. Both locate the agreement about what a customer is somewhere specific — and that, not the table shapes, is what makes either work. Most modern stacks are layered hybrids; the question that matters is not which name you use but whether you can point at your mechanism.

Part 8

Business Analytics With SQL

Metrics that survive scrutiny: revenue, CAC, LTV, retention, cohorts, funnels, churn, segmentation and attribution — and what SQL cannot answer.

Lesson 8.1·13 min read

What Makes a Good Metric (and How Metrics Get Gamed)

By the end you will be able to write a metric definition precise enough that two analysts, working separately, produce the same number — and to predict how that metric will be gamed once someone is paid on it.

A finance director, a growth lead and an analyst walk into a meeting with three numbers for last month's revenue. The finance figure is the smallest, the growth figure the largest, the analyst's somewhere between. Nobody has made an arithmetic mistake. They have simply answered three different questions and given all three the same one-word name.

This is the single most common failure in business analytics, and it is not a SQL failure. Every query in this part will be syntactically perfect and still produce a misleading number if the definition behind it is loose. So before any cohort or funnel, we deal with definitions.

A metric is a compressed claim about reality

A metric takes millions of rows describing what actually happened and squeezes them into one number a human can hold in their head. Compression is the point — and compression always discards something. The craft is choosing what to discard deliberately rather than accidentally.

Any metric, no matter how it is described in a meeting, is fully determined by four dials:

  1. Population. Which rows count? All orders, or only completed ones? All customers, or only those who have ever purchased?
  2. Measure. What is summed or counted? Item price before discount, after discount, before or after tax and shipping?
  3. Grain. Per what — per day, per month, per customer, per order?
  4. Timestamp. Which date attributes a row to a period? Order date, ship date, payment date, refund date?

Inference Two people who agree on all four dials will produce the same number. Disagreements that feel like disputes about "the truth" are almost always disagreements about one dial that nobody wrote down.

flowchart LR
  A["Raw rows
(orders, items)"] --> B["Dial 1: Population
which rows count"] B --> C["Dial 2: Measure
what is summed"] C --> D["Dial 3: Grain
per what"] D --> E["Dial 4: Timestamp
which date attributes it"] E --> F["One number
on a dashboard"]
Every dispute about a number is a dispute about one of these four dials. Naming the dial ends the argument in a minute; arguing about the number can take a week.

One word, three numbers

E-commerce Here are three defensible definitions of "revenue" over the same data. Nothing is wrong with any of them; what is wrong is calling all three revenue.

sqlthree_revenues.sql
SELECT
  -- Dial 1 wide open: every order, whatever happened to it afterwards
  ROUND(SUM(oi.quantity * oi.unit_price), 2)                       AS booked_all_statuses,
  -- Dial 1 narrowed: only orders that actually completed
  ROUND(SUM(CASE WHEN o.status = 'completed'
                 THEN oi.quantity * oi.unit_price END), 2)         AS booked_completed,
  -- Dial 2 changed: the amount the customer was actually charged
  ROUND(SUM(CASE WHEN o.status = 'completed'
                 THEN oi.quantity * oi.unit_price
                      * (1 - oi.discount_pct / 100) END), 2)       AS net_completed
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id;
Result
booked_all_statusesbooked_completednet_completed
7954.006538.006360.85

The gap between the first and third columns is the gap between the growth lead's number and the finance director's. It is not small, and it is entirely explained by two dial settings: whether cancelled and returned orders count, and whether discounts are subtracted.

Analogy

A metric is a photograph of a room. The room is real and the photograph is honest, but the photographer chose where to stand, what to crop and when to press the shutter. Two honest photographs of the same room can support opposite claims about how tidy it is. When someone shows you a number, you are looking at a photograph; the useful question is never "is this true?" but "where were you standing?"

What separates a good metric from a bad one

Six properties, in rough order of how often they are violated:

  • It is attached to a decision. If no plausible value of the metric would change anyone's behaviour, it is decoration. Ask: "if this doubled, what would we do differently?" A blank answer means delete the tile.
  • It is defined in one unambiguous sentence, naming all four dials. "Net revenue = sum of quantity × unit price × (1 − discount) for orders with status completed, attributed to order date."
  • It is decomposable. A good metric breaks into drivers you can act on. Revenue = orders × average order value; orders = sessions × conversion rate. A metric that cannot be decomposed tells you something moved but never why.
  • Its denominator is stable. Ratios move when the bottom changes, and the bottom is the part nobody watches.
  • It is hard to move without doing the underlying work. This is the anti-gaming property, and it is the hardest.
  • It can actually be computed from data you have, repeatedly, without a manual step. A metric that requires someone to paste a spreadsheet each month will quietly stop being produced.

How metrics get gamed

Fact Goodhart's law, as popularly stated, is: when a measure becomes a target, it ceases to be a good measure. The mechanism is not dishonesty. It is that any metric is a proxy for something you actually care about, and there are always cheaper ways to move the proxy than to move the real thing.

Target metricWhat you actually wantThe cheap path someone will find
Number of ordersProfitable demandDiscount until orders rise; margin falls faster
Tickets closed per agentCustomers helpedClose hard tickets, tell the customer to re-open
Signups this monthUsers who stayBuy the cheapest traffic available; retention collapses
Average handling timeEfficient serviceEnd calls early; repeat-contact rate rises
Model accuracy on a rare eventCatching the eventPredict "no" always; accuracy is high, recall is zero

The structural defence is to pair every target metric with a guardrail metric that moves in the opposite direction when the metric is gamed. Orders paired with gross margin. Signups paired with day-30 retention. Handling time paired with repeat-contact rate. You are not trying to make gaming impossible; you are trying to make it visible.

Common pitfall — the moving denominator

"Conversion rate fell from 4.0% to 3.2%, we have a checkout problem." Often the checkout is untouched and the denominator changed: a burst of cheap traffic, a bot crawl, or a tracking change added sessions that were never going to convert. Inference A ratio has two ways to move and reporting only shows one, so always publish the numerator and denominator alongside every rate. It costs two extra columns and prevents a whole genre of wrong conclusion.

When NOT to reach for a metric

Metrics are the wrong instrument more often than dashboard culture admits.

  • When the sample is tiny. The dataset in this course has 17 customers and 25 orders. Every query here runs correctly, but a 4-point move in a rate built on 17 people is noise. Real analysis would need orders of magnitude more rows before any claim about a difference is safe. Say this out loud rather than reporting a decimal place you cannot defend.
  • When the question is "why". Metrics detect; they rarely explain. Five user interviews will usually beat a fifth dashboard tile at explaining a drop.
  • When the thing is genuinely new. A two-week-old feature has no baseline and no seasonality. Reporting its "trend" invents a signal.
  • When measurement changes behaviour destructively. If publishing a per-person metric will make people optimise it at the expense of colleagues, the honest choice is often not to publish it per person.

The alternatives are qualitative research, controlled experiments (Part 8.13 revisits why), and simple counts with no ratio at all. Open How many metrics a team should track is genuinely contested: "one metric that matters" advocates argue focus, while balanced-scorecard advocates argue that any single metric is gameable by construction. Both critiques are correct about the other's failure mode.

In the field

The highest-leverage document an analytics team can own is not a dashboard — it is a metric dictionary: one row per metric, with its one-sentence definition, its four dials, the SQL that computes it, its owner, and its guardrail. When a number is questioned, the conversation moves from "your figure is wrong" to "the dictionary says completed orders only; your export included cancellations". That reframing is worth more than any visualisation.

Interview angle

"How would you measure the success of feature X?" is testing definition discipline, not creativity. Weak answers list metrics. Strong answers name the decision the metric serves, define one primary metric with its population, measure, grain and timestamp, add a guardrail metric with an explicit gaming story ("if the team optimises signups they will buy junk traffic, so I pair it with day-30 retention"), and state the sample size needed before the number means anything. Volunteering "with our current volume this would take about N weeks to read" is a very strong signal.

Exercise 8.1

Retail A merchandising lead asks for "average order value, by month". Write the query, then state in one sentence what your four dials are — and name one plausible alternative dial setting that would change the answer.

Show solution
sqlaov_by_month.sql
WITH order_value AS (
  SELECT o.order_id,
         strftime(o.order_date, '%Y-%m') AS order_month,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)) AS net_value
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status = 'completed'
  GROUP BY o.order_id, strftime(o.order_date, '%Y-%m')
)
SELECT order_month,
       COUNT(*)                                          AS orders,
       ROUND(SUM(net_value), 2)                          AS net_revenue,
       ROUND(SUM(net_value) / NULLIF(COUNT(*), 0), 2)    AS aov
FROM order_value
GROUP BY order_month
ORDER BY order_month;

The four dials. Population: orders with status completed. Measure: quantity × unit price × (1 − discount), summed to order level first. Grain: per calendar month, per order. Timestamp: order_date.

An alternative that changes the answer. Including returned orders in the population. A returned order was a real order at the moment of purchase, so a merchandising view might legitimately include it while a finance view would not. Note also the two-step structure: aggregating items to order level before averaging. Averaging item rows directly would give average item value, a different and usually smaller number — a classic silent error.

Knowledge check

A team's target is "weekly active users". Which guardrail metric best exposes the cheapest way to game it?

Key takeaway

A metric is fully specified by four dials: population, measure, grain, timestamp. Write them down or two analysts will produce two numbers with one name. Then assume the metric will be gamed — not by bad people, but by anyone who finds the cheap path — and pair it with a guardrail that moves the other way when they do.

Lesson 8.2·14 min read

Revenue Metrics: Gross, Net, AOV, Units, Mix

You will be able to decompose any revenue movement into the handful of drivers that caused it, and to spot when a headline number moved because the mix changed rather than performance.

Revenue went up 9% last month. That sentence, on its own, is almost useless. It could mean more customers bought, or the same customers bought more often, or the same orders got bigger, or nothing changed except that an expensive product happened to sell well. Each of those implies a different action, and three of them are not repeatable.

The job of revenue analytics is therefore not to report revenue. It is to decompose it until every component is something a human can act on.

The revenue ladder

Start from the most inclusive figure and subtract your way down. Each rung has a name, an owner and a reason to exist.

  GROSS REVENUE        list price x quantity, all orders placed
    - cancellations    orders that never shipped
    - discounts        promotions, codes, price matches
  = NET REVENUE        what the customer was charged
    - returns          money given back
  = NET REVENUE AFTER RETURNS
    - cost of goods    unit_cost x quantity
  = GROSS PROFIT       what is left to pay for everything else
    - marketing        see Lesson 8.3
  = CONTRIBUTION MARGIN

The rung most often skipped is the last two. A team can grow net revenue while shrinking gross profit — by discounting, or by selling more of a low-margin product — and a revenue-only dashboard will applaud.

The decomposition identity

Every revenue number obeys one identity, and it is worth memorising because it drives most diagnostic queries:

  Revenue  =  Orders  x  Average Order Value

  Orders   =  Buyers  x  Orders per Buyer
  AOV      =  Units per Order  x  Average Unit Price

  =>  Revenue = Buyers x Orders-per-Buyer x Units-per-Order x Avg-Unit-Price

Inference Because the identity is multiplicative, a movement in revenue is fully explained by movements in those four factors. If you compute all four for two periods, exactly one or two will have moved meaningfully — and that is your headline, not the revenue figure itself.

Analogy

Revenue is the total on a supermarket receipt. If the total is higher than last week, four things could have changed: you shopped on more days, you bought more items per trip, you bought pricier items, or the prices went up. "The receipt is bigger" is not a shopping insight. "I went three times instead of two" is — because it tells you what to change.

Computing the ladder and the drivers

E-commerce One query, one row per month, every rung and every driver. Note the two-stage aggregation: items roll up to orders, orders roll up to months. Skipping that stage is the most common bug in this whole family of queries.

sqlrevenue_drivers.sql
WITH order_level AS (
  SELECT o.order_id,
         o.customer_id,
         strftime(o.order_date, '%Y-%m')                              AS mth,
         SUM(oi.quantity)                                             AS units,
         SUM(oi.quantity * oi.unit_price)                             AS gross,
         SUM(oi.quantity * oi.unit_price * oi.discount_pct / 100)     AS discount,
         SUM(oi.quantity * p.unit_cost)                               AS cogs
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  JOIN products    AS p  ON p.product_id = oi.product_id
  WHERE o.status = 'completed'
  GROUP BY o.order_id, o.customer_id, strftime(o.order_date, '%Y-%m')
)
SELECT mth,
       COUNT(*)                                                       AS orders,
       COUNT(DISTINCT customer_id)                                    AS buyers,
       SUM(units)                                                     AS units,
       ROUND(SUM(gross), 2)                                           AS gross_rev,
       ROUND(SUM(gross - discount), 2)                                AS net_rev,
       ROUND(SUM(gross - discount - cogs), 2)                         AS gross_profit,
       ROUND(SUM(gross - discount) / NULLIF(COUNT(*), 0), 2)          AS aov,
       ROUND(SUM(units) * 1.0 / NULLIF(COUNT(*), 0), 2)               AS units_per_order,
       ROUND(100 * SUM(gross - discount - cogs)
                 / NULLIF(SUM(gross - discount), 0), 1)               AS margin_pct
FROM order_level
GROUP BY mth
ORDER BY mth;
Result — first six months of thirteen
mthordersbuyersunitsgross_revnet_revgross_profitaovunits_per_ordermargin_pct
2024-01224626.00591.20338.20295.602.0057.2
2024-02223617.00617.00369.00308.501.5059.8
2024-03222518.00473.15265.15236.581.0056.0
2024-04111219.00219.00131.00219.001.0059.8
2024-05224586.00547.30312.30273.652.0057.1
2024-06335945.00945.00569.00315.001.6760.2

Read that output as a diagnostic, not a report. Where aov jumps while orders is flat, someone bought something expensive — check whether one order carries the month. Where margin_pct falls while net_rev rises, you are buying growth with discount.

Note

Two details in that query earn their keep. NULLIF(COUNT(*), 0) makes an empty month return NULL rather than raising a divide-by-zero. And SUM(units) * 1.0 forces the division into decimal arithmetic — integer division would truncate 1.8 to 1. Money and rates should never be computed with integer or floating-point types where a decimal type is available; DuckDB's NUMERIC/DECIMAL keeps cents exact.

Mix: the driver nobody watches

Mix is the share of revenue coming from each part of the business. It matters because an aggregate can move without any component moving, purely because the weights changed.

FMCG Suppose margin on biscuits is 40% and on confectionery 25%. Nothing about either product changes, but confectionery grows faster. Blended margin falls. A dashboard shows "margin down 3 points" and a team spends a fortnight hunting a pricing error that does not exist. This is Simpson's paradox in its everyday commercial form: every segment can improve while the total gets worse.

sqlrevenue_mix.sql
WITH item_rev AS (
  SELECT c.department,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)) AS net_rev,
         SUM(oi.quantity * p.unit_cost)                                 AS cogs
  FROM orders     AS o
  JOIN order_items AS oi ON oi.order_id  = o.order_id
  JOIN products    AS p  ON p.product_id = oi.product_id
  JOIN categories  AS c  ON c.category_id = p.category_id
  WHERE o.status = 'completed'
  GROUP BY c.department
)
SELECT department,
       ROUND(net_rev, 2)                                               AS net_rev,
       ROUND(100 * net_rev / NULLIF(SUM(net_rev) OVER (), 0), 1)       AS pct_of_revenue,
       ROUND(100 * (net_rev - cogs) / NULLIF(net_rev, 0), 1)           AS margin_pct
FROM item_rev
ORDER BY net_rev DESC;
Result
departmentnet_revpct_of_revenuemargin_pct
Footwear2192.4534.558.6
Accessories1710.2526.957.4
Outdoor1275.1020.059.3
Apparel1183.0518.659.9

The window function SUM(net_rev) OVER () with an empty OVER clause computes the grand total alongside each row — the standard way to get a percentage-of-total without a self-join or a second pass.

Common pitfall — fan-out inflation

Joining orders to order_items multiplies order rows: an order with three items becomes three rows. Any COUNT(*), AVG() or order-level sum computed after that join is wrong — you will count orders three times and average item values rather than order values. The fix is the two-stage pattern above: aggregate items to one row per order first, then aggregate orders. If you ever see AOV that looks suspiciously close to a typical single-item price, this is why.

When NOT to use these metrics

  • Subscription businesses should not lead with AOV. SaaS When revenue is recurring, the meaningful quantities are MRR, expansion, contraction and churn (Lesson 8.8). AOV of a monthly charge is just the price list.
  • Marketplaces must separate GMV from revenue. The platform's revenue is the commission, not the transaction value. Reporting GMV as revenue overstates the business by an order of magnitude and is a well-known way to mislead investors.
  • Low-volume, high-ticket businesses should not report AOV monthly. With a handful of orders per month, one deal dominates and the series is noise. Report the deals themselves, or use a median, or lengthen the period.
  • Never lead with revenue when the decision is about profitability. Use gross profit or contribution margin; they are the same query with two more columns.

The alternative to any single aggregate is a distribution. Median order value, the 90th percentile, and the count of orders above some threshold together tell you far more than the mean — and are far harder to distort with one large order.

Interview angle

A classic take-home: "revenue fell 8% last month; find out why." The interviewer is watching for structure. Weak candidates start slicing by every dimension available. Strong candidates apply the identity first — buyers, orders per buyer, units per order, average unit price — establish which factor moved, and only then slice within that factor. They also check the boring explanations early: a status value that changed meaning, a channel that stopped sending data, one very large order in the comparison month, and a month with fewer trading days.

Exercise 8.2

Retail Discounting is under review. For each product category, report net revenue, the total value given away as discount, and the discount as a percentage of gross. Which category is buying its revenue most expensively?

Show solution
sqldiscount_depth.sql
SELECT cat.category_name,
       ROUND(SUM(oi.quantity * oi.unit_price), 2)                      AS gross_rev,
       ROUND(SUM(oi.quantity * oi.unit_price * oi.discount_pct / 100), 2)
                                                                       AS discount_given,
       ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)), 2)
                                                                       AS net_rev,
       ROUND(100 * SUM(oi.quantity * oi.unit_price * oi.discount_pct / 100)
                 / NULLIF(SUM(oi.quantity * oi.unit_price), 0), 2)     AS discount_pct_of_gross
FROM orders      AS o
JOIN order_items AS oi  ON oi.order_id   = o.order_id
JOIN products    AS p   ON p.product_id  = oi.product_id
JOIN categories  AS cat ON cat.category_id = p.category_id
WHERE o.status = 'completed'
GROUP BY cat.category_name
ORDER BY discount_pct_of_gross DESC;

Note that the discount percentage of gross must be computed as total discount over total gross, not as the average of the per-item discount_pct. Averaging the percentages weights a £10 item the same as a £400 item and will give a different, wrong answer. This is the weighted-average trap and it appears in nearly every rate metric you will build.

With this dataset each category has only a handful of items, so treat the ranking as an illustration of the method rather than a finding. A real discount review would need many months of data and would segment by promotion, not just by category.

Knowledge check

Net revenue rose 6% while gross profit fell 2%. Buyers, orders per buyer and units per order were all flat. What is the most likely explanation?

Key takeaway

Never report revenue alone. Walk the ladder — gross, net, after returns, gross profit — and decompose the movement with Revenue = Buyers × Orders-per-Buyer × Units-per-Order × Avg-Unit-Price. Aggregate items to orders before averaging, and check mix before believing that a blended rate moved because performance did.

Lesson 8.3·13 min read

Customer Acquisition Cost & Payback Period

You will be able to compute CAC from spend and signup data, express payback in months of gross profit, and explain why a blended CAC almost always flatters the truth.

A business that spends money to get customers has exactly one question underneath all the marketing dashboards: do we get the money back, and how long does it take? Everything else — click-through rates, impressions, cost per click — is intermediate machinery. Customer acquisition cost and payback period are where the machinery meets the bank account.

Payback matters as much as CAC because a business does not run on profit, it runs on cash. Two channels with identical CAC and identical lifetime value are not equivalent if one repays in two months and the other in fourteen. The fast one lets you reinvest six times a year; the slow one needs financing.

Definition, and the two honest variants

CAC = acquisition cost in a period ÷ new customers acquired in that period. Simple. The arguments are all about the numerator and the denominator.

VariantNumeratorDenominatorUse it for
Blended CACAll marketing spendAll new customers, including organicBoard-level "what does growth cost us"
Paid CACPaid media spend onlyNew customers attributed to paid channelsDeciding whether to spend more
Fully loaded CACMedia + salaries + tools + agency feesAll new customersUnit-economics and investor reporting

Inference Blended CAC falls whenever organic acquisition grows, even if every paid channel got worse. That makes it a comfortable number to present and a dangerous one to steer by. If you must show one, show it beside paid CAC so the divergence is visible.

Analogy

CAC is the price of a fruit tree; payback is how many seasons before the fruit covers what you paid. Nobody buys an orchard by comparing sapling prices alone — a cheap tree that fruits in year seven is worse than an expensive one that fruits in year two, because you can sell the year-two fruit and buy more trees. Anyone quoting you CAC without payback is quoting the sticker price of a tree and staying quiet about the harvest.

Computing CAC by channel and month

Marketing Our warehouse has monthly spend by channel in ad_spend, and each customer carries the channel they arrived through in customers.channel. That is enough for a paid CAC.

sqlcac_by_channel_month.sql
WITH spend AS (
  SELECT channel,
         strftime(spend_date, '%Y-%m') AS mth,
         SUM(cost)                     AS cost
  FROM ad_spend
  GROUP BY channel, strftime(spend_date, '%Y-%m')
),
acquired AS (
  SELECT channel,
         strftime(signup_date, '%Y-%m') AS mth,
         COUNT(*)                       AS new_customers
  FROM customers
  GROUP BY channel, strftime(signup_date, '%Y-%m')
)
SELECT s.mth,
       s.channel,
       ROUND(s.cost, 2)                                             AS cost,
       COALESCE(a.new_customers, 0)                                 AS new_customers,
       ROUND(s.cost / NULLIF(a.new_customers, 0), 2)                AS cac
FROM spend AS s
LEFT JOIN acquired AS a ON a.channel = s.channel AND a.mth = s.mth
ORDER BY s.mth, s.channel;
Result — first six rows of twelve
mthchannelcostnew_customerscac
2024-01paid_search2400.0012400.00
2024-01paid_social3100.000NULL
2024-02paid_search2650.000NULL
2024-02paid_social3400.0013400.00
2024-03paid_search2900.0012900.00
2024-03paid_social1900.000NULL

Those CAC figures are absurd — thousands of pounds to acquire a customer whose lifetime profit is in the hundreds. That is not a finding about the business; it is what happens when you divide six months of realistic media budget by seventeen customers. It is worth seeing precisely because it shows how violently the metric reacts to a small denominator: a single extra signup would halve the January figure. Treat this whole part's outputs as demonstrations of method, never as measurements of performance.

Three things in that query are the whole lesson. The LEFT JOIN keeps months where you spent and acquired nobody — those are exactly the months you need to see, and an inner join would hide them. NULLIF(a.new_customers, 0) turns "spent money, got nobody" into NULL rather than a crash; a NULL CAC is honest, because the true value is undefined, not zero and not infinite. And COALESCE on the count reports the zero plainly.

Note — on this dataset

ad_spend covers only paid_search and paid_social, and only the first half of 2024. Customers arriving via organic, referral and email have no spend row at all, and their CAC through this calculation is zero — which is false, not free. Organic acquisition is paid for in salaries, content and brand spend that simply do not appear in this table. Open How much paid media contributes to "organic" signups is genuinely unresolved and is the reason incrementality testing exists (Lesson 8.13).

Payback: CAC expressed in months of gross profit

Payback period is CAC ÷ gross profit per customer per month. The numerator must be a cost you actually paid; the denominator must be profit, not revenue. Using revenue is the most common error in this calculation and it makes every channel look roughly twice as good as it is.

sqlcac_payback.sql
WITH cust_profit AS (          -- lifetime gross profit realised so far, per customer
  SELECT c.customer_id,
         c.channel,
         MIN(o.order_date)                                              AS first_order,
         MAX(o.order_date)                                              AS last_order,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)
             - oi.quantity * p.unit_cost)                               AS gross_profit
  FROM customers   AS c
  JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id   = o.order_id
  JOIN products    AS p  ON p.product_id  = oi.product_id
  GROUP BY c.customer_id, c.channel
),
channel_profit AS (
  SELECT channel,
         COUNT(*)                                                       AS buyers,
         SUM(gross_profit)                                              AS total_gp,
         -- months of observed life, floored at 1 so a single-month customer is not divided by zero
         SUM(GREATEST(date_diff('month', first_order, last_order), 1))  AS observed_months
  FROM cust_profit
  GROUP BY channel
),
channel_spend AS (
  SELECT channel, SUM(cost) AS cost FROM ad_spend GROUP BY channel
)
SELECT cp.channel,
       cp.buyers,
       ROUND(cs.cost, 2)                                                AS media_cost,
       ROUND(cs.cost / NULLIF(cp.buyers, 0), 2)                         AS cac_per_buyer,
       ROUND(cp.total_gp / NULLIF(cp.buyers, 0), 2)                     AS gp_per_buyer,
       ROUND(cp.total_gp / NULLIF(cp.observed_months, 0), 2)            AS gp_per_buyer_month,
       ROUND((cs.cost / NULLIF(cp.buyers, 0))
             / NULLIF(cp.total_gp / NULLIF(cp.observed_months, 0), 0), 1)
                                                                        AS payback_months
FROM channel_profit AS cp
LEFT JOIN channel_spend AS cs ON cs.channel = cp.channel
ORDER BY cp.channel;

Channels with no row in ad_spend return NULL for cost, CAC and payback. That is the correct output: we do not know what organic cost, so we decline to state it. Reporting zero would be a fabrication dressed as a measurement.

Common pitfall — the timing mismatch

Spend in January does not produce all its customers in January. Someone who saw an ad in late January may sign up in February. Dividing January spend by January signups therefore mis-states CAC in both months whenever spend is changing, and the error is largest exactly when you are scaling — the moment you most need the number. Mitigations: use a trailing window (spend over the last 30 days divided by signups over the last 30 days), lag the spend by a typical consideration period, or move to cohort-based measurement. All three are approximations, and you should say so.

When NOT to use CAC

  • When acquisition is not the constraint. Healthcare A clinic limited by clinician hours cannot use more patients. Optimising CAC there buys demand you cannot serve; the relevant metric is utilisation.
  • For brand or category-creation spend. Its effect is diffuse and delayed, and forcing it into a monthly CAC will always make it look terrible. Measure it with holdouts or geo-tests (Lesson 8.13), not division.
  • When channel labels are unreliable. CAC by channel is only as good as the attribution behind customers.channel, and every attribution model is an assumption (Lesson 8.12). If the labels are shaky, report blended CAC and be explicit about why.
  • In very low-volume months. Dividing by 2 or 3 customers produces a number that swings wildly and means nothing. This dataset is exactly that case.

The main alternatives are marginal CAC — the cost of the next customer rather than the average one, which is what actually matters when deciding to increase budget (Lesson 8.5) — and contribution-margin-positive spend limits, where you cap spend at the point where marginal profit hits zero rather than targeting a CAC number at all.

In the field

When a CAC figure is challenged, the argument is almost never about the SQL. It is about whether agency fees belong in the numerator, whether reactivated lapsed customers count as "new", and whether a customer who signed up free and paid four months later is acquired in month one or month five. Settle these once, write them into the metric dictionary, and re-derive history under the agreed definition. Teams that renegotiate the definition every quarter cannot see their own trend.

Interview angle

"Our CAC went down — is that good?" is a trap question, and the expected answer is "it depends on why". CAC falls for good reasons (better creative, better targeting) and bad ones (spend cut, so only the cheapest brand traffic remains; mix shifted to organic; a promotion pulled in bargain-hunters who will never repeat). Strong candidates immediately ask to see paid CAC by channel alongside spend volume, and check whether the customers acquired at the lower CAC have the same early repeat behaviour — because cheaper customers are frequently worse ones.

Exercise 8.3

Advertising The media team wants a single monthly view: total paid spend, total new customers from paid channels, blended paid CAC, and the cost per click implied by the same spend. Build it, and note one reason the two cost metrics can move in opposite directions.

Show solution
sqlpaid_cac_monthly.sql
WITH spend AS (
  SELECT strftime(spend_date, '%Y-%m') AS mth,
         SUM(cost)                     AS cost,
         SUM(clicks)                   AS clicks
  FROM ad_spend
  GROUP BY strftime(spend_date, '%Y-%m')
),
paid_new AS (
  SELECT strftime(signup_date, '%Y-%m') AS mth,
         COUNT(*)                       AS new_customers
  FROM customers
  WHERE channel IN ('paid_search', 'paid_social')
  GROUP BY strftime(signup_date, '%Y-%m')
)
SELECT s.mth,
       ROUND(s.cost, 2)                                        AS paid_spend,
       COALESCE(p.new_customers, 0)                            AS paid_new_customers,
       ROUND(s.cost / NULLIF(p.new_customers, 0), 2)           AS paid_cac,
       ROUND(s.cost / NULLIF(s.clicks, 0), 2)                  AS cost_per_click
FROM spend AS s
LEFT JOIN paid_new AS p ON p.mth = s.mth
ORDER BY s.mth;

Why they diverge. Cost per click measures what the auction charges; CAC measures what a customer costs. They separate whenever conversion rate changes. Cheaper clicks from broader targeting can raise CAC, because the extra clicks convert worse — you paid less per click and more per customer. That divergence is one of the most reliable early signals that a channel is being scaled past its efficient frontier.

With six months of spend and a handful of customers per month here, treat the numbers as a demonstration of shape only. A real read on CAC efficiency needs enough signups per month that a one-customer difference does not move the metric.

Knowledge check

Blended CAC improved from £60 to £48 while paid CAC rose from £70 to £85. What happened?

Key takeaway

CAC = cost ÷ new customers; payback = CAC ÷ monthly gross profit per customer. Always use profit, never revenue, in the denominator of payback. Report paid CAC beside blended CAC, because blended CAC improves whenever organic grows even if every paid channel is deteriorating — and guard every division with NULLIF, because "we spent money and acquired nobody" is a real month you must be able to see.

Lesson 8.4·14 min read

Customer Lifetime Value: Historic vs Predictive

You will be able to compute historic LTV in SQL, build a simple predictive LTV from margin and churn, and explain precisely which parts of the number are measured and which are assumed.

Lifetime value is the most quoted and least understood number in commercial analytics. Quoted, because it is the other half of the CAC equation — you cannot say whether £80 to acquire a customer is sensible without knowing what a customer is worth. Misunderstood, because the word "lifetime" refers to a future that has not happened, and most reported LTVs quietly mix a measurement of the past with an assumption about the future without labelling either.

There are two fundamentally different quantities and they deserve different names.

Historic value versus predictive value

Historic LTVPredictive LTV
What it isProfit a customer has generated so farProfit a customer is expected to generate in total
Epistemic statusA measurementA forecast built on assumptions
Can it be wrong?Only if the SQL is wrongYes, and usually is
Good forSegmenting who has been valuableDeciding what to pay for a new customer
Fatal flawBiased by tenure — old customers look betterDepends on a churn rate you must assume

Inference Most arguments about LTV are two people using the two different quantities. A finance team quoting historic value and a growth team quoting predictive value will never converge, because they are not discussing the same thing.

Analogy

Historic LTV is reading a bank statement; predictive LTV is estimating someone's remaining career earnings. The statement is exact and tells you nothing about tomorrow. The estimate is what you actually need to decide whether to lend — and it rests on assumptions (they keep their job, the industry survives) that you should state out loud, because that is where the whole number lives. Nobody confuses a bank statement with a career forecast; almost everybody confuses the two LTVs.

Historic LTV in SQL

E-commerce Historic value is a straightforward roll-up: gross profit per customer to date, with the tenure that produced it. The tenure column is what stops the number from being misleading.

sqlhistoric_ltv.sql
SELECT c.customer_id,
       c.full_name,
       c.channel,
       c.signup_date,
       COUNT(DISTINCT o.order_id)                                       AS orders,
       ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)), 2)
                                                                        AS net_revenue,
       ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)
                 - oi.quantity * p.unit_cost), 2)                       AS historic_ltv_gp,
       date_diff('day', c.signup_date, MAX(o.order_date))               AS days_observed
FROM customers   AS c
JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
JOIN order_items AS oi ON oi.order_id   = o.order_id
JOIN products    AS p  ON p.product_id  = oi.product_id
GROUP BY c.customer_id, c.full_name, c.channel, c.signup_date
ORDER BY historic_ltv_gp DESC;
Result — top five of thirteen purchasing customers
customer_idfull_namechannelordersnet_revenuehistoric_ltv_gpdays_observed
5Elif Demirpaid_search31162.20675.20197
1Amara Oseipaid_search3805.10476.10370
15Oscar Lindgrenpaid_search2806.05476.0592
10Jolene Piercepaid_search2797.10459.10145
8Hana Kobayashipaid_social2697.30400.30114

Look at days_observed beside the value. Amara has been observed for 370 days and Oscar for 92, yet they have almost identical accumulated profit. Ranking them on the value column alone would put them side by side as equals; ranking them on value per day observed would put Oscar far ahead. Neither ranking is wrong, but only one of them answers "who should we acquire more of?"

Two structural points. First, the measure is gross profit, not revenue: a customer who buys £1,000 of a product costing £950 to make is not a valuable customer. Second, customers who never ordered are absent entirely because of the inner join — and in this dataset customers 16 and 17 are exactly that. Whether they belong in an LTV average is a real decision, not an oversight, and it is the subject of the next pitfall.

Common pitfall — survivorship and tenure bias

Averaging historic LTV across all customers systematically overstates the value of a new one. Two mechanisms are at work. Tenure bias: a customer who signed up two years ago has had two years to spend, and last month's customer has had a month, so the average is dominated by the old. Survivorship: if you compute the average only over customers who ever purchased, you have excluded every acquired-and-never-converted customer whose cost you nonetheless paid. The fix for both: compare LTV at a fixed tenure — value in the first 90 days, say — across cohorts, and be explicit about whether non-purchasers are in the denominator.

Predictive LTV: the simplest defensible model

The standard contractual formula is short enough to derive from scratch. If a customer generates gross profit m per period and has probability r of surviving to the next period, then expected total value is m + m·r + m·r² + ..., a geometric series that sums to:

  LTV = m / (1 - r)          where r = retention rate per period
                             and (1 - r) = churn rate per period

  equivalently:  LTV = m x expected_lifetime
                 expected_lifetime = 1 / churn_rate

So a customer producing £20 gross profit a month with 10% monthly churn has an expected lifetime of 10 months and an LTV of £200. Fact This formula assumes a constant churn rate, a constant margin, and no discounting of future cash. All three assumptions are false in most businesses, and the first is the most damaging.

SaaS With subscription data we can compute both inputs from the same table:

sqlpredictive_ltv_saas.sql
WITH observed AS (
  SELECT plan,
         COUNT(*)                                                        AS subs,
         SUM(CASE WHEN cancelled_on IS NOT NULL THEN 1 ELSE 0 END)       AS cancelled,
         AVG(mrr)                                                        AS avg_mrr,
         -- months each subscription has been observed, floored at 1
         SUM(GREATEST(date_diff('month', started_on,
               COALESCE(cancelled_on, DATE '2025-01-31')), 1))           AS total_months
  FROM subscriptions
  GROUP BY plan
)
SELECT plan,
       subs,
       cancelled,
       ROUND(avg_mrr, 2)                                                 AS avg_mrr,
       total_months,
       -- churn per subscription-month: cancellations divided by months at risk
       ROUND(cancelled * 1.0 / NULLIF(total_months, 0), 4)               AS monthly_churn,
       ROUND(1 / NULLIF(cancelled * 1.0 / NULLIF(total_months, 0), 0), 1)
                                                                         AS expected_life_months,
       -- gross margin assumption stated explicitly, not hidden in the maths
       ROUND(avg_mrr * 0.80
             / NULLIF(cancelled * 1.0 / NULLIF(total_months, 0), 0), 2)  AS ltv_at_80pct_margin
FROM observed
ORDER BY plan;
Result
plansubscancelledavg_mrrtotal_monthsmonthly_churnexpected_life_monthsltv_at_80pct_margin
basic4319.00180.16676.091.20
enterprise20199.00170.0000NULLNULL
pro4049.00380.0000NULLNULL

The NULLs are the most instructive cells in that table. No pro or enterprise subscription has cancelled yet, so the measured churn rate is zero and the implied lifetime is infinite — which NULLIF correctly converts to "undefined" rather than letting a division by zero fail or, worse, letting an infinite LTV reach a slide. Zero observed events does not mean zero risk; it means you have not observed long enough. A model that cannot say "I don't know yet" will eventually say something ridiculous with confidence.

Notice what the query does with the 0.80: it puts the gross-margin assumption on its own line with a comment, and names the output column after it. An LTV column called ltv hides an assumption; a column called ltv_at_80pct_margin forces the reader to see it. Do this everywhere.

Note — churn measured on subscription-months, not subscriptions

Dividing cancellations by the number of subscriptions gives a rate with no time unit, which cannot be inverted into a lifetime. Dividing by subscription-months at risk gives a genuine monthly hazard rate, which can. This distinction — events over exposure, not events over entities — is the foundation of survival analysis and reappears in Lesson 8.8.

With ten subscriptions and three cancellations in this dataset, the resulting churn rate is not a measurement of anything. It is arithmetic on a sample too small to support the inference, and the honest presentation says so beside the number. A real predictive LTV needs hundreds of subscriptions observed over several churn cycles before the rate stabilises.

When NOT to use LTV

  • When the business is too young. If your oldest customer is nine months old, you cannot estimate a three-year lifetime. Truncated LTV — "90-day value" or "12-month value" — is measurable, comparable across cohorts, and honest.
  • When purchases are non-contractual and irregular. Open The m/(1-r) formula assumes you can observe churn, which requires a contract to cancel. For a retailer, nobody announces they have stopped shopping, so churn must be inferred from silence — and models for this (such as the BG/NBD and Pareto/NBD families) rest on distributional assumptions that are actively debated. Do not pretend a retail LTV has the same standing as a subscription one.
  • When the product is genuinely one-off. A mattress or a house purchase has almost no repeat behaviour. Optimise first-order profitability and referral instead.
  • As a per-customer number for operational decisions. LTV is a cohort-level average. Using it to decide how to treat one individual pushes a population statistic into a place it does not fit.

The main alternative is what this course recommends by default: a truncated, cohort-based value curve. Report gross profit per acquired customer at 30, 90, 180 and 365 days by acquisition cohort. It is entirely measured, it is comparable across cohorts, it shows whether newer cohorts are getting better or worse, and it requires no assumption about churn at all.

In the field

The "LTV:CAC ratio should be 3:1" rule circulates widely and is routinely applied without its conditions. Whatever ratio a business targets, the number is only meaningful if the LTV uses gross profit (not revenue), the CAC is fully loaded, both are measured on the same cohort, and the payback period is short enough that the business can finance the gap. A 3:1 ratio with a 30-month payback will run a company out of cash while the dashboard looks healthy. Ask for payback before you ask for the ratio.

Interview angle

Expect "how would you calculate LTV?" and answer in two parts, unprompted. Part one: historic value, computed as gross profit per customer to date, with the tenure bias named. Part two: predictive value as margin / churn, with every assumption stated — constant churn, constant margin, no discount rate. The follow-up is usually "what if churn is not constant?", and the expected answer is that early-life churn is typically much higher than late-life churn, so a single average rate understates the value of survivors and overstates the value of the cohort. Mentioning cohort-level survival curves as the fix is a strong finish.

Exercise 8.4

E-commerce Build a truncated LTV that avoids tenure bias: gross profit generated within 180 days of signup, per acquisition channel, with every acquired customer in the denominator — including those who never purchased. Explain why the denominator choice matters.

Show solution
sqlltv_180d_by_channel.sql
WITH early_profit AS (
  SELECT c.customer_id,
         c.channel,
         SUM(CASE WHEN o.order_date <= c.signup_date + INTERVAL 180 DAY
                  THEN oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)
                       - oi.quantity * p.unit_cost
                  ELSE 0 END)                                       AS gp_180d
  FROM customers   AS c
  LEFT JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id   = o.order_id
  LEFT JOIN products    AS p  ON p.product_id  = oi.product_id
  GROUP BY c.customer_id, c.channel
)
SELECT channel,
       COUNT(*)                                              AS customers_acquired,
       SUM(CASE WHEN gp_180d > 0 THEN 1 ELSE 0 END)          AS customers_who_bought,
       ROUND(SUM(gp_180d), 2)                                AS total_gp_180d,
       ROUND(SUM(gp_180d) / NULLIF(COUNT(*), 0), 2)          AS gp_per_acquired,
       ROUND(SUM(gp_180d)
             / NULLIF(SUM(CASE WHEN gp_180d > 0 THEN 1 ELSE 0 END), 0), 2)
                                                             AS gp_per_buyer
FROM early_profit
GROUP BY channel
ORDER BY gp_per_acquired DESC;

Why the denominator matters. gp_per_buyer answers "how valuable is a customer who buys?"; gp_per_acquired answers "what do we get for a customer we paid to acquire?" Only the second is comparable with CAC, because you paid to acquire the non-buyers too. A channel that delivers a few excellent buyers among many non-buyers looks superb on the first column and can be loss-making on the second. The LEFT JOIN chain is what keeps customers 16 and 17 — signed up, never ordered — in the denominator where they belong.

The 180-day window equalises tenure so cohorts are comparable, but it also means customers who signed up within the last 180 days have not had their full window. In production you would exclude those immature cohorts explicitly rather than let them drag the average down.

Knowledge check

A team reports average historic LTV rising every quarter and concludes customer quality is improving. What is the most likely alternative explanation?

Key takeaway

Historic LTV is a measurement of the past and is biased by tenure. Predictive LTV is a forecast, and m/(1-r) hides three assumptions: constant churn, constant margin, no discounting. Name the two quantities separately, put assumptions in the column name, use gross profit rather than revenue, and when in doubt prefer a truncated cohort value curve — it assumes nothing.

Lesson 8.5·13 min read

ROAS, ROI & Marginal Return

You will be able to compute return on ad spend and return on investment correctly, and to explain why the average return tells you almost nothing about whether to spend the next pound.

A channel returns £4 of revenue for every £1 spent. Should you spend more on it? The instinctive answer is obviously yes, and the instinctive answer is wrong — or at least unsupported. Two separate problems stand between a 4:1 ROAS and a budget decision, and both are structural rather than statistical.

The first is that revenue is not profit. The second, and larger, is that the £4 is an average over all the money already spent, while the decision you are making is about the next pound. Those are different quantities, and in every mature channel they diverge sharply.

Three ratios that get confused

  ROAS  = attributed revenue / ad spend            (a revenue ratio, > 1 means nothing)
  ROI   = (gross profit - spend) / spend           (a profit ratio, > 0 means it paid)
  mROAS = extra revenue / extra spend              (the only one that answers "spend more?")

Inference ROAS is the most reported and the least decision-relevant of the three, because it omits cost of goods entirely. A business with 30% gross margin needs a ROAS above roughly 3.3 merely to break even on the media, before any fixed costs. Reporting ROAS without stating the break-even ROAS implied by your margin is close to reporting nothing.

Advertising The break-even calculation is worth doing once, in public, so everyone stops arguing about whether 4:1 is good:

  break-even ROAS = 1 / gross margin

  gross margin 60%  ->  break-even ROAS 1.67
  gross margin 30%  ->  break-even ROAS 3.33
  gross margin 15%  ->  break-even ROAS 6.67
Analogy

Average return is the average speed of your whole journey; marginal return is your speed right now. If you averaged 60 mph over three hours, that tells you nothing about whether you are currently stuck behind a lorry. Budget decisions are made in the present tense, so they need the speedometer, not the trip computer — and every advertising channel has a lorry in front of it eventually, because the cheap, high-intent audience gets used up first.

ROAS and ROI by channel

Marketing Using the acquisition channel recorded on each customer, we can attach revenue and profit to the channels where we have spend data. Every figure below inherits the assumptions of that channel label — a point Lesson 8.12 takes apart properly.

sqlroas_roi_by_channel.sql
WITH channel_value AS (
  SELECT c.channel,
         COUNT(DISTINCT c.customer_id)                                   AS buyers,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100))  AS net_rev,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)
             - oi.quantity * p.unit_cost)                                AS gross_profit
  FROM customers   AS c
  JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id   = o.order_id
  JOIN products    AS p  ON p.product_id  = oi.product_id
  GROUP BY c.channel
),
channel_spend AS (
  SELECT channel, SUM(cost) AS cost FROM ad_spend GROUP BY channel
)
SELECT v.channel,
       v.buyers,
       ROUND(s.cost, 2)                                                  AS spend,
       ROUND(v.net_rev, 2)                                               AS net_rev,
       ROUND(v.gross_profit, 2)                                          AS gross_profit,
       ROUND(v.net_rev / NULLIF(s.cost, 0), 2)                           AS roas,
       ROUND(100 * v.gross_profit / NULLIF(v.net_rev, 0), 1)             AS margin_pct,
       ROUND(1 / NULLIF(v.gross_profit / NULLIF(v.net_rev, 0), 0), 2)    AS breakeven_roas,
       ROUND((v.gross_profit - s.cost) / NULLIF(s.cost, 0), 2)           AS roi
FROM channel_value AS v
LEFT JOIN channel_spend AS s ON s.channel = v.channel
ORDER BY v.channel;
Result
channelbuyersspendnet_revgross_profitroasmargin_pctbreakeven_roasroi
email2NULL348.00208.00NULL59.81.67NULL
organic3NULL979.10580.10NULL59.21.69NULL
paid_search419730.003570.452086.450.1858.41.71-0.89
paid_social218040.001175.30685.300.0758.31.72-0.96
referral2NULL288.00172.00NULL59.71.67NULL

The breakeven_roas column is the one that changes conversations. At roughly 58–60% gross margin, this business needs about 1.7 in revenue per pound of media before the media pays for itself. Any channel below that line is losing money no matter how healthy its ROAS looks in isolation.

Note — read those numbers as arithmetic, not evidence

The paid channels show catastrophic ROI here because six months of realistic media budget is being divided by the lifetime revenue of a handful of customers, with no other customers in the data to have been influenced. The three unpaid channels show NULL throughout, because ad_spend holds no rows for them — and a NULL ROAS is the correct output when you genuinely do not know the cost, rather than a flattering infinity. What is genuinely transferable is the shape: the columns, the break-even logic, and the NULLIF guards that turn missing spend into NULL rather than a fake infinity.

Diminishing returns and the marginal question

Fact Advertising response curves are concave in practice: as spend on a channel rises, each additional pound reaches a less interested audience, so the incremental return declines. The mechanism is not mysterious — auctions serve the highest-value, highest-intent inventory first, and audiences are finite.

The consequence is that a channel can have an excellent average ROAS and a terrible marginal ROAS at the same time. Average ROAS is dragged upward by all the cheap early spend; it does not fall until the marginal return has been bad for a long time. By then you have wasted a great deal of money.

You can see the beginning of this in the cost of a click as spend scales:

sqlmarginal_cost_per_click.sql
WITH monthly AS (
  SELECT channel,
         strftime(spend_date, '%Y-%m') AS mth,
         SUM(cost)                     AS cost,
         SUM(clicks)                   AS clicks
  FROM ad_spend
  GROUP BY channel, strftime(spend_date, '%Y-%m')
),
deltas AS (
  SELECT channel, mth, cost, clicks,
         cost   - LAG(cost)   OVER (PARTITION BY channel ORDER BY mth) AS d_cost,
         clicks - LAG(clicks) OVER (PARTITION BY channel ORDER BY mth) AS d_clicks
  FROM monthly
)
SELECT channel, mth, cost, clicks,
       ROUND(cost / NULLIF(clicks, 0), 3)         AS average_cpc,
       d_cost,
       d_clicks,
       ROUND(d_cost / NULLIF(d_clicks, 0), 3)     AS marginal_cpc
FROM deltas
ORDER BY channel, mth;
Result — paid_search rows only
channelmthcostclicksaverage_cpcd_costd_clicksmarginal_cpc
paid_search2024-012400.0048000.500NULLNULLNULL
paid_search2024-022650.0052800.502250.004800.521
paid_search2024-032900.0058000.500250.005200.481
paid_search2024-044200.0063000.6671300.005002.600
paid_search2024-054560.0068400.667360.005400.667
paid_search2024-063020.0060400.500-1540.00-8001.925

Look at April. The average cost per click moved from £0.50 to £0.67 — a mild-looking rise. The marginal cost per click was £2.60: the extra £1,300 bought only 500 extra clicks. The average absorbed and disguised the shock. That divergence is the entire argument for tracking marginal return, and it is visible even in six rows.

Two honesty notes on this query. The April jump coincides with the campaign changing from brand_core to nonbrand, so we are comparing different things across the boundary — a confound, not a pure scaling effect. And the June row has negative deltas, where a marginal ratio becomes hard to interpret: dividing a spend cut by a click loss produces a number that looks like a cost but describes a withdrawal. In production you would compute marginal metrics only across increases, within a fixed campaign.

Common pitfall — treating platform-reported ROAS as measured return

The ROAS figure in an ad platform's own dashboard is computed with that platform's attribution rules, its own view-through windows, and its own conversion counting. Every platform claims conversions that other platforms also claim, so the sum of platform-reported revenue routinely exceeds a company's actual revenue. It is not a lie and it is not a measurement of incremental return; it is a self-report under a favourable and self-selected model. Always reconcile platform-reported revenue against the warehouse total before quoting a ROAS to anyone.

When NOT to use ROAS or ROI

  • When deciding budget changes. Use marginal return. Average ROAS answers "was the money already spent worthwhile", which is a question about the past you cannot act on.
  • For long-consideration purchases. Insurance If the decision cycle is months, a 7-day or 28-day attribution window will attribute almost nothing to upper-funnel activity and the ROAS will be structurally understated. The window is a modelling choice that changes the answer.
  • When the channel's job is not immediate conversion. Brand and category-creation spend show poor ROAS by construction. Measuring them this way guarantees they get cut, whether or not cutting them is right.
  • Whenever the underlying question is causal. ROAS is a ratio of two observed quantities. It cannot distinguish revenue the advertising caused from revenue that would have arrived anyway — and for retargeting and brand-keyword search, the second is often large. Lesson 8.13 is about exactly this gap.

The alternatives, in ascending order of rigour: marginal ROAS from budget changes, geo-based holdout tests, and media mix modelling. All three cost more than a division, and all three answer a question that no division can.

Interview angle

"Channel A has 8x ROAS and channel B has 3x. Where do you move budget?" The expected answer is not "A". It is a series of questions: what are the margins on what each channel sells, so what is the break-even ROAS for each; what is the marginal return at current spend, not the average; is A near the top of its response curve while B has room; and how much of A's ROAS is retargeting people who were going to buy anyway. Candidates who move budget to A without asking those are demonstrating exactly the failure the question is designed to detect.

Exercise 8.5

Digital media The finance team wants a monthly picture of whether paid media covered its own cost. Report, per month: total paid spend, gross profit from customers acquired via paid channels in that month, and the profit-to-spend ratio. State one reason this comparison is structurally unfair to the media.

Show solution
sqlpaid_media_payback_monthly.sql
WITH spend AS (
  SELECT strftime(spend_date, '%Y-%m') AS mth, SUM(cost) AS cost
  FROM ad_spend
  GROUP BY strftime(spend_date, '%Y-%m')
),
paid_cohort_profit AS (
  SELECT strftime(c.signup_date, '%Y-%m') AS mth,
         COUNT(DISTINCT c.customer_id)    AS paid_customers,
         SUM(COALESCE(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)
                      - oi.quantity * p.unit_cost, 0)) AS gross_profit
  FROM customers   AS c
  LEFT JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id   = o.order_id
  LEFT JOIN products    AS p  ON p.product_id  = oi.product_id
  WHERE c.channel IN ('paid_search', 'paid_social')
  GROUP BY strftime(c.signup_date, '%Y-%m')
)
SELECT s.mth,
       ROUND(s.cost, 2)                                        AS paid_spend,
       COALESCE(pc.paid_customers, 0)                          AS paid_customers,
       ROUND(COALESCE(pc.gross_profit, 0), 2)                  AS gross_profit_to_date,
       ROUND(COALESCE(pc.gross_profit, 0) / NULLIF(s.cost, 0), 3)
                                                               AS profit_per_pound_spent
FROM spend AS s
LEFT JOIN paid_cohort_profit AS pc ON pc.mth = s.mth
ORDER BY s.mth;

Why it is structurally unfair. The spend is complete for the month, but the profit is not: customers acquired in June have had far less time to generate profit than customers acquired in January, and none of them are finished. Comparing a full cost against a partial return will always make recent months look worse, creating a false impression of decline. The fixes are to compare profit at a fixed tenure (30-day profit against the spend that acquired that cohort), or to compare against predicted rather than realised value while labelling it a prediction.

A second unfairness runs the other way: any influence the media had on customers labelled organic or direct is credited to those channels and not to the media that prompted the search. Both biases exist simultaneously, which is why a single ratio should never be the deciding input to a budget decision.

Knowledge check

A channel's average ROAS is stable at 5.0 while its marginal ROAS over the last three budget increases has fallen from 4.0 to 1.2. Gross margin is 40%. What should you conclude?

Key takeaway

ROAS omits cost of goods, so compare it against break-even ROAS = 1 / gross margin or it means nothing. ROI uses profit and answers whether spend paid off. Neither answers "should we spend more" — only marginal return does, because response curves are concave and the average always lags the margin. And no ratio of observed numbers can separate caused revenue from revenue that would have happened anyway.

Lesson 8.6·14 min read

Cohort Analysis From First Principles

You will be able to build a cohort table in SQL from scratch, and explain why cohorting is the only reliable way to tell whether a business is genuinely improving or merely growing.

Here is a puzzle that cohorting exists to solve. A subscription business reports that the share of customers active in a given month has fallen every month for a year. Management concludes the product is deteriorating. In fact each individual intake of customers behaves slightly better than the one before it. Both statements are true simultaneously.

The explanation is dilution. The company is growing fast, so each month a large group of brand-new customers — who have had the least time to settle in and are most likely to lapse early — joins the denominator. The aggregate falls because the mixture changed, not because anyone's behaviour got worse. Inference Any aggregate over a population whose composition is changing tells you about the composition at least as much as about the behaviour, and usually you cannot tell which.

What a cohort is

A cohort is a group of entities that share a starting event in the same time bucket, tracked forward by time since that event rather than by calendar time. Two ingredients, both essential:

  1. An anchor event. Signup, first purchase, trial start, first login. Everything is measured relative to it.
  2. A relative clock. Month 0, month 1, month 2 — months since the anchor, not January, February, March.

Switching from absolute to relative time is the entire trick. It puts every cohort on the same axis, so a customer's first month is compared with another customer's first month regardless of when either happened.

Analogy

A school reports that average pupil height has fallen this year and worries about nutrition. It has just enrolled a large new reception class. Nothing is wrong with any child; the mixture changed. The fix is to stop asking "how tall is the average pupil?" and start asking "how tall is the average nine-year-old, by intake year?" That is a cohort analysis, and every child is now compared against children at the same point in their own development.

Building a cohort table in three steps

Every cohort query in existence has the same three parts. Learn the skeleton and the variations become trivial.

flowchart LR
  A["1. Assign
each customer to a cohort
(bucket the anchor date)"] --> B["2. Measure
activity with a relative clock
(period_index = date_diff)"] B --> C["3. Normalise
divide by cohort size
so cohorts are comparable"]
Assign, measure, normalise. Step 3 is the one people skip, and skipping it means big cohorts look better than small ones purely because they are big.

E-commerce Here is the skeleton in full, using signup month as the anchor and completed orders as the activity:

sqlcohort_long.sql
WITH cohorts AS (                          -- 1. assign
  SELECT customer_id,
         date_trunc('month', signup_date) AS cohort_month
  FROM customers
),
cohort_size AS (
  SELECT cohort_month, COUNT(*) AS cohort_size
  FROM cohorts
  GROUP BY cohort_month
),
activity AS (                              -- 2. measure on a relative clock
  SELECT ch.cohort_month,
         date_diff('month', ch.cohort_month,
                   date_trunc('month', o.order_date))                 AS month_index,
         COUNT(DISTINCT o.customer_id)                                AS active_customers,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)) AS net_rev
  FROM cohorts     AS ch
  JOIN orders      AS o  ON o.customer_id = ch.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id   = o.order_id
  GROUP BY ch.cohort_month,
           date_diff('month', ch.cohort_month, date_trunc('month', o.order_date))
)
SELECT strftime(cs.cohort_month, '%Y-%m')                             AS cohort_month,
       cs.cohort_size,
       a.month_index,
       a.active_customers,
       ROUND(100.0 * a.active_customers / NULLIF(cs.cohort_size, 0), 1)
                                                                      AS pct_of_cohort,  -- 3. normalise
       ROUND(a.net_rev, 2)                                            AS net_rev,
       ROUND(a.net_rev / NULLIF(cs.cohort_size, 0), 2)                AS rev_per_cohort_member
FROM cohort_size AS cs
JOIN activity    AS a ON a.cohort_month = cs.cohort_month
ORDER BY cs.cohort_month, a.month_index;
Result — first eight rows
cohort_monthcohort_sizemonth_indexactive_customerspct_of_cohortnet_revrev_per_cohort_member
2024-01202100.0591.20295.60
2024-0122150.0219.00109.50
2024-01212150.0278.00139.00
2024-02202100.0617.00308.50
2024-0320150.0254.15127.08
2024-0323150.0358.00179.00
2024-0326150.0550.05275.02
2024-0420150.0219.00109.50

Two things stand out. The March cohort's month 0 is only 50%: one of its two members signed up but did not order in their signup month, which is an acquisition observation, not a retention one — a distinction the anchor choice controls, as the note below explains. And month_index jumps — the January cohort appears at months 0, 2 and 12 with nothing in between. This is the "long" or tidy shape, and it only contains months where something happened. That is fine for storage and wrong for reading, because a missing row and a zero look identical to a human eye scanning a list. Turning it into a grid is the next step.

Common pitfall — missing periods read as zero

Because the join only produces rows for months with activity, an inactive month simply vanishes. Chart that series naively and the line connects month 0 to month 2, implying continuous activity that did not occur. Worse, an average over the rows present is an average over active months only, which silently excludes the zeros and inflates every result. The fix is to generate the full grid of (cohort × period) combinations and LEFT JOIN activity onto it, so absences become explicit zeros.

Pivoting to the grid everyone recognises

The familiar triangular cohort table is a pivot of the long result: one row per cohort, one column per period index. In standard SQL this is a conditional aggregate, and FILTER expresses it most clearly.

sqlcohort_grid.sql
WITH cohorts AS (
  SELECT customer_id, date_trunc('month', signup_date) AS cohort_month
  FROM customers
),
activity AS (
  SELECT ch.cohort_month,
         ch.customer_id,
         date_diff('month', ch.cohort_month, date_trunc('month', o.order_date)) AS mi
  FROM cohorts AS ch
  JOIN orders  AS o ON o.customer_id = ch.customer_id AND o.status = 'completed'
  GROUP BY ch.cohort_month, ch.customer_id,
           date_diff('month', ch.cohort_month, date_trunc('month', o.order_date))
),
sized AS (
  SELECT cohort_month, COUNT(*) AS cohort_size FROM cohorts GROUP BY cohort_month
)
SELECT strftime(s.cohort_month, '%Y-%m')                          AS cohort,
       s.cohort_size                                              AS n,
       COUNT(DISTINCT a.customer_id) FILTER (WHERE a.mi = 0)      AS m0,
       COUNT(DISTINCT a.customer_id) FILTER (WHERE a.mi = 1)      AS m1,
       COUNT(DISTINCT a.customer_id) FILTER (WHERE a.mi = 2)      AS m2,
       COUNT(DISTINCT a.customer_id) FILTER (WHERE a.mi = 3)      AS m3,
       COUNT(DISTINCT a.customer_id) FILTER (WHERE a.mi BETWEEN 4 AND 6) AS m4_6
FROM sized AS s
LEFT JOIN activity AS a ON a.cohort_month = s.cohort_month
GROUP BY s.cohort_month, s.cohort_size
ORDER BY s.cohort_month;

The LEFT JOIN from sized is doing real work: it keeps cohorts that never produced a single order — October and November 2024 in this dataset, whose only members never purchased — visible as rows of zeros. An inner join would delete them, and deleting your worst cohorts is an excellent way to conclude that everything is fine.

Note — anchor choice changes the answer

Anchoring on signup measures acquisition-to-purchase conversion as well as retention: a cohort with poor month-0 numbers had a lead-generation problem, not a retention problem. Anchoring on first purchase makes month 0 always 100% and isolates repeat behaviour. Neither is correct in general — but a table that does not state which anchor it used cannot be interpreted at all, and the two are routinely confused in the same meeting.

When NOT to cohort

  • When cohorts are too small. With two customers per monthly cohort, as here, each customer is 50 percentage points. Any pattern you see is one person changing their mind. Widen the buckets to quarters, or do not cohort at all.
  • When there is no meaningful repeat behaviour. Manufacturing For capital equipment bought once a decade, a monthly cohort grid is empty by construction.
  • When the anchor event is unreliable. If signup dates were backfilled during a migration, every cohort before the migration is fiction. Check the anchor's provenance before trusting the grid.
  • When the question is about a single moment. "How many people used the product yesterday" needs a count, not a cohort. Cohorts answer questions about change over the life of a relationship.

The alternatives are segment comparisons at a fixed point in time (simpler, but vulnerable to the composition problem cohorting solves) and survival analysis (more powerful, and correctly handles customers whose observation is incomplete — the subject of Lesson 8.7's honest caveats).

In the field

The most common review comment on a cohort table is "the bottom-right corner looks terrible". It usually is not. The newest cohorts have only lived a few periods, so their later columns are empty rather than bad, and the most recent period is often partial. Grey out or omit any cell where the cohort has not yet had the full period to act. Analysts who do not do this spend a lot of time explaining that the business is not collapsing.

Interview angle

"Write a cohort retention query" is one of the most common SQL screens for analytics roles. The interviewer is checking four things: that you bucket the anchor date rather than joining on raw dates; that you compute a relative period index with a date-difference function; that you divide by cohort size; and that you use COUNT(DISTINCT customer_id) so a customer with three orders in a month counts once. Candidates who produce the long form and then mention that a pivot with FILTER or CASE gives the readable grid are signalling that they have built one for real.

Exercise 8.6

Product analytics Build a cohort table anchored on first purchase rather than signup, showing cumulative net revenue per cohort member at month 0, by month 3, and by month 6. Explain why cumulative is the right shape here and what the anchor change does to month 0.

Show solution
sqlcumulative_value_cohort.sql
WITH first_order AS (
  SELECT customer_id,
         date_trunc('month', MIN(order_date)) AS cohort_month,
         MIN(order_date)                      AS first_order_date
  FROM orders
  WHERE status = 'completed'
  GROUP BY customer_id
),
order_value AS (
  SELECT f.customer_id,
         f.cohort_month,
         date_diff('month', f.cohort_month, date_trunc('month', o.order_date)) AS mi,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100))         AS net_rev
  FROM first_order AS f
  JOIN orders      AS o  ON o.customer_id = f.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id   = o.order_id
  GROUP BY f.customer_id, f.cohort_month,
           date_diff('month', f.cohort_month, date_trunc('month', o.order_date))
),
sized AS (
  SELECT cohort_month, COUNT(*) AS n FROM first_order GROUP BY cohort_month
)
SELECT strftime(s.cohort_month, '%Y-%m')                                    AS cohort,
       s.n,
       ROUND(SUM(v.net_rev) FILTER (WHERE v.mi = 0) / NULLIF(s.n, 0), 2)    AS rev_pc_m0,
       ROUND(SUM(v.net_rev) FILTER (WHERE v.mi <= 3) / NULLIF(s.n, 0), 2)   AS rev_pc_by_m3,
       ROUND(SUM(v.net_rev) FILTER (WHERE v.mi <= 6) / NULLIF(s.n, 0), 2)   AS rev_pc_by_m6
FROM sized AS s
LEFT JOIN order_value AS v ON v.cohort_month = s.cohort_month
GROUP BY s.cohort_month, s.n
ORDER BY s.cohort_month;

Why cumulative. Value accumulates, so the business question is "how much has a customer from this cohort produced by month N?" A per-period series answers a different question and is far noisier, because a customer buying in month 4 rather than month 3 creates a swing that means nothing. Cumulative curves are also what you compare against CAC to read payback directly off the table.

What the anchor change does. Anchoring on first purchase forces month 0 to include every cohort member by construction, so rev_pc_m0 is the average first-order value and every later column is pure repeat behaviour. Anchoring on signup would have mixed in the question of whether acquired customers convert at all. Note also that the <= comparisons inside FILTER make the columns cumulative without a window function — and that the newest cohorts cannot have a meaningful by_m6 figure, so those cells should be suppressed rather than read.

Knowledge check

Overall monthly active rate is falling, but every individual cohort's retention curve is improving. What is the most likely cause?

Key takeaway

A cohort is a group sharing a start event, tracked on a relative clock rather than a calendar. Every cohort query is assign, measure, normalise. Divide by cohort size or big cohorts win by being big; LEFT JOIN from the cohort list so empty cohorts stay visible; and never read the bottom-right corner of the grid, because those cells are incomplete rather than bad.

Lesson 8.7·13 min read

Retention Curves & the Retention Table

You will be able to read the shape of a retention curve as a statement about the business, and to distinguish the three retention definitions that produce three different tables from the same data.

Retention is the closest thing analytics has to a verdict on whether a product is worth building. Acquisition can be bought. Conversion can be optimised. Retention has to be earned, and a business with weak retention is filling a leaking bucket regardless of how good its marketing is.

The retention curve — percentage of a cohort still active, plotted against time since the anchor — is therefore the most examined chart in most companies. Its shape matters more than any individual point on it.

Reading the shape

  100 |*
      | *                      A: flattening  ->  a stable core exists
   %  |  *___________________     the business has a real product
      |
  ----+----------------------
  100 |*
      | *                      B: decaying to zero  ->  no core
   %  |  **                       every customer eventually leaves
      |    ***_
  ----+---***---**___-------
  100 |*
      | *   ____              C: smiling  ->  lapsed users return
   %  |  *_/                     seasonal or occasion-driven demand
      |
  ----+----------------------

Inference The single most important question about a retention curve is whether it flattens. A curve that flattens at 30% says a stable third of every intake keeps coming back — you have found a group for whom the product works, and growth compounds. A curve that decays towards zero says the product has no such group, and every pound of acquisition must be spent again. Two curves can look similar for three months and differ completely in what they imply.

Analogy

A new gym signs up 100 people in January. By March half have stopped coming; by June only 30 are left — and those same 30 are still there in December, and the following December. That flattening is the gym's real business: 30 people who genuinely want to exercise. The 70 were revenue, not customers. Every retention curve is asking the same question — how many of the people you acquired actually wanted the thing?

Three definitions of "retained"

The same raw data produces three different tables depending on what "still here in period N" means. Choosing without knowing you are choosing is a reliable source of confusion.

DefinitionRetained in period N meansSuitsBehaviour
Classic / N-dayActive specifically in period NHabitual daily productsStrictest; lowest numbers
Rolling / unboundedActive in period N or any later periodIrregular usageMonotonically decreasing; forgiving
Bracket / rangeActive anywhere in a window, e.g. days 7–14Weekly or intermittent productsSmooths spiky usage

For an occasional purchase, classic retention understates badly: a customer who buys every four months is "unretained" in three months out of four despite being perfectly loyal. That is not a small measurement difference; it can be the difference between a curve that decays to zero and one that flattens.

Building the retention table properly

The critical structural addition over Lesson 8.6 is the complete grid. Generate every (cohort, period) pair that could exist, then LEFT JOIN activity onto it. Absent months become explicit zeros instead of vanishing.

E-commerce

sqlretention_table.sql
WITH cohorts AS (
  SELECT customer_id, date_trunc('month', signup_date) AS cohort_month
  FROM customers
),
sized AS (
  SELECT cohort_month, COUNT(*) AS cohort_size FROM cohorts GROUP BY cohort_month
),
periods AS (                                  -- 0..6 months since anchor, for every cohort
  SELECT s.cohort_month, s.cohort_size, p.month_index
  FROM sized AS s
  CROSS JOIN (SELECT UNNEST(range(0, 7)) AS month_index) AS p
),
active AS (
  SELECT ch.cohort_month,
         date_diff('month', ch.cohort_month, date_trunc('month', o.order_date)) AS month_index,
         COUNT(DISTINCT o.customer_id) AS active_customers
  FROM cohorts AS ch
  JOIN orders  AS o ON o.customer_id = ch.customer_id AND o.status = 'completed'
  GROUP BY ch.cohort_month,
           date_diff('month', ch.cohort_month, date_trunc('month', o.order_date))
)
SELECT strftime(p.cohort_month, '%Y-%m')                              AS cohort,
       p.cohort_size,
       p.month_index,
       COALESCE(a.active_customers, 0)                                AS active,
       ROUND(100.0 * COALESCE(a.active_customers, 0)
             / NULLIF(p.cohort_size, 0), 1)                           AS retention_pct
FROM periods AS p
LEFT JOIN active AS a
       ON a.cohort_month = p.cohort_month AND a.month_index = p.month_index
WHERE p.cohort_month <= DATE '2024-06-01'
ORDER BY p.cohort_month, p.month_index;
Result — 2024-01 and 2024-02 cohorts
cohortcohort_sizemonth_indexactiveretention_pct
2024-01202100.0
2024-012100.0
2024-0122150.0
2024-012300.0
2024-012400.0
2024-012500.0
2024-012600.0
2024-02202100.0
2024-022100.0
2024-022200.0
2024-022300.0
2024-022400.0
2024-022500.0
2024-022600.0

Those zeros are the point of the whole exercise. Without the generated grid, months 1, 4, 5 and 6 would simply not appear for the January cohort, and any chart would join month 0 straight to month 2. The zeros are also what reveals that classic retention is the wrong definition for this business: a customer who buys twice a year oscillates between 100% and 0% and never shows a flattening curve, because the measurement period is shorter than the purchase cycle.

Note — range() and UNNEST

range(0, 7) produces a list of integers 0 to 6 (upper bound exclusive), and UNNEST expands the list into rows. Other engines spell this differently — generate_series in PostgreSQL and DuckDB (upper bound inclusive), UNNEST(GENERATE_ARRAY(...)) in BigQuery, a numbers table in older systems — but the pattern is identical everywhere: manufacture the axis, then LEFT JOIN the data onto it. It is the standard fix for every "missing rows in a time series" problem, not just retention.

Rolling retention, and why it is kinder

Rolling retention asks whether the customer was active in period N or any period after it. This is far better suited to irregular purchasing, and it has a useful property: it is monotonically non-increasing, so the curve cannot bounce around from the noise of a customer skipping one month.

sqlrolling_retention.sql
WITH cohorts AS (
  SELECT customer_id, date_trunc('month', signup_date) AS cohort_month
  FROM customers
),
last_activity AS (              -- how many months after signup did each customer last buy?
  SELECT ch.cohort_month,
         ch.customer_id,
         MAX(date_diff('month', ch.cohort_month, date_trunc('month', o.order_date)))
                                                                     AS last_month_index
  FROM cohorts AS ch
  LEFT JOIN orders AS o
         ON o.customer_id = ch.customer_id AND o.status = 'completed'
  GROUP BY ch.cohort_month, ch.customer_id
),
sized AS (
  SELECT cohort_month, COUNT(*) AS cohort_size FROM last_activity GROUP BY cohort_month
),
grid AS (
  SELECT s.cohort_month, s.cohort_size, g.month_index
  FROM sized AS s
  CROSS JOIN (SELECT UNNEST(range(0, 7)) AS month_index) AS g
)
SELECT strftime(g.cohort_month, '%Y-%m')                             AS cohort,
       g.cohort_size,
       g.month_index,
       COUNT(la.customer_id) FILTER (WHERE la.last_month_index >= g.month_index)
                                                                     AS still_alive,
       ROUND(100.0 * COUNT(la.customer_id)
             FILTER (WHERE la.last_month_index >= g.month_index)
             / NULLIF(g.cohort_size, 0), 1)                          AS rolling_retention_pct
FROM grid AS g
LEFT JOIN last_activity AS la ON la.cohort_month = g.cohort_month
WHERE g.cohort_month <= DATE '2024-03-01'
GROUP BY g.cohort_month, g.cohort_size, g.month_index
ORDER BY g.cohort_month, g.month_index;

The LEFT JOIN to orders inside last_activity is deliberate: it keeps never-purchasing customers with a NULL last index, so they are counted in the cohort size but never counted as alive. Dropping them would inflate every percentage in the table.

Common pitfall — right-censoring

Rolling retention has a subtle and serious flaw: it asks whether a customer was ever active later, which for recent cohorts we cannot yet know. A customer who signed up last month has had no opportunity to return, so their rolling retention is understated by construction, and recent cohorts look worse than old ones for purely mechanical reasons. This is right-censoring, and it is the reason survival analysis exists. The minimum defence is to only compare cohorts that have had equal observation time, and to suppress every cell where the cohort has not yet lived that long.

When NOT to use retention curves

  • When the purchase cycle is longer than the period grain. Monthly retention for an annual product measures the calendar, not loyalty. Match the grain to the natural cycle.
  • When "active" is not defined by a meaningful action. Telecommunications Retention measured by "account still open" counts people who forgot to cancel. Measure a real action, or you are tracking inertia and calling it loyalty.
  • For genuinely transactional or one-off products. Some purchases are not meant to repeat. Referral and satisfaction are the relevant measures.
  • When cohorts are small. With two customers per cohort, as here, the curve is a coin flip rendered as a percentage. Widen to quarters or accept that the exercise is illustrative.

The main alternative is proper survival analysis, which handles censoring correctly by treating incompletely observed customers as still at risk rather than as failures. If you find yourself apologising for the newest cohorts in every review, that is the signal to move to it.

Interview angle

A strong follow-up to the retention SQL question is "your day-30 retention is 20% — is that good?" There is no context-free answer, and inventing an industry benchmark is the wrong move. The expected response: it depends on the natural usage frequency, on how it compares with your own earlier cohorts, and above all on whether the curve flattens after day 30. A product at 20% that is flat from day 30 to day 180 is healthier than one at 35% still falling steeply. Comparison with your own past beats comparison with a number from the internet.

Exercise 8.7

Gaming Using the players and daily_logins tables, compute for each player their days since signup at last login, and classify them as active_30d, lapsed or never_returned. Explain why this classification is a retention measure and what it hides.

Show solution
sqlplayer_lapse_status.sql
WITH reference AS (
  SELECT MAX(last_login) AS as_of FROM players     -- treat the newest login as "today"
),
player_state AS (
  SELECT p.player_id,
         p.username,
         p.signup_date,
         p.last_login,
         date_diff('day', p.signup_date, p.last_login) AS days_alive,
         date_diff('day', p.last_login, r.as_of)       AS days_since_seen,
         COUNT(dl.player_id)                           AS logins_recorded
  FROM players AS p
  CROSS JOIN reference AS r
  LEFT JOIN daily_logins AS dl ON dl.player_id = p.player_id
  GROUP BY p.player_id, p.username, p.signup_date, p.last_login, r.as_of
)
SELECT username,
       days_alive,
       days_since_seen,
       logins_recorded,
       CASE WHEN days_since_seen <= 30                THEN 'active_30d'
            WHEN days_alive     <= 1                  THEN 'never_returned'
            ELSE                                           'lapsed'
       END AS retention_state
FROM player_state
ORDER BY days_since_seen;

Why it is a retention measure. It is classic retention collapsed to a single snapshot: "was this player active within the last 30 days" is the day-30 retention question asked of everyone at once rather than cohort by cohort.

What it hides. Three things. It mixes cohorts, so a fall in the active share could be composition rather than behaviour — the exact problem Lesson 8.6 solved. It is a snapshot, so it cannot show whether the curve flattens; a lapsed player and a player who left on day two are in the same bucket. And it inherits right-censoring: a player who signed up a week ago cannot yet be lapsed, so recent signups are structurally over-represented in active_30d. Snapshots are useful for operations — who to email — and misleading as a trend.

Knowledge check

A retailer whose customers typically buy every three months reports monthly classic retention that oscillates wildly and averages near 15%. What is the best response?

Key takeaway

Read the shape, not the point: a curve that flattens means a stable core exists; one decaying to zero means it does not. Generate the full period grid and LEFT JOIN onto it so inactive months appear as zeros. Match the grain to the natural purchase cycle, state whether you are using classic, rolling or bracket retention, and remember that recent cohorts are right-censored, not failing.

Lesson 8.8·14 min read

Churn: Definitions, Measurement & Traps

You will be able to compute customer churn and revenue churn correctly, choose a defensible denominator, and explain why the two can point in opposite directions.

Churn looks like the simplest metric in this part: customers who left, divided by customers you had. In practice it produces more disputed numbers than anything else on a dashboard, because both the numerator and the denominator have several defensible definitions, and because the word covers two genuinely different quantities that routinely disagree in sign.

Customer churn and revenue churn are different things

  customer churn = customers lost in period / customers at start of period
  revenue churn  = MRR lost in period       / MRR at start of period
  net revenue churn = (MRR lost - expansion MRR) / MRR at start

SaaS Suppose you lose 20 small accounts and one large account upgrades. Customer churn is high; revenue churn may be zero or negative. Which is "the" churn rate? Both, and reporting only one is how a business convinces itself things are fine while its customer base erodes underneath a few large accounts — or panics about revenue while its actual product-market fit is improving.

Inference Net revenue churn below zero (net revenue retention above 100%) means existing customers alone grow revenue, which is a genuinely different business model from one that must acquire to grow. It also hides customer churn completely, which is why the two must always be reported side by side.

Analogy

A theatre counts empty seats; the finance director counts lost ticket revenue. Lose fifty concession tickets and sell one extra box, and the two counts tell opposite stories about the same night. Neither is wrong. A theatre that only watches revenue will not notice its audience quietly disappearing until the boxes stop selling too.

The denominator problem

"Customers you had" is ambiguous, and each option gives a different rate from the same events.

DenominatorEffectNotes
Active at start of periodMost common; excludes anyone who joined and left within the periodUnderstates churn during fast growth
Average of start and endSmooths growth distortionHarder to explain; a compromise
All customers at risk during the periodIncludes joiners; most completeRequires exposure tracking
Customer-months at riskA true hazard rate that can be inverted into a lifetimeThe technically correct choice

The last one deserves emphasis. Dividing events by entities gives a rate with no time unit, which cannot be turned into an expected lifetime. Dividing events by exposure — customer-months during which a cancellation could have happened — gives a hazard rate, which can. That was the basis of the predictive LTV in Lesson 8.4, and it is the difference between a number you can reason with and one you can only quote.

Monthly churn from subscription data

The mechanics: build a month axis, count who was active at the start of each month, and count who cancelled during it.

sqlmonthly_churn.sql
WITH months AS (
  SELECT DATE '2024-01-01' + INTERVAL (m) MONTH AS month_start
  FROM (SELECT UNNEST(range(0, 13)) AS m) AS t
),
active_start AS (
  SELECT m.month_start,
         COUNT(*)   AS active_at_start,
         SUM(s.mrr) AS mrr_at_start
  FROM months AS m
  JOIN subscriptions AS s
    ON s.started_on < m.month_start
   AND (s.cancelled_on IS NULL OR s.cancelled_on >= m.month_start)
  GROUP BY m.month_start
),
churned AS (
  SELECT m.month_start,
         COUNT(*)   AS churned_customers,
         SUM(s.mrr) AS churned_mrr
  FROM months AS m
  JOIN subscriptions AS s
    ON s.cancelled_on >= m.month_start
   AND s.cancelled_on <  m.month_start + INTERVAL 1 MONTH
  GROUP BY m.month_start
)
SELECT strftime(a.month_start, '%Y-%m')                                    AS mth,
       a.active_at_start,
       ROUND(a.mrr_at_start, 2)                                            AS mrr_at_start,
       COALESCE(c.churned_customers, 0)                                    AS churned_customers,
       ROUND(COALESCE(c.churned_mrr, 0), 2)                                AS churned_mrr,
       ROUND(100.0 * COALESCE(c.churned_customers, 0)
             / NULLIF(a.active_at_start, 0), 1)                            AS customer_churn_pct,
       ROUND(100.0 * COALESCE(c.churned_mrr, 0)
             / NULLIF(a.mrr_at_start, 0), 1)                               AS revenue_churn_pct
FROM active_start AS a
LEFT JOIN churned AS c ON c.month_start = a.month_start
ORDER BY a.month_start;
Result — 2024-04 onwards
mthactive_at_startmrr_at_startchurned_customerschurned_mrrcustomer_churn_pctrevenue_churn_pct
2024-045335.0000.000.00.0
2024-056384.00119.0016.74.9
2024-066384.0000.000.00.0
2024-077583.00119.0014.33.3
2024-087613.0000.000.00.0
2024-108632.0000.000.00.0
2024-118632.00119.0012.53.0
2024-127613.0000.000.00.0

The two churn columns differ by a factor of three to five in every month with a cancellation, and always in the same direction: the accounts leaving are the cheapest ones. A board seeing only revenue_churn_pct would consider this business stable. A board seeing only customer_churn_pct would consider it in trouble. Both readings come from the same three cancellations.

Note — the boundary conditions

Two comparison choices in that query are doing quiet work. started_on < m.month_start means a subscription that begins mid-month is not counted as active at the start — it was not there. And the churn window uses >= month_start AND < month_start + 1 month, the half-open interval, so a cancellation on the first of the month lands in exactly one bucket. Using BETWEEN with two month-ends double-counts boundary events, and boundary bugs in churn are famously hard to spot because they move the number by a plausible-looking amount.

Non-contractual churn: inferring departure from silence

Subscriptions announce their own death. Retail customers do not. Retail Nobody sends an email saying they have stopped shopping, so churn has to be inferred from absence — and the threshold you pick creates the number.

sqlinferred_churn.sql
WITH as_of AS (SELECT MAX(order_date) AS today FROM orders),
last_seen AS (
  SELECT c.customer_id,
         c.channel,
         MAX(o.order_date)                                        AS last_order,
         COUNT(o.order_id)                                        AS orders
  FROM customers AS c
  LEFT JOIN orders AS o ON o.customer_id = c.customer_id AND o.status = 'completed'
  GROUP BY c.customer_id, c.channel
)
SELECT CASE
         WHEN ls.last_order IS NULL                                       THEN 'never_purchased'
         WHEN date_diff('day', ls.last_order, a.today) <= 90              THEN 'active'
         WHEN date_diff('day', ls.last_order, a.today) <= 180             THEN 'at_risk'
         ELSE                                                                  'churned'
       END                                                                AS status_90d_rule,
       COUNT(*)                                                           AS customers,
       ROUND(100.0 * COUNT(*) / NULLIF(SUM(COUNT(*)) OVER (), 0), 1)      AS pct_of_base
FROM last_seen AS ls
CROSS JOIN as_of AS a
GROUP BY 1
ORDER BY customers DESC;
Result
status_90d_rulecustomerspct_of_base
churned635.3
never_purchased423.5
active423.5
at_risk317.6

The never_purchased bucket holds four customers, not the two who have no orders at all: two more appear because their only orders were cancelled, and the join condition counts completed orders only. That is a defensible choice and a consequential one — a customer who tried to buy and had the order cancelled is commercially very different from one who never tried, and collapsing them loses a signal worth acting on.

Change 90 to 120 and the composition shifts immediately. Nothing about any customer changed; you changed a threshold. This is the defining property of non-contractual churn and the reason it should always be reported with its rule attached in the column name, exactly as above.

Common pitfall — churn measured on a growing base

If you double your customer base in a month, this month's churn rate looks excellent: the denominator exploded, and the new customers have not had time to leave. The rate then deteriorates for several months as those customers reach their natural decision points, and a team can spend that period searching for a cause that is really just arithmetic catching up. Inference Churn rates from fast-growing periods are not comparable with churn rates from stable periods, and cohort-based churn — churn within each intake, on a relative clock — is the only version that is comparable across time.

Three more traps worth knowing

  • Involuntary churn counted as voluntary. A large share of subscription cancellations in card-billed businesses are failed payments, not decisions. Mixing them means product teams try to fix a payments problem with feature work. Split them.
  • Reactivation ignored. If a customer returns after being marked churned, does your rate reverse? Most implementations quietly count them as a new acquisition, which flatters both churn and acquisition simultaneously.
  • Annualising a monthly rate by multiplying by twelve. Churn compounds on a shrinking base. The correct conversion is 1 - (1 - monthly)^12. Multiplying overstates, and at high monthly rates it can exceed 100%, which is a useful sign you have done it wrong.

When NOT to use churn

  • In non-contractual businesses where you have a better option. Retention curves and repeat-purchase rates (Lesson 8.10) describe the same behaviour without inventing a departure event.
  • At small scale. Three cancellations, as here, produce a rate that swings by double-digit percentage points on a single customer's decision. State the count alongside the rate always; at low counts, lead with the count.
  • As a single company-level number. Churn is almost always heavily segmented — by plan, tenure, acquisition channel and country. A blended rate averages populations with genuinely different hazards and hides every actionable pattern.
  • When you need to predict, not describe. A churn rate is backward-looking. Predicting which individual customer will churn is a classification problem, not an aggregation, and SQL is the wrong instrument for it.
In the field

Churn is the metric most likely to be quietly redefined under pressure. Pauses excluded, downgrades not counted as churn, annual contracts churning only at renewal, involuntary churn moved to a separate line: each change is individually defensible and each one lowers the headline. The discipline that protects you is version control on the definition — when it changes, restate history under the new rule and show both series for a quarter. Teams that do not restate history cannot tell improvement from redefinition.

Interview angle

"Churn improved from 5% to 4% — what do you check first?" The expected answer is the denominator, then the definition, then the mix — before any product explanation. Did the base grow, mechanically diluting the rate? Did the definition change? Did a batch of annual contracts move their renewal dates out of the window? Did the mix shift towards a lower-churn segment while every segment individually got worse? Candidates who immediately credit a recent product launch are showing exactly the reasoning the question is designed to expose.

Exercise 8.8

SaaS For each plan, compute subscriptions started, subscriptions cancelled, total subscription-months at risk, and the monthly hazard rate. Then convert the hazard to an annualised churn rate correctly. Explain why the annualisation is not a multiplication.

Show solution
sqlhazard_and_annualised.sql
WITH exposure AS (
  SELECT plan,
         COUNT(*)                                                       AS subs,
         SUM(CASE WHEN cancelled_on IS NOT NULL THEN 1 ELSE 0 END)      AS cancelled,
         SUM(GREATEST(date_diff('month', started_on,
               COALESCE(cancelled_on, DATE '2025-01-31')), 1))          AS months_at_risk
  FROM subscriptions
  GROUP BY plan
)
SELECT plan,
       subs,
       cancelled,
       months_at_risk,
       ROUND(cancelled * 1.0 / NULLIF(months_at_risk, 0), 4)            AS monthly_hazard,
       ROUND(100 * (1 - POWER(1 - cancelled * 1.0
                              / NULLIF(months_at_risk, 0), 12)), 1)     AS annualised_churn_pct,
       ROUND(100 * LEAST(12.0 * cancelled / NULLIF(months_at_risk, 0), 1), 1)
                                                                        AS naive_x12_pct
FROM exposure
ORDER BY plan;

Why not multiplication. Churn applies to whoever is still present. After month one, only (1 - h) of the cohort remains, so month two's departures come from a smaller base. Survival over twelve months is (1 - h)^12, making annual churn 1 - (1 - h)^12. Multiplying by twelve assumes each month's departures are drawn from the original base, which double-counts people who already left — and at a monthly hazard above about 8% the naive figure exceeds 100%, an impossible result that reveals the error.

With three cancellations across ten subscriptions, these rates are arithmetic rather than evidence. The pro and enterprise plans have zero observed cancellations, giving a hazard of exactly zero — which does not mean those plans never churn, only that we have not yet observed it. A defensible estimate would need many more subscriptions observed across several renewal cycles, and would report an interval rather than a point.

Knowledge check

Customer churn rose from 3% to 6% while net revenue churn fell from 2% to −1%. What is the most likely explanation?

Key takeaway

Report customer churn and revenue churn together — they routinely disagree, and the disagreement is the insight. State your denominator; prefer events over exposure (customer-months at risk) because only a hazard rate can be inverted into a lifetime. In non-contractual businesses churn is a threshold you chose, so put the rule in the column name. And never annualise by multiplying by twelve.

Lesson 8.9·13 min read

Funnels & Conversion Rate Analysis

You will be able to build a step funnel from raw event data, compute step-to-step and overall conversion correctly, and avoid the ordering and denominator errors that make most funnel charts wrong.

A funnel is a sequence of steps that people must pass through to reach an outcome, measured as the count surviving each step. It is the standard instrument for locating where demand is lost, and its appeal is obvious: one chart, one obvious worst step, one place to send the design team.

That appeal is also its danger. Funnels compress a messy, non-linear reality into a tidy staircase, and almost every methodological mistake in funnel analysis comes from taking the staircase literally.

Two numbers, routinely confused

  1000 sessions
   620 viewed an item      step conv 62%   overall 62%
   210 added to cart       step conv 34%   overall 21%
   150 began checkout      step conv 71%   overall 15%
    95 purchased           step conv 63%   overall 9.5%

Step conversion divides by the previous step; overall conversion divides by the top of the funnel. The worst step here is view-to-cart at 34%, but the biggest absolute loss is at the very top: 380 sessions never viewed anything at all. Inference Optimising the worst percentage is not always optimising the biggest opportunity — a 5-point gain on a step carrying 1,000 people beats a 20-point gain on a step carrying 100. Always look at counts lost, not only rates.

Analogy

A funnel is a set of doors in a building, and you are counting how many people get through each. The count tells you which door is stuck. It does not tell you whether people wanted to be in that room, whether they left through a window, or whether they came back the next day and walked straight through. Everyone who walks out is recorded identically as a failure, including the person who realised in the corridor that they were in the wrong building.

Building a funnel from events

Product analytics The events table records view_item, add_to_cart, begin_checkout and purchase against a session. The safest construction collapses events to one row per session with a flag per step, then counts the flags. Doing it this way avoids double-counting sessions that fired the same event twice.

sqlsession_funnel.sql
WITH flags AS (
  SELECT s.session_id,
         MAX(CASE WHEN e.event_name = 'view_item'      THEN 1 ELSE 0 END) AS viewed,
         MAX(CASE WHEN e.event_name = 'add_to_cart'    THEN 1 ELSE 0 END) AS carted,
         MAX(CASE WHEN e.event_name = 'begin_checkout' THEN 1 ELSE 0 END) AS checkout,
         MAX(CASE WHEN e.event_name = 'purchase'       THEN 1 ELSE 0 END) AS purchased
  FROM sessions AS s
  LEFT JOIN events AS e ON e.session_id = s.session_id
  GROUP BY s.session_id
),
funnel AS (
  SELECT 1 AS step_no, 'sessions'       AS step, CAST(COUNT(*)        AS BIGINT) AS n FROM flags
  UNION ALL
  SELECT 2, 'view_item',      CAST(SUM(viewed)    AS BIGINT) FROM flags
  UNION ALL
  SELECT 3, 'add_to_cart',    CAST(SUM(carted)    AS BIGINT) FROM flags
  UNION ALL
  SELECT 4, 'begin_checkout', CAST(SUM(checkout)  AS BIGINT) FROM flags
  UNION ALL
  SELECT 5, 'purchase',       CAST(SUM(purchased) AS BIGINT) FROM flags
)
SELECT step_no,
       step,
       n,
       LAG(n) OVER (ORDER BY step_no) - n                                     AS lost_here,
       ROUND(100.0 * n / NULLIF(FIRST_VALUE(n) OVER (ORDER BY step_no), 0), 1)
                                                                              AS pct_of_top,
       ROUND(100.0 * n / NULLIF(LAG(n) OVER (ORDER BY step_no), 0), 1)        AS step_conv_pct
FROM funnel
ORDER BY step_no;

The LEFT JOIN from sessions is essential: sessions that fired no events at all must remain in the denominator, or the funnel silently starts from "sessions that did something", which is a much friendlier and much less useful population.

Common pitfall — the unordered funnel

The query above counts whether each event ever occurred in the session, not whether the events occurred in order. A session that purchased without a recorded view_item — through a direct link, a saved cart, or a tracking gap — still counts at the purchase step. This can produce the impossible-looking result where a later step has more sessions than an earlier one. The fix is a sequenced funnel: require each step's timestamp to be later than the previous step's within the same session. It is stricter, more honest, and usually returns smaller numbers that people initially refuse to believe.

The sequenced version

sqlsequenced_funnel.sql
WITH step_times AS (
  SELECT session_id,
         MIN(CASE WHEN event_name = 'view_item'      THEN event_at END) AS t_view,
         MIN(CASE WHEN event_name = 'add_to_cart'    THEN event_at END) AS t_cart,
         MIN(CASE WHEN event_name = 'begin_checkout' THEN event_at END) AS t_checkout,
         MIN(CASE WHEN event_name = 'purchase'       THEN event_at END) AS t_purchase
  FROM events
  GROUP BY session_id
),
ordered AS (
  SELECT s.session_id,
         CASE WHEN st.t_view IS NOT NULL THEN 1 ELSE 0 END                        AS s_view,
         CASE WHEN st.t_cart > st.t_view THEN 1 ELSE 0 END                        AS s_cart,
         CASE WHEN st.t_checkout > st.t_cart AND st.t_cart > st.t_view
              THEN 1 ELSE 0 END                                                   AS s_checkout,
         CASE WHEN st.t_purchase > st.t_checkout AND st.t_checkout > st.t_cart
                   AND st.t_cart > st.t_view THEN 1 ELSE 0 END                    AS s_purchase
  FROM sessions AS s
  LEFT JOIN step_times AS st ON st.session_id = s.session_id
)
SELECT COUNT(*)                                                          AS sessions,
       SUM(s_view)                                                       AS viewed,
       SUM(s_cart)                                                       AS carted_after_view,
       SUM(s_checkout)                                                   AS checkout_in_order,
       SUM(s_purchase)                                                   AS purchased_in_order,
       ROUND(100.0 * SUM(s_purchase) / NULLIF(COUNT(*), 0), 1)           AS overall_conv_pct
FROM ordered;

Note how NULL does the work for free. If t_cart is NULL because the step never happened, t_cart > t_view evaluates to NULL, which is not true, so the CASE falls through to 0. Three-valued logic, which causes so many bugs elsewhere, is exactly the behaviour you want here.

Note — session funnels versus user funnels

Both queries above are session-scoped: a person who browses on Monday and buys on Wednesday counts as one failed session and one successful one. That is the right frame for evaluating a single visit, and the wrong frame for evaluating whether a person ever converted. A user-scoped funnel groups by customer and allows steps to span sessions, usually with a window ("within 7 days"). The two produce very different conversion rates from identical data, and quoting one while the audience assumes the other is a routine source of confusion.

Making the funnel diagnostic

A funnel that returns one number per step is a scoreboard. To make it useful, split every step by a dimension you can act on — device, channel, country, new versus returning. The step with the largest variance across segments is usually the real problem, because a step that is bad everywhere is often just hard, while a step that is fine on desktop and terrible on mobile is a defect.

sqlfunnel_by_device.sql
WITH flags AS (
  SELECT s.session_id,
         s.device,
         s.channel,
         MAX(CASE WHEN e.event_name = 'view_item'   THEN 1 ELSE 0 END) AS viewed,
         MAX(CASE WHEN e.event_name = 'add_to_cart' THEN 1 ELSE 0 END) AS carted,
         MAX(CASE WHEN e.event_name = 'purchase'    THEN 1 ELSE 0 END) AS purchased
  FROM sessions AS s
  LEFT JOIN events AS e ON e.session_id = s.session_id
  GROUP BY s.session_id, s.device, s.channel
)
SELECT device,
       COUNT(*)                                                         AS sessions,
       SUM(viewed)                                                      AS viewed,
       SUM(carted)                                                      AS carted,
       SUM(purchased)                                                   AS purchased,
       ROUND(100.0 * SUM(carted)    / NULLIF(SUM(viewed), 0), 1)        AS view_to_cart_pct,
       ROUND(100.0 * SUM(purchased) / NULLIF(SUM(carted), 0), 1)        AS cart_to_purchase_pct,
       ROUND(100.0 * SUM(purchased) / NULLIF(COUNT(*), 0), 1)           AS overall_pct
FROM flags
GROUP BY device
ORDER BY sessions DESC;

With fifteen sessions in this dataset, each one moves a device's rate by tens of percentage points, so nothing here is a finding. The pattern is what transfers: segment the funnel, then compare rates and absolute losses across segments.

When NOT to use a funnel

  • When the journey is not linear. Banking Choosing a mortgage involves comparison sites, calculators, a branch call and several sessions over weeks, in no fixed order. Forcing that into five steps invents a sequence that never existed. Path or flow analysis fits better.
  • When the drop-off is the product working. A user who reads a help article and leaves without contacting support has succeeded. Counting them as funnel loss inverts the meaning of the metric.
  • When events are unreliable. Ad blockers, consent refusals and app crashes suppress events unevenly across devices and countries. A funnel is only as trustworthy as its least reliably fired step, and the missing data is never missing at random.
  • When you need to know why. Funnels locate; they do not explain. Session replay, usability testing and support tickets explain. The funnel's job is to tell those methods where to look.

Alternatives worth knowing: path analysis for non-linear journeys, time-to-convert distributions for slow decisions where a fixed window mislabels people as lost, and controlled experiments when you want to know whether a fix actually caused the improvement rather than coinciding with it.

In the field

The most valuable funnel work is usually not building the chart — it is reconciling it. Take the purchase count at the bottom of the event funnel and compare it against the order count in the transactional database. They will not match. The gap, and its pattern across devices and countries, tells you exactly how much to trust every rate above it. Analysts who publish funnels without doing this reconciliation are quoting a number whose error bars they have never measured.

Interview angle

"Write a funnel query" tests three things: collapsing events to one row per entity so repeats do not double-count, keeping entities with no events in the denominator via a LEFT JOIN, and knowing the difference between step and overall conversion. The follow-up is often "how would you handle a user who skips a step?" — and the expected answer is that it depends on whether the step is genuinely required, so you must decide between an unordered "ever did" funnel and a sequenced one, and say which you built.

Exercise 8.9

E-commerce The merchandising team wants to know which products lose people between viewing and adding to cart. Build a per-product funnel from events, showing views, add-to-carts and the view-to-cart rate, and explain why the resulting ranking should not be acted on directly.

Show solution
sqlproduct_view_to_cart.sql
WITH product_events AS (
  SELECT e.product_id,
         COUNT(*) FILTER (WHERE e.event_name = 'view_item')   AS views,
         COUNT(*) FILTER (WHERE e.event_name = 'add_to_cart') AS carts
  FROM events AS e
  WHERE e.product_id IS NOT NULL
  GROUP BY e.product_id
)
SELECT p.product_name,
       cat.category_name,
       p.unit_price,
       pe.views,
       pe.carts,
       ROUND(100.0 * pe.carts / NULLIF(pe.views, 0), 1) AS view_to_cart_pct
FROM product_events AS pe
JOIN products   AS p   ON p.product_id   = pe.product_id
JOIN categories AS cat ON cat.category_id = p.category_id
ORDER BY pe.views DESC, view_to_cart_pct DESC;

Why not to act on the ranking. Three reasons, and the first is decisive. Sample size: these products have single-digit view counts, so one person's behaviour swings the rate by 50 points or more; the ordering is essentially random. Confounding: view-to-cart rate varies systematically with price and considerationCost — an expensive tent will always convert worse than a cheap pair of socks, and comparing them ranks the price, not the page. Selection: products surfaced by a promotion attract browsers with weaker intent, which depresses their rate for reasons that have nothing to do with the product.

The defensible version compares each product against itself over time, or against products in the same category and price band, and only after enough views accumulate that a single visitor cannot move the number. Stating that requirement is part of the answer, not a caveat bolted on afterwards.

Knowledge check

A funnel shows more sessions at begin_checkout than at add_to_cart. What is the most likely cause?

Key takeaway

Collapse events to one row per entity before counting, keep event-less entities in the denominator, and report step conversion, overall conversion, and absolute count lost together — the worst rate is often not the biggest opportunity. Decide explicitly between an unordered and a sequenced funnel, say which you built, and reconcile the bottom step against the transactional source before trusting anything above it.

Lesson 8.10·13 min read

Repeat Purchase Behaviour & Purchase Frequency

You will be able to measure repeat rate, purchase frequency and inter-purchase time, and to explain why the average customer is usually a fiction that no real customer resembles.

For a business without subscriptions, repeat purchase behaviour is what retention actually looks like. Nobody cancels; they simply stop coming back. So the questions become: what share of customers ever buy twice, how often do the repeaters buy, and how long is the gap between purchases?

That third question is the one most often skipped and the one that pays for itself. If you know the typical gap between order one and order two, you know when a customer is overdue — and "overdue" is an actionable state in a way that "churned" never is.

The average customer does not exist

Fact Purchase counts in most retail businesses are heavily right-skewed: a large group buys once, a small group buys many times. In any such distribution the mean sits well above the median and describes almost nobody — it is pulled up by a thin tail.

Retail If 70 customers buy once and 30 buy five times, the mean is 2.2 purchases. No customer bought 2.2 times, and the mean is above the 70th percentile: it describes neither group. Anything you build around "the average customer buys 2.2 times" is designed for a person who does not exist.

Analogy

A pub reports that its average customer visits 2.2 times a month. Most people who walk in are there once, for a specific occasion; a couple of dozen regulars come three times a week. Designing the pub for a 2.2-visits-a-month customer produces something the tourists find confusing and the regulars find impersonal. Both groups are real. The average is not.

Start with the distribution, not the mean

sqlpurchase_distribution.sql
WITH per_customer AS (
  SELECT c.customer_id,
         COUNT(o.order_id) AS orders          -- LEFT JOIN so non-buyers count as 0
  FROM customers AS c
  LEFT JOIN orders AS o
         ON o.customer_id = c.customer_id AND o.status = 'completed'
  GROUP BY c.customer_id
)
SELECT orders                                                          AS orders_placed,
       COUNT(*)                                                        AS customers,
       ROUND(100.0 * COUNT(*) / NULLIF(SUM(COUNT(*)) OVER (), 0), 1)   AS pct_of_base,
       SUM(COUNT(*)) OVER (ORDER BY orders DESC
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
                                                                       AS customers_at_least
FROM per_customer
GROUP BY orders
ORDER BY orders;

The customers_at_least column is a running total taken from the top down, giving "how many customers placed at least this many orders" — the survival form of the distribution, and usually the most decision-relevant view. Note also that the LEFT JOIN keeps zero-order customers in the table, so the base is everyone acquired rather than everyone who bought.

Repeat rate, and its denominator problem

Repeat rate is the share of customers who bought more than once — but more than once out of what, and by when?

  • Of all acquired customers, including those who never bought: measures the whole acquisition-to-loyalty pipeline.
  • Of customers who bought at least once: isolates repeat behaviour from acquisition quality. This is the usual meaning, and it is the higher number.
  • Within a fixed window from first purchase (60, 90, 180 days): the only version comparable across cohorts, because it does not reward older customers for having had more time.
sqlrepeat_rate.sql
WITH first_order AS (
  SELECT customer_id, MIN(order_date) AS first_date
  FROM orders WHERE status = 'completed'
  GROUP BY customer_id
),
repeat_flags AS (
  SELECT f.customer_id,
         f.first_date,
         COUNT(o.order_id)                                                    AS total_orders,
         COUNT(o.order_id) FILTER (WHERE o.order_date > f.first_date
              AND o.order_date <= f.first_date + INTERVAL 90 DAY)             AS repeats_90d
  FROM first_order AS f
  JOIN orders AS o ON o.customer_id = f.customer_id AND o.status = 'completed'
  GROUP BY f.customer_id, f.first_date
)
SELECT (SELECT COUNT(*) FROM customers)                                       AS acquired,
       COUNT(*)                                                              AS buyers,
       SUM(CASE WHEN total_orders > 1 THEN 1 ELSE 0 END)                     AS ever_repeated,
       SUM(CASE WHEN repeats_90d > 0 THEN 1 ELSE 0 END)                      AS repeated_in_90d,
       ROUND(100.0 * SUM(CASE WHEN total_orders > 1 THEN 1 ELSE 0 END)
             / NULLIF(COUNT(*), 0), 1)                                       AS repeat_rate_of_buyers,
       ROUND(100.0 * SUM(CASE WHEN total_orders > 1 THEN 1 ELSE 0 END)
             / NULLIF((SELECT COUNT(*) FROM customers), 0), 1)               AS repeat_rate_of_acquired,
       ROUND(100.0 * SUM(CASE WHEN repeats_90d > 0 THEN 1 ELSE 0 END)
             / NULLIF(COUNT(*), 0), 1)                                       AS repeat_rate_90d
FROM repeat_flags;

Three columns, three different "repeat rates", all correct. Publishing one without naming which is how two teams end up quoting incompatible figures from the same warehouse.

Common pitfall — "ever repeated" rewards age

The unbounded ever_repeated measure gives older customers more chances to qualify, so it rises automatically as your customer base ages — the same tenure bias that inflates historic LTV. A cohort acquired last month is competing against cohorts with two years of opportunity. Any comparison across time must use the fixed-window version, and any single company-wide "repeat rate" quoted without a window is measuring the average age of your customer base at least as much as their loyalty.

Inter-purchase time with a window function

The gap between consecutive orders is what turns retention from a report into an operational trigger. LAG gives it directly.

sqlinter_purchase_gap.sql
WITH ordered AS (
  SELECT o.customer_id,
         o.order_id,
         o.order_date,
         ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.order_date) AS order_seq,
         LAG(o.order_date) OVER (PARTITION BY o.customer_id ORDER BY o.order_date)
                                                                              AS prev_date
  FROM orders AS o
  WHERE o.status = 'completed'
),
gaps AS (
  SELECT customer_id,
         order_seq,
         date_diff('day', prev_date, order_date) AS gap_days
  FROM ordered
  WHERE prev_date IS NOT NULL
)
SELECT order_seq                                                    AS purchase_number,
       COUNT(*)                                                     AS customers_reaching,
       MIN(gap_days)                                                AS min_gap,
       ROUND(AVG(gap_days), 1)                                      AS mean_gap,
       MEDIAN(gap_days)                                             AS median_gap,
       MAX(gap_days)                                                AS max_gap
FROM gaps
GROUP BY order_seq
ORDER BY order_seq;

Inference In many businesses the gap shrinks with each successive purchase — the second order comes months after the first, the third weeks after the second. If that pattern holds for you, it is strong evidence that the second purchase is the real hurdle, and that effort spent converting one-time buyers into two-time buyers is worth more than effort spent on customers who have already crossed it. With thirteen buyers and six gaps in this dataset, we cannot establish that pattern here; the query is the instrument, not the evidence.

Note — the gap you cannot see

This query only measures gaps that closed. Every customer sitting between purchases right now, and every customer who will never return, contributes no row — so the observed average gap is biased downwards, because long gaps are systematically under-represented. It is right-censoring again, wearing different clothes. A partial defence is to compute "days since last order" for active customers alongside the closed gaps, so the open-ended waits are at least visible.

When NOT to use these metrics

  • When the product is genuinely one-off. Insurance An annual policy has one purchase per year by design; frequency measures the renewal calendar, not behaviour.
  • When purchase frequency is set by consumption, not preference. For consumables, the gap is determined by pack size. Frequency then measures your packaging decision.
  • As a company-wide average. Repeat behaviour differs enormously by category, price band and acquisition channel. A single blended frequency averages populations with different natural cycles into a number describing none of them.
  • When identity resolution is weak. Guest checkouts and multiple email addresses split one person into several customer records, each with one order. This mechanically destroys repeat rate. If your repeat rate looks implausibly low, suspect identity before behaviour.

The alternatives: cohort-based repeat curves (Lesson 8.6) when comparing across time, and probabilistic buy-till-you-die models when you need per-customer predictions rather than population description — with the caveat from Lesson 8.4 that those rest on distributional assumptions SQL cannot validate.

Interview angle

"How would you increase repeat purchase rate?" is as much an analytics question as a marketing one. A strong answer starts by decomposing: which segment fails to repeat, at which purchase number, and after what gap? It names the second purchase as usually the steepest drop, proposes measuring the natural inter-purchase gap to time any intervention, and — crucially — insists that whatever is launched be tested against a holdout, because customers who would have returned anyway will otherwise be counted as a win. That last point is the one most candidates omit and the one interviewers are listening for.

Exercise 8.10

E-commerce Build a customer-level table showing order count, days since last order, the customer's own average gap between orders, and a flag for whether they are "overdue" — past their personal typical gap. Explain why a personal threshold beats a global one, and where it breaks.

Show solution
sqloverdue_customers.sql
WITH as_of AS (SELECT MAX(order_date) AS today FROM orders),
ordered AS (
  SELECT customer_id, order_date,
         LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_date
  FROM orders
  WHERE status = 'completed'
),
per_customer AS (
  SELECT customer_id,
         COUNT(*)                                                     AS orders,
         MAX(order_date)                                              AS last_order,
         AVG(date_diff('day', prev_date, order_date))                 AS avg_gap_days
  FROM ordered
  GROUP BY customer_id
)
SELECT c.full_name,
       pc.orders,
       pc.last_order,
       ROUND(pc.avg_gap_days, 0)                                      AS avg_gap_days,
       date_diff('day', pc.last_order, a.today)                       AS days_since_last,
       CASE WHEN pc.avg_gap_days IS NULL THEN 'single_order'
            WHEN date_diff('day', pc.last_order, a.today) > pc.avg_gap_days
                 THEN 'overdue'
            ELSE 'on_schedule'
       END                                                            AS status
FROM per_customer AS pc
JOIN customers AS c ON c.customer_id = pc.customer_id
CROSS JOIN as_of AS a
ORDER BY days_since_last DESC;

Why personal beats global. A customer who reliably buys every 30 days is alarming at day 45; one who buys every 200 days is entirely normal at day 45. A single global threshold generates false alarms for the first group and misses the second entirely. The personal baseline compares each customer against their own established rhythm, which is the only comparison that respects how differently people actually behave.

Where it breaks. It needs at least two orders to compute a gap, so single-order customers — the majority, and the most important group to reactivate — fall out entirely, handled here by an explicit single_order label rather than a misleading NULL. With two orders the "average" gap is a single observation with no notion of variability. And a mean gap is fragile for irregular buyers; a median, or a gap plus a tolerance band, is more robust. The honest version reports the number of gaps behind each average so the reader can see how much to trust it.

Knowledge check

A retailer's mean orders per customer is 2.4 and the median is 1. What does this most strongly imply?

Key takeaway

Purchase counts are right-skewed, so lead with the distribution and the median, never the mean alone. Say which repeat rate you mean — of buyers, of all acquired, or within a fixed window — because only the windowed version is comparable across cohorts. And measure the inter-purchase gap: it converts retention from a report into a trigger, as long as you remember that the gaps you can see are only the ones that closed.

Lesson 8.11·13 min read

Customer Segmentation & RFM Scoring

You will be able to build an RFM segmentation with window functions, and to judge when a segmentation is genuinely useful rather than an expensive way of relabelling a spreadsheet.

Segmentation exists because a single average is a bad instruction. "Customers spend £180 a year" tells a marketing team nothing about who to email tomorrow. Splitting the base into groups that behave differently, and that you can treat differently, converts description into action.

The test of a segmentation is not how clever it is. It is whether anyone does anything differently as a result. A segmentation nobody acts on is a taxonomy, and taxonomies are cheap to produce and expensive to maintain.

RFM: three questions that carry most of the signal

RFM scores customers on three axes, each answering a question about behaviour rather than identity:

AxisQuestionWhy it predicts
RecencyHow long since they last bought?The strongest single signal of whether someone is still a customer at all
FrequencyHow many times have they bought?Distinguishes established habit from a one-off purchase
MonetaryHow much have they spent?Separates who is worth the cost of contacting

Inference RFM survives because it uses only transaction data every business already has, it needs no model training, and the resulting groups map directly onto obvious actions: reward the loyal, reactivate the lapsed, nurture the new. Its limitation is the mirror image — it knows nothing about why anyone behaves as they do, so it cannot tell you what to say to them.

Analogy

A corner-shop owner knows their customers without any data: the man who comes every morning, the family who does a big shop monthly, the woman who has not been in since summer. Recency, frequency, monetary. RFM is that shopkeeper's intuition written down so it works for a hundred thousand customers instead of a hundred. It is not sophisticated, and it does not need to be — the shopkeeper's version was already good enough to run a business on.

Building RFM with NTILE

The standard construction ranks customers on each axis and cuts them into equal-sized buckets with NTILE. Quintiles are conventional; with seventeen customers we use tertiles, because quintiles on this many people would put three customers in each band and mean nothing.

Retail

sqlrfm_scores.sql
WITH as_of AS (SELECT MAX(order_date) AS today FROM orders),
base AS (
  SELECT c.customer_id,
         c.full_name,
         date_diff('day', MAX(o.order_date), a.today)                       AS recency_days,
         COUNT(DISTINCT o.order_id)                                         AS frequency,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100))     AS monetary
  FROM customers   AS c
  JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id   = o.order_id
  CROSS JOIN as_of AS a
  GROUP BY c.customer_id, c.full_name, a.today
),
scored AS (
  SELECT *,
         -- recency reversed: fewer days since purchase is better, so score 3 is best
         NTILE(3) OVER (ORDER BY recency_days DESC) AS r_score,
         NTILE(3) OVER (ORDER BY frequency)         AS f_score,
         NTILE(3) OVER (ORDER BY monetary)          AS m_score
  FROM base
)
SELECT full_name,
       recency_days,
       frequency,
       ROUND(monetary, 2) AS monetary,
       r_score, f_score, m_score,
       CAST(r_score AS VARCHAR) || CAST(f_score AS VARCHAR) || CAST(m_score AS VARCHAR)
                                                                            AS rfm_cell,
       CASE
         WHEN r_score = 3 AND f_score >= 2                 THEN 'champion'
         WHEN r_score = 3 AND f_score = 1                  THEN 'new_or_promising'
         WHEN r_score = 2                                  THEN 'needs_attention'
         WHEN r_score = 1 AND m_score = 3                  THEN 'cant_lose_them'
         ELSE                                                   'lapsed'
       END                                                                  AS segment
FROM scored
ORDER BY r_score DESC, f_score DESC, m_score DESC;

The reversed sort on recency is the detail people get wrong. Low recency_days is good, so ordering descending puts the most recent buyers in the highest tile. Get this backwards and your "champions" are the customers who left a year ago — an error that survives review surprisingly often because the query runs perfectly and the output looks plausible.

Common pitfall — NTILE forces equal groups that may not exist

NTILE divides customers into equal-sized buckets, not equal-valued ones. If most of your base has bought exactly once, NTILE(3) on frequency will split identical customers across different scores purely by tie-breaking order — two people with one order each land in different segments for no reason at all. And because the buckets are relative, everyone's score can stay the same while the whole business declines: RFM measures rank within your base, never absolute health. When ties dominate, use explicit thresholds (frequency >= 3) instead of tiles, and state the thresholds.

From cells to actions

Three tertiles across three axes give 27 cells, which is too many for anyone to act on. The CASE above collapses them into a handful of named segments, and the naming is the analytical work — each name should imply an action:

SegmentPatternImplied action
ChampionRecent, frequentReward, ask for referrals, do not discount
New or promisingRecent, only one purchaseDrive the second purchase — the steepest drop-off
Needs attentionMid recency, was valuableReach out before the gap becomes a lapse
Can't lose themLapsed but high spendPersonal contact; worth a real cost to recover
LapsedOld, low valueCheap automated win-back, or accept the loss

Note the asymmetry: "do not discount" for champions is a genuine finding of segmentation work. Blanket promotions hand margin to people who were going to buy anyway — the same incrementality problem that Lesson 8.13 addresses, appearing here in a customer-marketing costume.

sqlsegment_summary.sql
WITH as_of AS (SELECT MAX(order_date) AS today FROM orders),
base AS (
  SELECT c.customer_id,
         date_diff('day', MAX(o.order_date), a.today)                       AS recency_days,
         COUNT(DISTINCT o.order_id)                                         AS frequency,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100))     AS monetary
  FROM customers   AS c
  JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id   = o.order_id
  CROSS JOIN as_of AS a
  GROUP BY c.customer_id, a.today
),
scored AS (
  SELECT *,
         NTILE(3) OVER (ORDER BY recency_days DESC) AS r_score,
         NTILE(3) OVER (ORDER BY frequency)         AS f_score,
         NTILE(3) OVER (ORDER BY monetary)          AS m_score
  FROM base
),
segmented AS (
  SELECT *,
         CASE WHEN r_score = 3 AND f_score >= 2 THEN 'champion'
              WHEN r_score = 3 AND f_score = 1  THEN 'new_or_promising'
              WHEN r_score = 2                  THEN 'needs_attention'
              WHEN r_score = 1 AND m_score = 3  THEN 'cant_lose_them'
              ELSE                                   'lapsed' END AS segment
  FROM scored
)
SELECT segment,
       COUNT(*)                                                            AS customers,
       ROUND(AVG(recency_days), 0)                                         AS avg_recency_days,
       ROUND(AVG(frequency), 2)                                            AS avg_frequency,
       ROUND(SUM(monetary), 2)                                             AS total_value,
       ROUND(100.0 * SUM(monetary) / NULLIF(SUM(SUM(monetary)) OVER (), 0), 1)
                                                                           AS pct_of_value
FROM segmented
GROUP BY segment
ORDER BY total_value DESC;

The pct_of_value column is what makes the table worth showing to anyone. A segmentation where one small group holds a large share of value tells you where attention should go; one where value is spread evenly tells you segmentation may not be the right tool at all. Both are useful answers.

Note — nested aggregate in a window

SUM(SUM(monetary)) OVER () looks wrong and is correct. The inner SUM aggregates within each segment; the window function then sums those group totals across all rows of the result, giving the grand total on every row. Window functions are evaluated after grouping, which is precisely why this composes. It is the standard idiom for "percentage of overall total" in a grouped query.

When NOT to segment this way

  • When the base is small. Seventeen customers cut into 27 cells is a sorting exercise, not a segmentation. Below a few thousand customers per segment, differences between segments are unlikely to be stable.
  • When behaviour is not the useful axis. Manufacturing A B2B business with 200 accounts segments better by industry, contract size and procurement cycle. RFM on 200 accounts tells you what you already knew from the account list.
  • When you need to know why. RFM describes what people did. Needs-based segmentation, built from surveys or product usage, describes what they want — and only the second tells you what to build.
  • When the segments are not actionable. If your channels cannot address a segment differently, the segmentation changes nothing. Check that the operational capability exists before building it.

The alternatives, in ascending complexity: simple threshold rules (often better than tiles, and much easier to explain); clustering such as k-means on behavioural features, which finds groups you did not anticipate but produces segments that are hard to name and unstable as data shifts; and propensity models, which skip segments entirely and score each customer for a specific action — more accurate and far less interpretable.

In the field

Segmentations decay. Because tiles are relative, a customer can move segments without changing their behaviour at all — simply because other customers moved around them. Teams then react to "churn" in the champion segment that is pure reshuffling. Two defences: recompute on a fixed schedule and keep the history so movement is visible, and use absolute thresholds for the segments that trigger expensive actions. Relative scores are fine for ranking; absolute ones are what you should attach money to.

Interview angle

RFM is a favourite window-function exercise because it packs NTILE, aggregation and a reversed sort into one query. Interviewers watch for the recency direction, and for whether you notice that NTILE splits ties arbitrarily. The strong follow-up answer is unprompted: "with this distribution I would use thresholds rather than tiles, because most customers have one order and tiles would separate identical people." Mentioning that the segmentation is relative — so scores are stable even if the business is shrinking — puts you ahead of nearly everyone.

Exercise 8.11

Digital media Build a two-axis segmentation using absolute thresholds instead of tiles: value tier (high/mid/low by total spend) crossed with activity (active/lapsed by 120 days). Report customers, total value and value share per cell, and explain why thresholds are the better choice here.

Show solution
sqlthreshold_segments.sql
WITH as_of AS (SELECT MAX(order_date) AS today FROM orders),
base AS (
  SELECT c.customer_id,
         MAX(o.order_date)                                                  AS last_order,
         COALESCE(SUM(oi.quantity * oi.unit_price
                      * (1 - oi.discount_pct / 100)), 0)                    AS monetary
  FROM customers   AS c
  LEFT JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id   = o.order_id
  GROUP BY c.customer_id
),
labelled AS (
  SELECT b.customer_id,
         b.monetary,
         CASE WHEN b.last_order IS NULL                                  THEN 'never_bought'
              WHEN date_diff('day', b.last_order, a.today) <= 120        THEN 'active'
              ELSE                                                            'lapsed'
         END AS activity,
         CASE WHEN b.monetary >= 700 THEN 'high'
              WHEN b.monetary >= 300 THEN 'mid'
              WHEN b.monetary >  0   THEN 'low'
              ELSE                        'none'
         END AS value_tier
  FROM base AS b
  CROSS JOIN as_of AS a
)
SELECT activity,
       value_tier,
       COUNT(*)                                                            AS customers,
       ROUND(SUM(monetary), 2)                                             AS total_value,
       ROUND(100.0 * SUM(monetary) / NULLIF(SUM(SUM(monetary)) OVER (), 0), 1)
                                                                           AS pct_of_value
FROM labelled
GROUP BY activity, value_tier
ORDER BY total_value DESC;

Why thresholds beat tiles here. Four reasons. They are stable: a customer's tier changes only when their own behaviour changes, not when other customers move. They are absolute: if the whole base declines, the high tier empties, which is exactly the signal tiles suppress. They are explicable: "spent £700 or more" survives a meeting in a way that "third tertile of monetary" does not. And they handle ties correctly, which matters enormously when many customers share the same value.

The cost is that thresholds are arbitrary and need periodic review — £700 means something different after two years of price inflation. That is a real maintenance burden, but it is a visible one, which is preferable to the invisible drift that tiles produce. Note too that the LEFT JOIN and the never_bought branch keep acquired non-purchasers in the picture rather than silently dropping them.

Knowledge check

A business using quintile-based RFM finds its champion segment has held steady at 20% of customers for two years, while total revenue has halved. What does this tell you?

Key takeaway

RFM turns three columns every business already has into groups that imply actions. Watch the reversed sort on recency, and remember that NTILE produces equal-sized buckets that split ties arbitrarily and are purely relative — segment sizes stay constant while the business declines. Prefer absolute thresholds wherever money is attached, and judge any segmentation by one test: does anyone do anything differently because of it?

Lesson 8.12·15 min read

Marketing Attribution Models (First, Last, Linear, Time-Decay, Position)

You will be able to implement five attribution models in SQL over the same journeys, compare what each one credits, and explain clearly why every one of them is an assumption rather than a measurement.

A customer clicks a paid search ad in January, ignores you for two months, sees a social post, searches your brand name, and buys. Which channel gets the sale?

There is no true answer to that question. Not a hard-to-find answer, not an answer requiring better tracking — no answer exists in the data. The sale happened once. The counterfactual worlds in which one touchpoint was removed were never observed, so the data cannot tell you what any single touch contributed. Attribution models do not discover the answer; they impose a rule for splitting credit, and then everyone treats the output of the rule as a finding.

Open This is not a solved problem awaiting better software. Whether any observational attribution model recovers causal contribution is genuinely contested, and the reasonable position is that they do not — they produce consistent bookkeeping, which has real value, but bookkeeping is not measurement.

Analogy

A football team scores. Who gets the credit — the striker, the player who passed, the defender who won the ball, or the manager who set up the formation? Every league has a rule: a goal and an assist. The rule is useful because it is consistent and everyone knows it. But nobody believes the rule measures each player's causal contribution to the goal, and no amount of extra camera footage would turn it into a measurement. Attribution models are goals and assists for marketing.

The five standard rules

ModelRuleSystematically favoursImplicit belief
First touch100% to the first interactionDiscovery channels: display, social, videoGetting noticed is the hard part
Last touch100% to the final interactionClosing channels: brand search, email, retargetingThe final nudge is what converts
LinearEqual split across all touchesChannels appearing often, at any stageEvery interaction contributed equally
Time decayWeight rises closer to conversionLate-stage channels, less extremely than last touchRecent influence matters more
Position based40% first, 40% last, 20% sharedDiscovery and closing togetherBeginning and end matter most

Read the last column carefully. Each model encodes a belief about how persuasion works. Choosing a model is choosing a belief, and the choice determines which teams look successful. That is why attribution debates are rarely resolved by analysis: the analysis is downstream of the assumption.

Reconstructing journeys

Marketing Our warehouse has sessions (each with a channel and a timestamp) and orders. A journey is every session belonging to a customer up to and including the day they ordered.

sqljourneys.sql
WITH conversions AS (
  SELECT order_id, customer_id, order_date
  FROM orders
  WHERE status = 'completed'
),
journey AS (
  SELECT cv.order_id,
         cv.customer_id,
         cv.order_date,
         s.channel,
         s.started_at,
         ROW_NUMBER() OVER (PARTITION BY cv.order_id ORDER BY s.started_at)      AS t_first,
         ROW_NUMBER() OVER (PARTITION BY cv.order_id ORDER BY s.started_at DESC) AS t_last,
         COUNT(*)     OVER (PARTITION BY cv.order_id)                            AS touches
  FROM conversions AS cv
  JOIN sessions    AS s
    ON s.customer_id = cv.customer_id
   AND CAST(s.started_at AS DATE) <= cv.order_date
)
SELECT order_id, customer_id, order_date, touches, t_first, channel, CAST(started_at AS DATE) AS touch_date
FROM journey
WHERE customer_id IN (1, 5)
ORDER BY order_id, t_first;

Two modelling decisions are already baked in and neither is neutral. The join includes every prior session with no lookback window, so a touch from a year ago counts as much as one from yesterday. And a session is counted as a touch even if the customer merely landed and left. Both choices change the results below, and neither is visible in the output.

First touch versus last touch, side by side

sqlfirst_vs_last_touch.sql
WITH conversions AS (
  SELECT order_id, customer_id, order_date
  FROM orders WHERE status = 'completed'
),
journey AS (
  SELECT cv.order_id,
         s.channel,
         ROW_NUMBER() OVER (PARTITION BY cv.order_id ORDER BY s.started_at)      AS t_first,
         ROW_NUMBER() OVER (PARTITION BY cv.order_id ORDER BY s.started_at DESC) AS t_last
  FROM conversions AS cv
  JOIN sessions    AS s
    ON s.customer_id = cv.customer_id
   AND CAST(s.started_at AS DATE) <= cv.order_date
)
SELECT channel,
       COUNT(*) FILTER (WHERE t_first = 1)                                  AS first_touch_orders,
       COUNT(*) FILTER (WHERE t_last  = 1)                                  AS last_touch_orders,
       COUNT(*) FILTER (WHERE t_last = 1) - COUNT(*) FILTER (WHERE t_first = 1)
                                                                            AS last_minus_first
FROM journey
GROUP BY channel
ORDER BY channel;
Result
channelfirst_touch_orderslast_touch_orderslast_minus_first
direct055
email000
organic440
paid_search72-5
paid_social220
referral110

This is the entire lesson in one table. Nothing about the business changed between those two columns — same customers, same orders, same sessions. Only the crediting rule changed. Paid search loses five conversions and direct gains five, because customers who first arrived through a paid ad later returned by typing the site name.

Under first touch, paid search is the engine of the business. Under last touch, direct traffic is — and "direct" is not a channel anyone can buy. A last-touch report hands credit to a bucket that no budget decision can act on, while a first-touch report hands it to a channel that may have contributed nothing beyond being the first thing recorded.

Common pitfall — comparing channels across different models

The most damaging attribution error is not choosing the wrong model — it is mixing them. A social platform reports view-through conversions under its own model, search reports last-click, the warehouse reports first-touch, and someone builds a single deck comparing all three. Every channel appears to be doing well simultaneously, the sum of "attributed" revenue exceeds actual revenue, and the resulting budget conversation is unmoored from reality. Pick one model, apply it to every channel from one source of journey data, and reconcile the total against actual orders before anyone presents it.

All five models in one query

Fractional models assign each touch a weight, normalise the weights so each conversion contributes exactly 1.0, then sum by channel. That normalisation is what guarantees the models remain comparable.

sqlfive_attribution_models.sql
WITH conversions AS (
  SELECT order_id, customer_id, order_date
  FROM orders WHERE status = 'completed'
),
journey AS (
  SELECT cv.order_id,
         s.channel,
         date_diff('day', CAST(s.started_at AS DATE), cv.order_date)             AS days_before,
         ROW_NUMBER() OVER (PARTITION BY cv.order_id ORDER BY s.started_at)      AS t_first,
         ROW_NUMBER() OVER (PARTITION BY cv.order_id ORDER BY s.started_at DESC) AS t_last,
         COUNT(*)     OVER (PARTITION BY cv.order_id)                            AS touches
  FROM conversions AS cv
  JOIN sessions    AS s
    ON s.customer_id = cv.customer_id
   AND CAST(s.started_at AS DATE) <= cv.order_date
),
raw_weights AS (
  SELECT *,
         1.0 / NULLIF(touches, 0)                             AS w_linear,
         -- half-life of 7 days: a touch 7 days out is worth half of one at conversion
         POWER(0.5, days_before / 7.0)                        AS w_decay_raw,
         CASE WHEN touches = 1                  THEN 1.0
              WHEN t_first = 1 OR t_last = 1    THEN 0.4
              ELSE 0.2 / NULLIF(touches - 2, 0) END           AS w_pos_raw
  FROM journey
),
normalised AS (
  SELECT *,
         w_decay_raw / NULLIF(SUM(w_decay_raw) OVER (PARTITION BY order_id), 0) AS w_decay,
         w_pos_raw   / NULLIF(SUM(w_pos_raw)   OVER (PARTITION BY order_id), 0) AS w_position
  FROM raw_weights
)
SELECT channel,
       COUNT(*) FILTER (WHERE t_first = 1)     AS first_touch,
       COUNT(*) FILTER (WHERE t_last  = 1)     AS last_touch,
       ROUND(SUM(w_linear),   2)               AS linear,
       ROUND(SUM(w_decay),    2)               AS time_decay,
       ROUND(SUM(w_position), 2)               AS position_based
FROM normalised
GROUP BY channel
ORDER BY channel;
Result
channelfirst_touchlast_touchlineartime_decayposition_based
direct053.504.983.50
email000.500.020.50
organic444.004.004.00
paid_search724.502.024.50
paid_social222.002.002.00
referral111.001.001.00

Every column sums to 15 — the number of completed orders — because the weights are normalised per conversion. That property is what makes the columns comparable, and it is also the clearest statement of what these models are: a fixed quantity of credit being redistributed under different rules. No model creates or discovers value; each one just moves the same 15 units around.

Paid search ranges from 7.00 to 2.02 depending on the rule. Direct ranges from 0 to 4.98. If a budget decision would differ across that range — and it would — then the decision is being driven by the choice of model, not by evidence. Note also that linear and position-based agree exactly here: with at most two touches per journey, position-based degenerates to a 50/50 split, which is precisely what linear does. Model differences only appear when journeys are long.

Note — what the data cannot see

Every model above operates on recorded sessions. It cannot see the podcast advert, the friend's recommendation, the packaging in a shop window, or any session on a device you failed to link to the customer. Attribution is a redistribution of credit among the touches you happened to log, and untracked influence is not merely under-credited — it is invisible, so its credit is silently reallocated to whatever was tracked. This inflates measurable channels at the expense of unmeasurable ones, which is a systematic bias, not random noise.

When NOT to use attribution models

  • When the question is causal. "Did this spend generate incremental revenue?" cannot be answered by any splitting rule. It needs an experiment or a model with a control group. Lesson 8.13 is entirely about this.
  • When journeys are mostly single-touch. If nearly every conversion has one recorded session, all five models return the same answer, and building the machinery adds cost without insight.
  • When tracking coverage is poor. Consent refusals, cross-device journeys and app-to-web transitions leave large gaps. A model applied to a biased sample of journeys produces a confidently biased answer, which is worse than an admitted unknown.
  • For brand or upper-funnel spend. Its influence arrives outside any lookback window and often without a click at all. Attribution will consistently value it near zero, which is a property of the method rather than a finding about the spend.

The alternatives are the subject of the next lesson: incrementality testing (hold out a group and compare), geo experiments (turn spend off in matched regions), and media mix modelling (regression on aggregate time series, which trades granularity for a shot at causality). Attribution remains useful for operational questions — which creative to keep, which keyword to pause — where consistency matters more than truth. The mistake is using it to size the value of a channel.

In the field

The most useful thing an analyst can do with attribution is refuse to pick a model in private. Publish the comparison table — every channel under every model, as above — and let the range be the finding. When a channel's credit varies by a factor of three across models, the honest headline is "this decision is not supported by attribution data", and that sentence has saved more budget than any single model has ever allocated well. Teams that quietly choose one model inherit its bias and cannot see it.

Interview angle

Attribution questions separate people who have shipped this from people who have read about it. Expect "implement first and last touch in SQL" — two ROW_NUMBER() window functions with opposite sort orders over the same partition, which is the compact answer. The real test is the follow-up: "which model is correct?" The expected answer is that none is, because the counterfactual is unobserved; models are conventions for splitting a fixed pot. Candidates who go further and name incrementality testing as the thing that does answer the causal question are demonstrating exactly the judgement the role requires.

Exercise 8.12

Advertising Extend the comparison to revenue: report first-touch and last-touch attributed net revenue by channel, plus the percentage difference between them. Then state what a large gap for a channel implies about its role in the journey.

Show solution
sqlattributed_revenue.sql
WITH order_value AS (
  SELECT o.order_id,
         o.customer_id,
         o.order_date,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)) AS net_rev
  FROM orders      AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status = 'completed'
  GROUP BY o.order_id, o.customer_id, o.order_date
),
journey AS (
  SELECT ov.order_id,
         ov.net_rev,
         s.channel,
         ROW_NUMBER() OVER (PARTITION BY ov.order_id ORDER BY s.started_at)      AS t_first,
         ROW_NUMBER() OVER (PARTITION BY ov.order_id ORDER BY s.started_at DESC) AS t_last
  FROM order_value AS ov
  JOIN sessions    AS s
    ON s.customer_id = ov.customer_id
   AND CAST(s.started_at AS DATE) <= ov.order_date
)
SELECT channel,
       ROUND(SUM(net_rev) FILTER (WHERE t_first = 1), 2)                AS first_touch_rev,
       ROUND(SUM(net_rev) FILTER (WHERE t_last  = 1), 2)                AS last_touch_rev,
       ROUND(100.0 * (COALESCE(SUM(net_rev) FILTER (WHERE t_last = 1), 0)
                    - COALESCE(SUM(net_rev) FILTER (WHERE t_first = 1), 0))
             / NULLIF(COALESCE(SUM(net_rev) FILTER (WHERE t_first = 1), 0), 0), 1)
                                                                        AS pct_shift
FROM journey
GROUP BY channel
ORDER BY channel;

What a large gap implies. A channel whose last-touch revenue far exceeds its first-touch revenue is a closer: it appears late, after demand already exists. Brand search, email and retargeting behave this way, and they are the channels most likely to be taking credit for demand created elsewhere — or for demand that would have converted with no advertising at all.

A channel with the opposite pattern is a discoverer: it starts journeys and is then absent at the moment of purchase. It will look weak under last-touch reporting and is the most common casualty of a last-click budget process.

The critical caveat: this gap describes position in the recorded journey, not contribution. A closer might be genuinely persuasive or might be pure credit-claiming, and this query cannot distinguish those cases — nor can any other attribution query. Answering that requires withholding the channel from a comparable group and observing what happens, which is the next lesson.

Knowledge check

Switching from last-touch to first-touch attribution, brand search drops from 30% of credited revenue to 5%. What has been established?

Key takeaway

Attribution models redistribute a fixed pot of credit under a chosen rule — every column in the comparison sums to the same number of conversions. First touch flatters discovery channels, last touch flatters closers, and the gap between them measures position in the journey, not contribution. Never mix models across channels, always reconcile against actual orders, and when the range across models is wide enough to change a decision, report the range as the finding.

Part 9

Statistics, Taught Entirely in SQL

From the mean to regression, built from first principles and implemented in runnable SQL. No prior statistics, no Python.

Lesson 9.1·12 min read

What Statistics Is For: Signal, Noise & Uncertainty

By the end you will be able to look at a number produced by a query and say, out loud, how much of it is real and how much of it is luck.

A marketing lead sends you a message: “June was our best month ever — revenue was up 73% on May. Whatever we changed, keep doing it.” You run the query and the number checks out. So do you agree?

You should not, and the reason is the entire subject of this part. That 73% is made of two things mixed together, and a query cannot separate them for you unless you ask it to. One part is signal: a real, repeatable change in how the business behaves. The other is noise: the ordinary wobble that would have appeared anyway, because months are made of a small number of orders and orders are made of people who buy things for reasons that have nothing to do with your campaign.

Statistics is not a bag of formulas. It is a discipline for answering one question in as many settings as possible: given that my number would have been different if the world had rolled differently, how much should I believe it?

Three words, defined before anything else

Every term in this part gets defined at first use, starting here.

  • Population. The complete set of things you actually care about. “Every order this shop will ever take.” You almost never have it.
  • Sample. The subset you can actually see. “The 25 orders in our database.” Everything you compute is computed on a sample, whether or not you admit it.
  • Statistic. Any number computed from a sample — an average, a count, a percentage. A statistic is an estimate of something about the population, and estimates have error.

The trap for anyone who is fluent in SQL and new to statistics is the feeling that SELECT AVG(revenue) FROM orders gives you the average. It does not. It gives you the average of the rows that happen to exist. Run the same business for the same year in a parallel universe and you would get a different number from an identically correct query.

Analogy

You are trying to hear someone speak at a party. Their voice is the signal; the crowd is the noise. If they say one word you may mishear it entirely. If they say the same sentence six times you become confident. Nothing about their voice changed — you just collected more evidence, and the crowd, being random, cancelled itself out. Sample size is exactly this: it does not make the signal louder, it makes the noise quieter.

Seeing noise in the data you already have

E-commerce Here is monthly revenue for completed orders. Nothing clever — just a GROUP BY. The point is to look at the shape of the column, not the total.

sqlmonthly_wobble.sql
-- Revenue per calendar month, discounts applied.
-- Read the column top to bottom and ask: which changes are real?
SELECT strftime(o.order_date, '%Y-%m')                                   AS month,
       COUNT(DISTINCT o.order_id)                                        AS orders,
       ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)), 2) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY 1
ORDER BY 1;
Result
monthordersrevenue
2024-012591.20
2024-022617.00
2024-032473.15
2024-041219.00
2024-052547.30
2024-063945.00
2024-071179.00
2024-082478.00
2024-092758.10
2024-101359.10
2024-111318.00
2024-121598.00
2025-011278.00

June is indeed the biggest month, and it is 73% above May. Now look at the orders column: June has three orders and May has two. The “73% growth” is one extra person buying one extra thing. July then falls to 179.00, a drop of 81%, and no one sends a message about that.

Inference When the count behind a metric is in single digits, month-on-month percentage changes describe who happened to buy, not how the business is doing. This dataset is deliberately tiny so that the effect is impossible to miss; in a real warehouse with 40,000 orders a month the same effect is present, just smaller and therefore easier to mistake for insight.

Two numbers instead of one

The remedy is never to report a single number naked. Report the centre and the spread. Here is the whole of descriptive statistics in one query, on individual order lines:

sqlcentre_and_spread.sql
-- A line's value = how many units, times the price of one unit.
SELECT COUNT(*)                                       AS n_lines,
       ROUND(AVG(quantity * unit_price), 2)           AS mean_line,
       MIN(quantity * unit_price)                     AS min_line,
       MAX(quantity * unit_price)                     AS max_line,
       ROUND(STDDEV_SAMP(quantity * unit_price), 2)   AS sd_line
FROM order_items;
Result
n_linesmean_linemin_linemax_linesd_line
30265.13129.00459.00103.79

STDDEV_SAMP is the standard deviation — a single number summarising how far a typical row sits from the average. Lesson 9.3 builds it from scratch; for now treat it as “typical distance from the middle”. The average order line is 265.13, and a typical line sits about 104 away from that in either direction. Those two numbers together say something the average alone cannot: order lines vary enormously, so any average computed from a handful of them will bounce around.

💡Intuition — the central bargain of statistics

You cannot remove noise. You can only quantify it, and then say whether the signal is bigger. Every technique in this part — standard deviation, confidence intervals, t-tests, regression — is a different way of writing the same fraction: effect size / noise size. When that ratio is large you have found something. When it is small you have found a month with three orders in it.

When statistics is the wrong tool

Reaching for statistical machinery is sometimes a way of avoiding a simpler answer. Do not do it when:

  • You have the whole population and the question is descriptive. “How much did we invoice in Q3?” has an exact answer. There is no uncertainty to quantify — it is accounting, not inference. Adding a confidence interval to a ledger total is nonsense.
  • The effect is enormous and obvious. If a bug drops checkout conversion from 4% to 0%, you do not need a hypothesis test. You need a deployment rollback.
  • The data-generating process is broken. If tracking double-counted purchases for a week, no test rescues that week. Statistics assumes your measurements measure what you think. Fix the pipe before you model the water.
  • The decision is the same either way. If you will ship the feature regardless, the test is theatre. Establish the decision rule before computing the number.
Common pitfall — the naked percentage

The single most damaging habit in analytics reporting is quoting a percentage change without the counts underneath it. “Conversion up 50%” is compatible with 2 sales becoming 3. It is also compatible with 20,000 becoming 30,000. These are wildly different facts and the percentage hides which one you have. Inference The habit persists because percentages look more professional than counts — they are scaled, comparable and tidy. Always show the numerator and the denominator beside the ratio; if a dashboard will not let you, that is a reason to change the dashboard.

In the field

Advertising Weekly performance reviews are where noise gets promoted to strategy. A channel's cost-per-acquisition moves by 15% week to week, someone builds a narrative around it, budget is reallocated, and the next week it moves back — now attributed to the reallocation. The cheapest fix costs nothing: add a column showing the number of conversions each figure is based on, and add a horizontal band showing the typical week-to-week movement over the last year. Most “changes” visibly land inside the band and the conversation ends before it starts.

Interview angle

A standard analytics screen: “Our signup rate jumped 20% yesterday. What do you do?” Weak answers dive straight into segmentation. Strong answers establish magnitude and plausibility first: how many signups is 20%, is that inside the normal daily range, did anything change in tracking or in traffic mix, and is the comparison window sensible (a Monday against a Sunday?). Interviewers are testing whether you treat a number as evidence or as fact. Saying “before I explain it, I want to know whether it needs explaining” scores highly.

Exercise 9.1

Product analytics A stakeholder claims the shop's average order line value “is about 265”. Write a query that reports, per calendar month of 2024, the number of order lines, the mean line value and the standard deviation of line values — so a reader can judge how stable that 265 really is. Then say in one sentence what the output shows.

Show solution
sqlstability_by_month.sql
SELECT strftime(o.order_date, '%Y-%m')                    AS month,
       COUNT(*)                                           AS n_lines,
       ROUND(AVG(oi.quantity * oi.unit_price), 2)         AS mean_line,
       ROUND(STDDEV_SAMP(oi.quantity * oi.unit_price), 2) AS sd_line
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY 1
ORDER BY 1;

The monthly means scatter widely around 265 and several months return NULL for the standard deviation, because STDDEV_SAMP is undefined when a group has only one row — you cannot measure spread from a single observation. That NULL is not a defect; it is the database refusing to pretend it knows something it cannot know. The honest one-sentence summary: “265 is the average across all 30 lines, but individual months are built from one to five lines and their means range far above and below it, so 265 should never be quoted as a monthly expectation.”

Knowledge check

A dashboard shows “Refund rate: 12% (↑ 4 points vs last week)”. What is the single most important extra number to see before reacting?

Key takeaway

Every number a query returns is an estimate computed from the rows that happened to exist. Statistics is the discipline of reporting centre and spread together, so that a reader can judge whether a movement is signal or noise. The working question for the rest of this part is always the same fraction: how big is the effect compared with how much things wobble anyway?

Lesson 9.2·13 min read

Mean, Median & Mode — and When Each Lies

Three different answers to “what is a typical value?” — and the ability to choose the right one and defend the choice.

“What does a typical customer spend?” sounds like one question. It is three, and they have different answers, and a great deal of bad analysis comes from someone asking one and being given another.

Before the formulas, the notation. Throughout this part, x means one observation — one salary, one order value, one click count. n means how many observations there are. The symbol SUM(x) means “add up all the observations”. That is the entire mathematical vocabulary needed for the next three lessons.

The three centres

The mean (arithmetic average):

mean = SUM(x) / n

Add everything up, divide by how many there were. In words: the value each observation would have if the total were shared out equally. It uses every observation, which is its strength and its weakness — one extreme value pulls it.

The median: sort the values from smallest to largest and take the one in the middle. If n is odd there is a single middle value at position (n + 1) / 2. If n is even there are two middle values and the median is their average. The median cares only about order, not magnitude — you can make the largest value a hundred times larger and the median will not move at all.

The mode: the value that occurs most often. It is the only one of the three that works on things that are not numbers at all — the modal acquisition channel, the modal country, the modal plan.

Analogy

Ten people are in a room and their heights are being summarised. The mean is what you get if you melt everyone down and re-pour them into ten identical moulds. The median is the person you would have to look straight at if you lined everyone up shortest to tallest and stood in the middle. The mode is the height you would guess if you had to guess and were rewarded only for being exactly right. Now let a two-and-a-half-metre basketball player walk in. The mean rises for everybody; the median shifts by one place in the queue; the mode does not move at all.

All three in one query

Retail DuckDB provides all three directly. Prices on order lines:

sqlthree_centres.sql
SELECT COUNT(*)                          AS n,
       ROUND(AVG(unit_price), 2)         AS mean_price,
       MEDIAN(unit_price)                AS median_price,
       MODE(unit_price)                  AS modal_price
FROM order_items;
Result
nmean_pricemedian_pricemodal_price
30235.67199.00129.00

Three legitimate answers to “what is a typical price on an order line?”, and they differ by 107. If someone asks “what do people usually pay?” the honest reply is a question: do you mean the shared-out average (235.67), the middle of the range (199.00), or the price that comes up most often (129.00)?

Building the median by hand

The built-in is convenient, but you should be able to construct the median yourself — partly because some engines lack the function, and mostly because doing it once makes the definition permanent.

sqlmedian_from_scratch.sql
-- Number the rows in sorted order, then pick the middle one (odd n)
-- or average the two middle ones (even n). '//' is integer division.
WITH ranked AS (
  SELECT unit_price,
         ROW_NUMBER() OVER (ORDER BY unit_price) AS pos,
         COUNT(*)    OVER ()                     AS n
  FROM order_items
)
SELECT AVG(unit_price) AS median_from_scratch
FROM ranked
WHERE pos IN ( (n + 1) // 2, (n + 2) // 2 );
Result
median_from_scratch
199.0

Why the two expressions (n+1)//2 and (n+2)//2? With n = 30 they evaluate to 15 and 16 — the two middle positions, which get averaged. With an odd n, say 31, they both evaluate to 16 — the same single row, so the average of one value is that value. One WHERE clause handles both cases without a CASE expression.

The mode is even simpler: count and sort.

sqlmode_from_scratch.sql
SELECT unit_price, COUNT(*) AS times_sold
FROM order_items
GROUP BY unit_price
ORDER BY times_sold DESC, unit_price
LIMIT 4;
Result
unit_pricetimes_sold
129.004
139.003
199.003
219.003

This exposes something MODE() hides: the winner leads by a single occurrence, and three values are tied behind it. A mode with a margin of one is not a stable fact about the business — one more sale of a 139.00 item and the answer changes. Inference Any statistic whose value can be flipped by one row deserves to be reported with its runner-up beside it.

Where the mean lies

Finance Salaries are the classic case, because organisations are pyramids and pyramids have a point at the top.

sqlsalary_centres.sql
SELECT COUNT(*)                    AS n,
       ROUND(AVG(salary), 2)       AS mean_salary,
       MEDIAN(salary)              AS median_salary,
       MAX(salary)                 AS max_salary
FROM employees;
Result
nmean_salarymedian_salarymax_salary
10141300.0133000.00240000.00

The mean sits 8,300 above the median. Seven of the ten employees earn less than the mean. If you told a new analyst “the average salary here is 141,300” you would have told them something true and misleading in the same breath, because the single 240,000 executive salary is doing the lifting.

salary, sorted ascending          mean = 141,300
                                  median = 133,000
 95k 98k 112k 122k 128k | 138k 140k 165k 175k        240k
  |   |    |    |    |     |    |    |    |            |
  +---+----+----+----+-----+----+----+----+------------+
                    ^median      ^mean
                    |____________|
              the gap IS the skew: one big value
              pulls the mean right, leaves median put
Common pitfall — averaging an average

You have average order value per country and you want the overall average, so you average the averages. This is wrong unless every country has exactly the same number of orders. A country with 2 orders gets the same weight as one with 2,000. The correct overall mean is SUM(all values) / COUNT(all values) computed on the raw rows, or a weighted mean SUM(group_mean * group_n) / SUM(group_n) if you only have summaries. This error is extremely common because pre-aggregated tables invite it: the averages are sitting right there, and averaging them takes one line.

Choosing, and when not to use each

MeasureUse whenDo NOT use when
MeanYou need totals to reconcile (mean × n = total), the distribution is roughly symmetric, and every observation should countThe data is skewed or has extreme values — salaries, revenue per customer, page-load times, insurance claims
MedianYou want “the typical case” and refuse to let extremes distort it; reporting incomes, response times, order valuesYou need to reconcile to a total, or the data is bimodal — the median of a two-humped distribution sits in the empty valley between the humps and describes nobody
ModeThe variable is categorical (channel, plan, country) or you need “the most common single choice”Values are continuous and rarely repeat — the mode of 30 distinct prices is arbitrary; also when the lead over the runner-up is one or two rows

Open Which measure a business “should” report is genuinely contested and often political. Median customer value flatters a business with a long tail of small customers; mean customer value flatters one with a few whales. Neither is dishonest; publishing only the one that suits the argument is. The defensible practice is to publish both and let the gap between them speak.

Interview angle

“Would you report mean or median order value, and why?” is a fast filter. The answer the interviewer wants is not a preference but a rule tied to purpose: median for “what does a typical customer experience?”, mean for “what do we expect to bank per order?” because only the mean multiplies back to the total. Candidates who add “and I'd show the gap between them, since it's a free read on skew” are demonstrating that they understand the shape of the distribution, not just two function names.

Exercise 9.2

SaaS The subscriptions table holds monthly recurring revenue (mrr) per customer. Produce one row per plan showing the count, mean and median MRR, plus the mean-minus-median gap. Then state which plan, if any, has a centre you would be uncomfortable summarising with a single number.

Show solution
sqlmrr_centres_by_plan.sql
SELECT plan,
       COUNT(*)                            AS n,
       ROUND(AVG(mrr), 2)                  AS mean_mrr,
       MEDIAN(mrr)                         AS median_mrr,
       ROUND(AVG(mrr) - MEDIAN(mrr), 2)    AS mean_minus_median
FROM subscriptions
GROUP BY plan
ORDER BY mean_mrr DESC;

Every plan returns a gap of zero, because within a plan the price is fixed — each plan has exactly one distinct MRR value. That is the useful discovery: for this data, plan explains MRR completely, so “average MRR per plan” carries no information beyond the price list. The number worth computing is instead the blended average across all subscriptions, which mixes the three prices in whatever proportion customers happen to have chosen — and that number will move whenever the plan mix moves, even if no price ever changes. Reporting a blended average without the mix beside it is how “average revenue per user is falling” gets mistaken for a pricing problem when it is a mix shift.

Knowledge check

A support team's ticket resolution times are: 5, 6, 6, 7, 8, 9, 240 minutes. Which summary best answers “how long does a ticket usually take?”

Knowledge check

Country A has 3 orders averaging 500. Country B has 300 orders averaging 100. What is the overall average order value?

Key takeaway

Mean = share the total out equally; reconciles to totals, but one extreme value drags it. Median = the middle of the queue; immune to extremes, but does not multiply back to a total. Mode = the most common value; the only one that works on categories. The gap between mean and median is a free measurement of skew — when it is large, no single number is an honest summary and you must show the spread as well.

Lesson 9.3·14 min read

Variance & Standard Deviation From Scratch

Build the single most important number in statistics out of subtraction and division — and understand why it divides by n − 1.

Two teams both average 141,300 in salary. In the first, everybody earns between 138,000 and 145,000. In the second, half earn 95,000 and half earn 187,600. The mean is identical and the two situations could not be more different — different retention risk, different budgeting, different conversations at review time.

You need a number that tells them apart. The obvious idea is “average distance from the mean”, and the obvious idea fails immediately, in a way worth seeing.

Why the obvious formula collapses

Take five values: 2, 4, 4, 6, 9. The mean is 5. Now list the deviations — each value minus the mean:

value       2     4     4     6     9      mean = 5
deviation  -3    -1    -1    +1    +4
                                          SUM = 0

The deviations sum to zero. They always do, for every dataset that has ever existed, because that is precisely what the mean is: the balance point where the negative deviations exactly cancel the positive ones. So the average deviation is always zero and tells you nothing.

There are two ways out. You can take absolute values and ignore the signs, which gives the mean absolute deviation — a perfectly sensible measure that statistics mostly does not use. Or you can square each deviation, which also kills the signs (a negative squared is positive) and has three properties absolute value lacks: it is smooth and differentiable, it makes large deviations count disproportionately, and it decomposes cleanly when you split data into groups — the property that ANOVA and regression are built on.

Squaring wins, and the result is variance:

variance = SUM( (x - mean)^2 ) / (n - 1)

Symbol by symbol: x is one observation; mean is the arithmetic average of all of them; (x - mean) is how far that observation sits from the centre; ^2 means multiply it by itself; SUM(...) adds those squared distances across all observations; n is the number of observations. The result is in squared units — squared pounds, squared minutes — which is meaningless to a human. So take the square root and get back to real units:

standard deviation = SQRT(variance)

Standard deviation answers “how far from the average is a typical observation?” in the original units of the data. It is the number to reach for.

Analogy

Darts. The mean is where the centre of your cluster landed — your aim. The standard deviation is how tight the cluster is — your consistency. A player whose darts land all round the board but average out on the bullseye has good aim and terrible consistency, and reporting only the average would make them look like a champion. Squaring the distances is the scoring rule that says a dart that misses by 20 cm is far worse than four darts that miss by 5 cm — which, if you have ever played, is exactly right.

Computing it with nothing but arithmetic

Finance Here is the formula transcribed directly into SQL. A CTE computes the mean and the count, a cross join makes those available on every row, and the arithmetic follows the formula left to right.

sqlvariance_from_scratch.sql
-- variance = SUM((x - mean)^2) / (n - 1)
WITH stats AS (
  SELECT AVG(salary) AS mu, COUNT(*) AS n FROM employees
)
SELECT s.n,
       ROUND(s.mu, 2)                                                   AS mean_salary,
       ROUND(SUM((e.salary - s.mu) * (e.salary - s.mu)), 2)             AS sum_sq_dev,
       ROUND(SUM((e.salary - s.mu) * (e.salary - s.mu)) / (s.n - 1), 2) AS variance_n_minus_1,
       ROUND(SUM((e.salary - s.mu) * (e.salary - s.mu)) / s.n, 2)       AS variance_n
FROM employees AS e
CROSS JOIN stats AS s
GROUP BY s.n, s.mu;
Result
nmean_salarysum_sq_devvariance_n_minus_1variance_n
10141300.016878100000.01875344444.441687810000.0

Now confirm the built-ins agree, which also names the two variants:

sqlvariance_builtins.sql
SELECT ROUND(VAR_SAMP(salary), 2)    AS var_samp,    -- divides by n - 1
       ROUND(VAR_POP(salary), 2)     AS var_pop,     -- divides by n
       ROUND(STDDEV_SAMP(salary), 2) AS sd_samp,
       ROUND(STDDEV_POP(salary), 2)  AS sd_pop
FROM employees;
Result
var_sampvar_popsd_sampsd_pop
1875344444.441687810000.043305.2541082.96

VAR_SAMP matches the n − 1 column exactly and VAR_POP matches the n column exactly. The standard deviations are the square roots: a typical salary sits about 43,305 away from the mean of 141,300 — which, on a mean of 141,300, is a very wide spread indeed.

Why n − 1 — Bessel's correction

This is the question everyone asks and almost nobody is answered properly, so here is the argument in full.

You want the variance of the population — all employees this company will ever have, all orders it will ever take. You do not have the population; you have a sample. To compute deviations you need a centre, and you do not know the population's true mean either, so you substitute the sample mean.

And here is the catch. The sample mean is, by construction, the number that makes SUM((x - mean)^2) as small as it can possibly be for your particular sample. Pick any other centre — including the true population mean — and the sum of squared deviations gets bigger. Not sometimes: always.

  sum of squared deviations, plotted against the centre you use

  larger  |  \                                    /
          |   \                                  /
          |    \                                /
          |     \___                       ____/
          |         \___              ____/
  smaller |             \____________/
          +-------------------------------------------
                         ^        ^
                  sample mean   true population mean
                  (the minimum) (always >= the minimum)

  Using the sample mean UNDERSTATES the true spread, every time.

So dividing the sum of squared deviations by n gives an answer that is systematically too small — it is biased downwards. Bessel's correction divides by n − 1 instead, which makes the result slightly larger and, it can be proved, exactly right on average across all possible samples.

The intuitive reading of n − 1 is degrees of freedom: how many of your observations were free to be anything. If you know the mean of ten numbers and you know nine of the numbers, the tenth is forced — it is whatever makes the arithmetic work. So ten observations carry only nine independent pieces of information about spread, once you have spent one on estimating the centre. You divide by the information you actually have.

Fact The correction matters most when n is small. At n = 10, dividing by 9 instead of 10 inflates the variance by about 11%. At n = 10,000 the difference is 0.01% and utterly irrelevant. This is why the choice is agonised over in textbooks and ignored in production analytics on large tables — both responses are correct in their own context.

💡Intuition — which one do I want?

Ask: is this dataset the entire thing I care about, or a glimpse of something larger? Marks of the 30 students in a class, when the question is about that class → population, VAR_POP. The same 30 students as a stand-in for “students like these” → sample, VAR_SAMP. In business analytics the answer is almost always sample, because last quarter's orders are a glimpse of an ongoing process, not a closed universe. When in doubt use VAR_SAMP: on any dataset large enough for the choice to matter to a decision, the two are indistinguishable anyway.

Making spread comparable across scales

A standard deviation of 43,305 is meaningless in isolation — is that a lot? It depends entirely on the scale of the thing being measured. The coefficient of variation divides the standard deviation by the mean to give a unitless ratio, usually shown as a percentage:

coefficient of variation = 100 * standard deviation / mean

sqlcoefficient_of_variation.sql
SELECT department,
       COUNT(*)                                                       AS n,
       ROUND(AVG(salary), 2)                                          AS mean_salary,
       ROUND(STDDEV_SAMP(salary), 2)                                  AS sd_salary,
       ROUND(100 * STDDEV_SAMP(salary) / NULLIF(AVG(salary), 0), 1)   AS cv_pct
FROM employees
GROUP BY department
ORDER BY department;
Result
departmentnmean_salarysd_salarycv_pct
Analytics5121600.028236.523.2
Engineering4141250.025863.4218.3
Executive1240000.0NULLNULL

Two things to notice. First, Analytics salaries are more spread out relative to their level (23.2%) than Engineering's (18.3%), even though Engineering's mean is higher — the CV makes that comparison possible where the raw standard deviations nearly matched. Second, the Executive row returns NULL, because n = 1 means n − 1 = 0 and dividing by zero is undefined. That NULL is the correct answer: you genuinely cannot measure spread from one observation. The NULLIF(AVG(salary), 0) guard is there for a different reason — it protects the CV from a group whose mean happens to be zero, which cannot occur for salaries but certainly can for profit or net change columns.

Common pitfall — the “computational” formula and floating point

Older texts give variance = (SUM(x*x) - n*mean*mean) / (n - 1) because it needs only one pass over the data. It is algebraically identical and numerically dangerous: with large values and small spread it subtracts two huge, nearly equal numbers, and floating-point arithmetic loses most of the significant digits in the process — a phenomenon called catastrophic cancellation. It can even return a small negative variance, which is impossible by definition. Modern engines use numerically stable algorithms internally, so VAR_SAMP is both easier and safer than hand-rolling the one-pass form. Write the two-pass version when teaching, call the built-in in production.

When standard deviation is the wrong summary

  • Heavily skewed data. On revenue-per-customer with a few whales, the standard deviation is dominated by the tail and can exceed the mean, implying a range that includes impossible negative values. Report the interquartile range instead (Lesson 9.4).
  • Ordinal data. Survey answers coded 1–5 are ranks, not quantities: the gap between “agree” and “strongly agree” is not guaranteed to equal the gap between “neutral” and “agree”. Squaring those differences assumes a scale that is not there.
  • Bounded proportions near 0 or 1. A conversion rate of 0.5% cannot vary symmetrically — it cannot go below zero. Standard deviation implies a symmetry the data does not have.
  • Tiny samples. With n below about five, the standard deviation is itself so unstable that quoting it to two decimal places is false precision.
In the field

Manufacturing Process control is where standard deviation earns its keep. A production line's output is measured continuously, and the control limits are set at the process mean plus and minus three standard deviations, computed from a period when the line was known to be running well. A reading outside those limits triggers investigation. Notice what this needs: a stable baseline period to estimate the spread from. Teams that recompute the limits from a rolling recent window discover, painfully, that the limits widen to accommodate whatever has gone wrong — and the alarm never fires.

Interview angle

“Why does sample variance divide by n − 1?” is asked constantly and answered badly. “Bessel's correction” is a name, not an answer. The answer that lands: “because we measure deviations from the sample mean, and the sample mean is the value that minimises those squared deviations for this particular sample. So the sum of squares is systematically too small, and dividing by n − 1 rather than n corrects that downward bias. Equivalently, one degree of freedom was spent estimating the mean.” A strong follow-up you can volunteer: it barely matters at large n, which is why nobody argues about it on a billion-row table.

Exercise 9.3

Advertising For each channel in ad_spend, compute the mean, sample standard deviation and coefficient of variation of cost, without using STDDEV_SAMP or VAR_SAMP — build it from SUM, AVG, COUNT and SQRT. Then say which channel's spend is less predictable.

Show solution
sqlchannel_spread_manual.sql
WITH per_channel AS (
  SELECT channel, AVG(cost) AS mu, COUNT(*) AS n
  FROM ad_spend
  GROUP BY channel
)
SELECT p.channel,
       p.n,
       ROUND(p.mu, 2)                                                    AS mean_cost,
       ROUND(SQRT(SUM((a.cost - p.mu) * (a.cost - p.mu)) / (p.n - 1)), 2) AS sd_manual,
       ROUND(100 * SQRT(SUM((a.cost - p.mu) * (a.cost - p.mu)) / (p.n - 1))
             / NULLIF(p.mu, 0), 1)                                        AS cv_pct
FROM ad_spend AS a
JOIN per_channel AS p ON p.channel = a.channel
GROUP BY p.channel, p.n, p.mu
ORDER BY cv_pct DESC;

The join brings each row's group mean alongside it, exactly as the cross join did for the single-group case; the GROUP BY then collapses back to one row per channel. Comparing the two cv_pct values tells you which channel's monthly spend swings more relative to its own level — a comparison the raw standard deviations cannot make when the two channels spend different amounts. With only six months per channel, treat the ranking as a prompt to ask why, not as an established fact: at n = 6 the standard deviation is itself estimated with substantial error.

Knowledge check

Why can't we just average the raw deviations (x - mean) to measure spread?

Knowledge check

A group contains exactly one row. What does STDDEV_SAMP return, and why?

Key takeaway

Variance = average squared distance from the mean; standard deviation = its square root, back in real units. Squaring exists because raw deviations always cancel to zero. Dividing by n − 1 exists because deviations are measured from the sample mean, which sits at the minimum of the squared-error curve and therefore understates the true spread — you have n − 1 independent pieces of information left after estimating the centre. Divide by the mean to get a coefficient of variation when you need to compare spread across different scales.

Lesson 9.4·12 min read

Percentiles, Quartiles & the Five-Number Summary

Describe a distribution with five numbers that no single outlier can distort — and know exactly what a “p95” is being computed from.

An engineering team promises that page loads complete in under 400 ms. The mean load time is 310 ms, so the promise looks kept. Then complaints arrive. The mean is fine because most requests are fast; the complaints come from the 5% of requests that take three seconds, and the mean does not know they exist as a group.

The tool for this is the percentile. The pth percentile is the value below which p per cent of the observations fall. The 95th percentile of load time — “p95” — is the number such that 95% of requests were faster and 5% were slower. It speaks directly about the tail, which is where users notice things.

Definitions, and the awkward bit

Percentiles are just the median idea generalised. Sort the data; walk p per cent of the way along; report the value you land on. The median is the 50th percentile. Three particular percentiles get their own names because they cut the data into four equal parts:

  • Q1, the first quartile, is the 25th percentile — a quarter of the data is below it.
  • Q2 is the 50th percentile, the median.
  • Q3, the third quartile, is the 75th percentile — three quarters below it.

The distance between them is the interquartile range:

IQR = Q3 - Q1

The IQR is the width of the middle half of the data. It is the robust counterpart to the standard deviation: throw away the most extreme 25% at each end and the IQR does not change at all.

Now the awkward bit, which trips people up in production. “Walk 25% of the way along” usually lands between two rows. With 30 sorted values, the position for Q1 is 0.25 × (30 - 1) + 1 = 8.25 — between the 8th and 9th value. There are two reasonable responses, and SQL gives you both:

  • Continuous (QUANTILE_CONT): interpolate between the neighbours. Position 8.25 gives the 8th value plus a quarter of the gap to the 9th. The answer may be a value that appears nowhere in the data.
  • Discrete (QUANTILE_DISC): round to an actual row and return a value that really occurred.

Fact These two can return different numbers for the same data and the same percentile, and neither is wrong. This is the single most common cause of “our p95 doesn't match theirs” between two tools.

Analogy

A school reports exam results. The mean is the average mark. The percentile is where you came in the queue: “you are at the 90th percentile” means nine out of ten pupils scored below you. Notice that percentile is immune to how the marks were scaled — if the examiner adds 10 marks to everyone, or doubles all marks, every pupil's percentile is unchanged, while the mean moves. That is exactly the robustness property: percentiles depend only on order.

The five-number summary

E-commerce Five numbers — minimum, Q1, median, Q3, maximum — describe a distribution's location, spread and asymmetry in a single row.

sqlfive_number_summary.sql
SELECT MIN(unit_price)                        AS min_price,
       QUANTILE_CONT(unit_price, 0.25)        AS q1,
       QUANTILE_CONT(unit_price, 0.50)        AS median_price,
       QUANTILE_CONT(unit_price, 0.75)        AS q3,
       MAX(unit_price)                        AS max_price,
       QUANTILE_CONT(unit_price, 0.75)
         - QUANTILE_CONT(unit_price, 0.25)    AS iqr
FROM order_items;
Result
min_priceq1median_priceq3max_priceiqr
129.00149.00199.00299.00459.00150.00

Read it as a shape. The bottom quarter of prices is squeezed into a 20-wide band (129 to 149). The top quarter is spread across 160 (299 to 459). The median sits closer to Q1 than to Q3. Every one of those observations says the same thing: the distribution leans right — lots of cheap items, a thinner tail of expensive ones. That is the diagnosis Lesson 9.7 will put a number on.

  the five-number summary as a box plot

   min      Q1   median        Q3            max
   129     149    199         299            459
    |-------[=====|============]--------------|
    |       |<---- IQR = 150 --->|            |
    |                                         |
    +--- lower whisker            upper whisker ---+
        (short: values bunched)   (long: values spread)

  box  = middle 50% of the data
  line inside box = median
  whiskers = the rest of the range

Percentiles from scratch

To see what the built-in is doing, compute the positions yourself. The standard linear-interpolation rule places the pth percentile at sorted position p × (n - 1) + 1.

sqlpercentile_positions.sql
-- Where in the sorted list do Q1 and Q3 actually fall?
WITH ranked AS (
  SELECT unit_price,
         ROW_NUMBER() OVER (ORDER BY unit_price) AS pos,
         COUNT(*)    OVER ()                     AS n
  FROM order_items
)
SELECT n,
       ROUND(0.25 * (n - 1) + 1, 3) AS q1_position,
       ROUND(0.75 * (n - 1) + 1, 3) AS q3_position
FROM ranked
LIMIT 1;
Result
nq1_positionq3_position
308.2522.75

Q1 lands a quarter of the way from the 8th value to the 9th; Q3 lands three quarters of the way from the 22nd to the 23rd. QUANTILE_CONT does that interpolation; QUANTILE_DISC would return an actual 8th-or-9th value instead. There is also NTILE(4), a window function that stamps each row with a bucket number 1–4 — useful for segmenting rows into quartile groups, but note it is a different job: NTILE labels rows, QUANTILE_CONT returns a cut point.

Common pitfall — averaging percentiles

You have p95 latency per hour and want p95 for the day, so you average the 24 hourly figures. This is not the daily p95 and can be badly wrong. Percentiles are not additive: the 95th percentile of a combined set depends on the full distribution, not on the sub-percentiles. If one hour had ten times the traffic, its tail dominates the day and averaging hides that entirely. The only correct route is to recompute from the raw observations, or from a data structure designed to merge — a histogram or a sketch. Inference This is why serious monitoring systems store latency histograms rather than pre-computed percentiles: percentiles cannot be re-aggregated, histograms can.

When not to use percentiles

  • Small n. A p95 computed from 20 rows is determined by one observation, and it will lurch whenever that observation changes. As a rough working rule, you need considerably more rows than 1 / (1 - p) for the estimate to be stable — and even then it is the noisiest statistic on your dashboard.
  • When totals must reconcile. Percentiles do not sum, average or decompose. If finance needs the numbers to add up, you need means and totals.
  • When you need a full picture. Two very different distributions can share a five-number summary. If the shape matters, draw the histogram (Lesson 9.5).
  • On categorical data. Percentiles need an ordering. There is no 75th percentile of “country”.
In the field

Telecommunications Service level agreements are written in percentiles precisely because means are gameable: a network can hit a mean latency target while a minority of customers get an unusable service. Two details decide whether such an SLA means anything. First, the window — p95 per minute, per hour and per month give very different guarantees, and the longer the window the more a bad hour is diluted. Second, the interpolation method, which must be written into the contract or the two parties will compute different numbers from identical data and each be correct.

Interview angle

“Compute the median without using a median function” is a classic SQL exercise, testing whether you can combine ROW_NUMBER() with a count. The follow-up that separates candidates: “now do it per group.” The answer is to partition the row numbering (ROW_NUMBER() OVER (PARTITION BY g ORDER BY x)) and the count (COUNT(*) OVER (PARTITION BY g)) by the same key, then apply the same middle-position filter. A second favourite: “why might your p99 differ from the monitoring tool's?” — interpolation method, aggregation window, and whether the tool is estimating from a sketch rather than exact data.

Exercise 9.4

Supply chain Produce a five-number summary of order-line value (quantity × unit_price) for each product category name, along with the row count. Order by median descending. Then explain why one of the summaries should not be trusted.

Show solution
sqlfive_number_by_category.sql
SELECT c.category_name,
       COUNT(*)                                                    AS n_lines,
       MIN(oi.quantity * oi.unit_price)                            AS min_val,
       ROUND(QUANTILE_CONT(oi.quantity * oi.unit_price, 0.25), 2)  AS q1,
       ROUND(QUANTILE_CONT(oi.quantity * oi.unit_price, 0.50), 2)  AS median_val,
       ROUND(QUANTILE_CONT(oi.quantity * oi.unit_price, 0.75), 2)  AS q3,
       MAX(oi.quantity * oi.unit_price)                            AS max_val
FROM order_items AS oi
JOIN products   AS p ON p.product_id  = oi.product_id
JOIN categories AS c ON c.category_id = p.category_id
GROUP BY c.category_name
ORDER BY median_val DESC;

Several categories return only two or three lines. A five-number summary of three observations is theatre: minimum, median and maximum are simply the three values, and Q1 and Q3 are interpolations between them that carry no independent information. The honest reading is that n_lines must be shown next to every summary, and any category below roughly ten lines should be reported as raw values rather than dressed up as a distribution. Notice that the query itself is correct — the failure is one of interpretation, which is the failure mode statistics is most prone to.

Knowledge check

Your API's p99 latency is 2,000 ms and the mean is 120 ms. What does this combination tell you?

Key takeaway

Percentiles describe position in the sorted order, so extreme values cannot distort them. The five-number summary — min, Q1, median, Q3, max — gives location, spread and asymmetry in one row, and IQR = Q3 - Q1 is the robust alternative to standard deviation. Two operational rules: percentiles cannot be averaged or re-aggregated, and always publish which interpolation method produced them.

Lesson 9.5·13 min read

Distributions: Shape, Histograms & Binning in SQL

Stop summarising a column and start looking at it — build histograms with nothing but FLOOR and GROUP BY, and read what the shape is telling you.

Everything so far has compressed a column into one or five numbers. Compression loses information, and sometimes it loses the point. Two columns can share a mean, a median, a standard deviation and a five-number summary while describing completely different worlds — one a single hump, the other two separate populations that were never the same thing.

A distribution is simply the answer to “how often does each value, or each range of values, occur?” A histogram is a picture of it: chop the range into intervals called bins, count how many observations land in each, and draw a bar per bin. You do not need a plotting library. You need FLOOR, GROUP BY, and a function that repeats a character.

The binning arithmetic

The whole trick is one expression:

bin_floor = FLOOR(x / bin_width) * bin_width

x is the observation, bin_width is how wide you want each interval, and FLOOR rounds down to the nearest whole number. Divide by the width, throw away the fractional part, multiply back. A value of 349 with a width of 100 becomes FLOOR(3.49) × 100 = 300, so it lands in the “300 to 399” bin. Every value in that range collapses to the same label, and then GROUP BY counts them.

sqlhistogram_order_lines.sql
-- A text histogram of order-line values, 100-wide bins.
WITH lines AS (
  SELECT quantity * unit_price AS amount FROM order_items
),
binned AS (
  SELECT FLOOR(amount / 100) * 100 AS bin_floor,
         COUNT(*)                  AS freq
  FROM lines
  GROUP BY 1
)
SELECT CAST(bin_floor AS INTEGER)       AS bin_from,
       CAST(bin_floor + 100 AS INTEGER) AS bin_to,
       freq,
       REPEAT('#', freq)                AS bar
FROM binned
ORDER BY bin_floor;
Result
bin_frombin_tofreqbar
10020011###########
2003009#########
3004008########
4005002##

That is a distribution, drawn in SQL. It slopes downward to the right: most order lines are cheap, progressively fewer are expensive. The single most valuable habit this lesson can give you is running this query on any unfamiliar column before computing a mean on it.

Analogy

A histogram is a car park at the end of the day. Each bay is a bin; each car is an observation. Standing at one end you can see instantly whether cars cluster near the entrance, spread evenly, or form two clumps with an empty stretch between them. The average parking position is a single point on the tarmac and might sit in the empty stretch, describing a place where no car ever parked. The bay size matters too: mark out enormous bays and everything looks like one clump; mark out bays the width of a wheel and every car is alone.

Empty bins are data

The query above only returns bins that contain something. That is a real problem: a gap in the middle of a distribution is often the most interesting feature in it, and if the row is missing the reader's eye slides over it. The fix is to generate the full set of bins and left-join the counts onto it.

Finance Salaries, in 25,000-wide bands:

sqlhistogram_with_empty_bins.sql
WITH binned AS (
  SELECT FLOOR(salary / 25000) * 25000 AS bin_floor, COUNT(*) AS freq
  FROM employees
  GROUP BY 1
),
all_bins AS (                       -- 75k through 225k, inclusive
  SELECT CAST(b.gs * 25000 AS INTEGER) AS bin_floor
  FROM generate_series(3, 9) AS b(gs)
)
SELECT a.bin_floor                                   AS bin_from,
       a.bin_floor + 25000                           AS bin_to,
       COALESCE(bi.freq, 0)                          AS freq,
       REPEAT('#', CAST(COALESCE(bi.freq, 0) AS INTEGER)) AS bar
FROM all_bins AS a
LEFT JOIN binned AS bi ON bi.bin_floor = a.bin_floor
ORDER BY a.bin_floor;
Result
bin_frombin_tofreqbar
750001000002##
1000001250002##
1250001500003###
1500001750001#
1750002000001#
2000002250000
2250002500001#

The empty 200,000–225,000 band is the story. There is a gap, and then one person on the far side of it. Without the empty row the table reads as a gentle slope; with it, the executive salary is visibly detached from the rest of the distribution rather than being the top of a ramp. That is a qualitative difference and it came entirely from showing a zero.

The shapes worth recognising

SYMMETRIC (one hump, balanced)      RIGHT-SKEWED (long tail to the right)
        ####                          ########
      ########                        ######
      ########                        ####
    ############                      ###
  ################                    ##  #   #      #
  mean = median = mode                mode < median < mean

LEFT-SKEWED (long tail to the left)  BIMODAL (two populations mixed)
              ########                ######        ######
            ##########                ######        ######
          ############                ####          #####
       #     #    #####               ###            ###
  mean < median < mode                mean and median land in the VALLEY
                                      -- describing nobody at all

UNIFORM (flat)                       HEAVY-TAILED (extreme values, far out)
  ####  ####  ####  ####  ####        ##########
  ####  ####  ####  ####  ####        #####
  ####  ####  ####  ####  ####        ##
  every value equally likely            #      #          #        #
                                      mean is unstable; it jumps when
                                      one new extreme value arrives

Four diagnoses to make from a shape, before any test:

  1. Symmetric and single-humped? Mean and median agree, standard deviation is meaningful, most classical methods apply.
  2. Skewed? Mean and median disagree. Prefer the median, prefer the IQR, and be suspicious of any test that assumes symmetry.
  3. Bimodal? You have two populations glued together — mobile and desktop, trial and paid, weekday and weekend. Do not summarise; split, then summarise each. This is the single highest-value discovery a histogram makes.
  4. Heavy-tailed? A handful of enormous values. The mean is unstable and outlier handling (Lesson 9.8) becomes a required decision rather than an optional one.
Common pitfall — bin width changes the conclusion

The same data binned three ways can look unimodal, bimodal or like noise. Very wide bins smooth two humps into one and hide the finding; very narrow bins turn ordinary randomness into a jagged mountain range and invent findings that are not there. There is no automatic correct width, so the defensible practice is to look at two or three widths before concluding anything about shape, and to state in the write-up which width the published chart used. A shape that survives every reasonable bin width is real; one that appears at exactly one width is an artefact of your choice.

Non-uniform bins and cumulative views

Equal-width bins are wrong when the data spans orders of magnitude. If most customers spend under 200 and a few spend 50,000, equal bins put nearly everyone in the first bar. Two alternatives:

  • Hand-cut bins with CASE, chosen to match business meaning — “under 100”, “100–250”, “250–500”, “500+”. Readable by non-analysts, and the boundaries can carry commercial meaning like a free-shipping threshold.
  • Equal-count bins via NTILE(10), which puts the same number of rows in each bucket and lets the boundaries fall where they may. Useful for ranking customers into deciles.

A cumulative distribution answers a different and often better question: “what fraction of observations are at or below this value?”

sqlcumulative_distribution.sql
-- Running share of order lines at or below each 100-wide threshold.
WITH lines AS (
  SELECT quantity * unit_price AS amount FROM order_items
),
binned AS (
  SELECT FLOOR(amount / 100) * 100 AS bin_floor, COUNT(*) AS freq
  FROM lines GROUP BY 1
)
SELECT CAST(bin_floor + 100 AS INTEGER)                    AS at_or_below,
       freq,
       SUM(freq) OVER (ORDER BY bin_floor)                 AS running_count,
       ROUND(100.0 * SUM(freq) OVER (ORDER BY bin_floor)
             / SUM(freq) OVER (), 1)                       AS running_pct
FROM binned
ORDER BY bin_floor;
Result
at_or_belowfreqrunning_countrunning_pct
200111136.7
30092066.7
40082893.3
500230100.0

Two thirds of order lines are under 300. This form is immune to the bin-width criticism in one important way: the running percentage at any threshold is a fact about the data, not about your binning choice.

When not to bother

  • Fewer than about 20 observations. A histogram of 8 rows shows the accidents of those 8 rows. List the values instead — you can read them all.
  • High-cardinality categories. A “histogram” of 40,000 product IDs is a frequency table with 40,000 rows. Take the top 20 and lump the rest into “other”.
  • When you already know the shape and only need to monitor it. Tracking the median and the IQR over time is cheaper and comparable across periods.
In the field

Product analytics A team reports that average session duration has fallen and concludes engagement is dropping. A histogram shows two humps: a large one near zero seconds and a normal-looking one around four minutes. The near-zero hump is bot traffic and mis-fired tracking events, and it has been growing. Engagement among real sessions is unchanged; the mixture changed. No amount of staring at the mean would have found this, and the fix was a filter, not a product change. This is the pattern to remember: a moving average with a stable median usually means the mixture moved, not the behaviour.

Interview angle

“Write a query that buckets values into ranges and counts them” appears constantly, and the expected answer is either the FLOOR(x / w) * w idiom or a CASE expression. The follow-up worth preparing: “how do you show bins with zero rows?” — generate the bin list (generate_series or a values list) and LEFT JOIN the counts onto it. Candidates who volunteer “and I'd check the shape at more than one bin width before drawing conclusions” signal that they have actually used histograms rather than memorised one.

Exercise 9.5

Digital media Build a histogram of ad_spend.cost using 1,000-wide bins, showing every bin from 1,000 to 5,000 including empty ones, with a text bar. Then add a second query that splits the same histogram by channel, and say what the split reveals that the pooled version hides.

Show solution
sqlspend_histogram.sql
WITH binned AS (
  SELECT FLOOR(cost / 1000) * 1000 AS bin_floor, COUNT(*) AS freq
  FROM ad_spend GROUP BY 1
),
all_bins AS (
  SELECT CAST(g.gs * 1000 AS INTEGER) AS bin_floor
  FROM generate_series(1, 4) AS g(gs)
)
SELECT a.bin_floor AS bin_from,
       a.bin_floor + 1000 AS bin_to,
       COALESCE(b.freq, 0) AS freq,
       REPEAT('#', CAST(COALESCE(b.freq, 0) AS INTEGER)) AS bar
FROM all_bins AS a
LEFT JOIN binned AS b ON b.bin_floor = a.bin_floor
ORDER BY a.bin_floor;
sqlspend_histogram_by_channel.sql
SELECT channel,
       CAST(FLOOR(cost / 1000) * 1000 AS INTEGER)       AS bin_from,
       COUNT(*)                                          AS freq,
       REPEAT('#', CAST(COUNT(*) AS INTEGER))            AS bar
FROM ad_spend
GROUP BY channel, 2
ORDER BY channel, bin_from;

The pooled histogram shows a single broad lump between 1,000 and 5,000 and invites the reader to describe “typical monthly spend”. The split version shows the two channels occupying overlapping but distinguishable ranges — and with only six months each, it also shows how thin the evidence is. The general lesson: whenever a pooled distribution looks wide or lumpy, the first thing to try is splitting it by an obvious categorical column. A wide distribution is very often two narrow ones standing next to each other.

Knowledge check

A histogram of checkout completion time shows two clear humps, at 30 seconds and at 4 minutes. What is the best next step?

Key takeaway

A histogram is FLOOR(x / width) * width plus GROUP BY — no charting tool required. Look at the shape before computing anything else, because shape decides which summary is honest and which test is legitimate. Show empty bins, check more than one bin width, and treat two humps as an instruction to split the data rather than a curiosity to describe.

Lesson 9.6·13 min read

The Normal Distribution & Standardisation (z-scores)

Put salaries, click counts and page-load times on one common ruler — and learn precisely how much the famous bell curve is entitled to claim.

Is a salary of 175,000 unusual? Is a campaign with 6,300 clicks unusual? The questions are unanswerable as stated, because “unusual” has no meaning without a scale of comparison. 175,000 is enormous in one company and unremarkable in another; 6,300 clicks is a triumph on one budget and a disaster on ten times that budget.

The fix is standardisation: re-express every value as how many standard deviations it sits from its own mean. That number is called a z-score, and it is unitless — which means a salary z-score and a click-count z-score are directly comparable.

z = (x - mean) / standard_deviation

Symbol by symbol: x is the observation you are scoring; mean is the average of the column it came from; standard_deviation is that column's typical distance from its mean, from Lesson 9.3. Subtracting the mean shifts the centre to zero; dividing by the standard deviation rescales so that “one unit” means “one typical distance”. A z of 0 is exactly average, +1 is one typical step above, −2 is two typical steps below.

Analogy

Two athletes: one ran 10.2 seconds in a sprint, the other threw 68 metres in the javelin. Who performed better? The raw numbers cannot be compared — different units, different directions of “good”. But if you know each event's field average and its typical spread, you can ask “how many typical steps above their field was each?” and the comparison becomes meaningful. That is a z-score, and it is what decathlon scoring exists to approximate. Note what is smuggled in: the comparison is only fair if “typical spread” means the same thing in both events.

z-scores in SQL

Banking Score every employee's salary against the company's own distribution:

sqlz_scores.sql
-- z = (x - mean) / sd, computed once and joined to every row.
WITH s AS (
  SELECT AVG(salary) AS mu, STDDEV_SAMP(salary) AS sd FROM employees
)
SELECT e.full_name,
       e.salary,
       ROUND((e.salary - s.mu) / s.sd, 2) AS z_score
FROM employees AS e
CROSS JOIN s
ORDER BY z_score DESC;
Result
full_namesalaryz_score
Rowan Idris240000.002.28
Tomas Vega175000.000.78
Sena Adeyemi165000.000.55
Wren Abbott140000.00-0.03
Xiulan Zhou138000.00-0.08
Uma Krishnan128000.00-0.31
Viktor Novak122000.00-0.45
Ana Moreau112000.00-0.68
Yusuf Bello98000.00-1.0
Zara Haq95000.00-1.07

One salary is 2.28 typical steps above the mean; nothing else exceeds 1.1 in either direction. That is a far more useful statement than “the top salary is 240,000”, because it is relative to this company's own spread and would carry the same meaning in a company paying ten times as much.

Standardisation also makes different columns comparable. Retail Price and cost are measured on different scales; z-scores put them side by side:

sqltwo_columns_one_ruler.sql
WITH s AS (
  SELECT AVG(unit_price) AS mp, STDDEV_SAMP(unit_price) AS sp,
         AVG(unit_cost)  AS mc, STDDEV_SAMP(unit_cost)  AS sc
  FROM products
)
SELECT p.product_name, p.unit_price, p.unit_cost,
       ROUND((p.unit_price - s.mp) / s.sp, 2) AS z_price,
       ROUND((p.unit_cost  - s.mc) / s.sc, 2) AS z_cost
FROM products AS p
CROSS JOIN s
ORDER BY z_price DESC
LIMIT 6;
Result
product_nameunit_priceunit_costz_pricez_cost
Summit 3P Tent459.00190.001.962.01
Pacer GPS Watch Pro399.00163.001.421.41
Basecamp 2P Tent349.00141.000.960.93
Pacer GPS Watch299.00120.000.500.47
Nimbus -10C Bag249.00101.000.050.05
Stormshell Jacket219.0088.00-0.23-0.23

The two z-columns track each other almost exactly, which tells you the price list is essentially a fixed multiple of cost — a pricing policy, visible without computing a correlation. A product whose z_price sat well above its z_cost would be priced unusually richly for its cost, and would be worth asking about.

The normal distribution, and what it actually promises

The normal distribution (or Gaussian, or bell curve) is one specific symmetric shape: a single hump, tails that thin out fast and smoothly in both directions, fully determined by just two numbers — its mean and its standard deviation.

                    the normal distribution
                          ########
                        ############
                      ################
                    ####################
                 ##########################
             ##################################
       ##########################################
   ------|---------|---------|---------|---------|------
       -2sd      -1sd       mean     +1sd      +2sd

   |<----------- about 68% of values ----------->|   (-1sd to +1sd)
|<-------------- about 95% of values --------------->| (-2sd to +2sd)

Fact If data follows a normal distribution, roughly 68% of values fall within one standard deviation of the mean, roughly 95% within two, and roughly 99.7% within three. This is the empirical rule, and every word of the conditional matters.

Test it on real data:

sqlempirical_rule_check.sql
WITH s AS (
  SELECT AVG(salary) AS mu, STDDEV_SAMP(salary) AS sd FROM employees
)
SELECT COUNT(*)                                                          AS n,
       SUM(CASE WHEN ABS((e.salary - s.mu)/s.sd) <= 1 THEN 1 ELSE 0 END) AS within_1sd,
       SUM(CASE WHEN ABS((e.salary - s.mu)/s.sd) <= 2 THEN 1 ELSE 0 END) AS within_2sd,
       ROUND(100.0 * SUM(CASE WHEN ABS((e.salary - s.mu)/s.sd) <= 1
                              THEN 1 ELSE 0 END) / COUNT(*), 1)          AS pct_within_1sd
FROM employees AS e
CROSS JOIN s;
Result
nwithin_1sdwithin_2sdpct_within_1sd
108980.0

80% within one standard deviation against a predicted 68%. Do not conclude that salaries are non-normal from this: with n = 10, one employee moving across the boundary changes the figure by ten percentage points. The result is entirely consistent with normality and entirely consistent with several other shapes. This is the honest state of affairs with ten observations, and saying so is the skill.

💡Intuition — why normality shows up at all

The normal distribution is not a law of nature, and there is no reason revenue or salaries should follow it. It appears for a specific mechanical reason: when a quantity is the sum of many small independent contributions, its distribution tends towards normal regardless of what the individual contributions look like. Height works that way (many genes and environmental factors adding up). Revenue mostly does not: it is a product of factors and it is bounded below at zero, which produces a right-skewed shape instead. Lesson 9.14 demonstrates the summing mechanism in SQL, and it is the reason so much of statistics works even when raw data is nowhere near normal.

Common pitfall — assuming normality because a formula mentions it

The most damaging misuse of z-scores is applying the “95% within 2 standard deviations” rule to skewed business data and then flagging everything beyond as anomalous. On revenue per customer, where a handful of accounts are legitimately enormous, the mean and the standard deviation are both inflated by those accounts — so the threshold moves out to meet them and the real anomalies hide inside it. Meanwhile, a symmetric threshold on a bounded quantity produces impossible lower limits: mean 40 with a standard deviation of 30 puts the “normal range” floor at −20 orders. Inference Check the histogram first. On skewed data use the IQR method from Lesson 9.8, which makes no symmetry assumption at all.

When not to standardise

  • When the raw units carry the decision. “This customer is 1.8 standard deviations above average spend” is unactionable in a pricing meeting; “this customer spends 4,200 a year” is not. Standardise for comparison, report in units.
  • On heavily skewed data. Both ingredients — the mean and the standard deviation — are distorted by the tail, so the z-scores inherit the distortion. Consider transforming (a log transform often makes multiplicative data roughly symmetric) or use rank-based measures.
  • On very small samples. With n below about 10 the standard deviation is unreliable and no observation can exceed a z of roughly (n-1)/SQRT(n) — so a “3 sigma” rule is mathematically incapable of firing on small groups, no matter how extreme the value.
  • When groups have different spreads and you pooled them. Standardising against a pooled mean and standard deviation mixes two rulers into one and produces scores that mean different things for different rows. Standardise within group.
In the field

Insurance Standardisation is the workhorse of any scoring model that combines unlike inputs — claim frequency, claim size, tenure, region. Without it, whichever variable happens to have the largest numeric range dominates the score for no better reason than its units. Two operational cautions that cause real incidents: the mean and standard deviation must be computed on a frozen training period and stored, not recomputed each run (otherwise the scale silently drifts and yesterday's scores are not comparable with today's), and new data must be scored using those stored parameters rather than its own.

Interview angle

“How would you find unusual values in this column?” invites the z-score answer, and the trap is answering it unconditionally. The strong response asks about the shape first: “if it's roughly symmetric, z-scores beyond about 3; if it's skewed — which revenue and duration columns usually are — the mean and standard deviation are themselves dragged by the outliers, so I'd use the IQR fences instead.” Interviewers are checking whether you know that the tool has a precondition. A good extra: “and I'd compute the threshold on a clean historical window, not on the data I'm screening.”

Exercise 9.6

Advertising Compute a z-score for cost within each channel of ad_spend — that is, each row scored against its own channel's mean and standard deviation, not the pooled ones. Return channel, date, cost and the within-channel z. Then explain why the within-channel version is the right one here.

Show solution
sqlz_within_channel.sql
SELECT spend_date,
       channel,
       cost,
       ROUND( (cost - AVG(cost) OVER (PARTITION BY channel))
              / NULLIF(STDDEV_SAMP(cost) OVER (PARTITION BY channel), 0), 2) AS z_within_channel
FROM ad_spend
ORDER BY channel, spend_date;

Window functions do this without a join: AVG(cost) OVER (PARTITION BY channel) attaches each channel's mean to every one of its rows, and the same for the standard deviation. The NULLIF(..., 0) guard matters because a channel with a single row, or with identical spend every month, would otherwise divide by zero.

Within-channel is right because the two channels operate at different spend levels and different volatilities. A pooled z-score would answer “is this month unusual for the company?” — and would flag every month of the higher-spending channel as high, which is not news. The within-channel z answers the question anyone actually cares about: is this month unusual for this channel? With six months per channel the scores are indicative at best; the structure of the query is the transferable part, not the specific values.

Knowledge check

A value has a z-score of −1.5. What does this mean?

Knowledge check

You apply a “flag anything beyond 2 standard deviations” rule to revenue per customer, which is heavily right-skewed. What most likely happens?

Key takeaway

z = (x - mean) / sd converts any value into “typical steps from its own centre”, making unlike columns comparable on one ruler. The normal distribution is one particular symmetric shape with the 68–95–99.7 property — a genuine fact conditional on the data actually being normal, which business data frequently is not. Standardise for comparison, report in real units, and check the histogram before trusting any sigma-based threshold.

Lesson 9.7·12 min read

Skewness & Kurtosis

Put a number on “which way does the tail lean?” and “how fat are the tails?” — the two shape facts that decide whether your other statistics are trustworthy.

A histogram shows shape but cannot be put in a monitoring table, compared across a hundred columns, or alerted on. For that you need shape reduced to numbers. Two numbers do most of the work.

Skewness measures asymmetry — which side has the longer tail. Kurtosis measures tailedness — how much of the distribution's variance comes from rare extreme values rather than ordinary ones. Together they tell you whether the mean and standard deviation you computed in Lessons 9.2 and 9.3 are describing the data or being pushed around by it.

The pattern behind both

Both are built from the same ingredient you have already met: the standardised deviation, (x - mean) / sd. The only difference is what power you raise it to.

PowerNameWhat it measuresWhy that power
1(mean deviation)Nothing — always zeroSigns cancel exactly
2VarianceSpreadSquaring removes signs; large deviations count more
3SkewnessAsymmetryCubing keeps the sign, so left and right tails do not cancel
4KurtosisTailednessFourth power weights far values so heavily that only the tails matter

That is the whole idea. The third power is the key to skewness: a deviation of −2 cubed is −8, and +2 cubed is +8. If the two tails are balanced they cancel and skewness is zero. If one tail reaches further, its cubes dominate and the sign of the total tells you which side.

The sample skewness formula, with the small-sample correction that DuckDB uses:

skewness = n / ((n-1) * (n-2)) * SUM( ((x - mean) / sd)^3 )

And the sample excess kurtosis — “excess” meaning measured relative to a normal distribution, so that a normal distribution scores zero:

kurtosis = ( n*(n+1) / ((n-1)*(n-2)*(n-3)) ) * SUM( ((x - mean) / sd)^4 ) - 3*(n-1)^2 / ((n-2)*(n-3))

The bracketed fractions look forbidding but they are just bookkeeping — the same kind of small-sample correction as the n − 1 in variance. The part that carries meaning is SUM(((x - mean)/sd)^power).

Analogy

A seesaw with children sitting along it. Variance is how far out they sit on average — it does not care which side. Skewness is which end is heavier: put one heavy child far out on the right and the plank tips right, no matter how many small children sit on the left. Kurtosis is a different question entirely: are the children bunched near the middle with one or two hanging off the very ends, or spread evenly along the plank? High kurtosis is the first picture — a crowded centre plus dramatic extremes, and very little in between.

Reading the numbers

SKEWNESS < 0 (left)        SKEWNESS = 0              SKEWNESS > 0 (right)
        ########               ########                ########
      ##########             ##########                ######
    ############           ############                ####
  #     #   #####        #####      #####              ###   #     #
  mean < median           mean = median                median < mean
  e.g. exam marks         e.g. measurement            e.g. revenue, income,
  with a ceiling               error                  latency, claim size


KURTOSIS ~ 0 (normal-like)          KURTOSIS > 0 (heavy tails)
      ########                            ##
    ############                        ######
  ################                     ########
 ##################                   ##########
 tails thin out smoothly              #   #        #          #
                                      tall narrow peak PLUS far-out
                                      extremes; the middle ground empty

Working interpretations, treated as rules of thumb rather than tests:

  • Skewness near 0 — roughly symmetric; mean and median agree; standard-deviation-based methods are on solid ground.
  • Skewness positive — long right tail. The mean sits above the median. This is the default state of most business quantities: revenue, order value, session length, claim size, time-to-anything.
  • Skewness negative — long left tail. Less common; arises where a ceiling exists, such as scores out of 100 or completion percentages.
  • Excess kurtosis above 0 — heavier tails than a normal distribution, so extreme values are more common than the 68–95–99.7 rule predicts. Any threshold built on that rule will under-fire.

Built-in and from scratch, side by side

Finance DuckDB provides both directly:

sqlshape_builtins.sql
SELECT COUNT(*)                    AS n,
       ROUND(SKEWNESS(salary), 4)  AS skew,
       ROUND(KURTOSIS(salary), 4)  AS excess_kurtosis
FROM employees;
Result
nskewexcess_kurtosis
101.36462.2093

Now build skewness from the formula, to prove there is no magic in it:

sqlskewness_from_scratch.sql
-- skewness = n/((n-1)(n-2)) * SUM( ((x - mean)/sd)^3 )
WITH s AS (
  SELECT AVG(salary) AS mu, STDDEV_SAMP(salary) AS sd, COUNT(*) AS n
  FROM employees
)
SELECT ROUND( (s.n / ((s.n - 1.0) * (s.n - 2.0)))
              * SUM( POWER((e.salary - s.mu) / s.sd, 3) ), 4) AS skew_from_scratch
FROM employees AS e
CROSS JOIN s
GROUP BY s.n;
Result
skew_from_scratch
1.3646

Identical to the built-in. The same treatment for kurtosis:

sqlkurtosis_from_scratch.sql
WITH s AS (
  SELECT AVG(salary) AS mu, STDDEV_SAMP(salary) AS sd, COUNT(*) AS n
  FROM employees
)
SELECT ROUND(
   ( (s.n * (s.n + 1.0)) / ((s.n - 1.0) * (s.n - 2.0) * (s.n - 3.0)) )
     * SUM( POWER((e.salary - s.mu) / s.sd, 4) )
   - ( 3.0 * POWER(s.n - 1.0, 2) ) / ((s.n - 2.0) * (s.n - 3.0))
 , 4) AS excess_kurtosis_from_scratch
FROM employees AS e
CROSS JOIN s
GROUP BY s.n;
Result
excess_kurtosis_from_scratch
2.2093

A skewness of +1.36 confirms in one number what the histogram in Lesson 9.5 showed: the salary distribution leans right, dragged by the executive salary. The positive excess kurtosis says the same thing from the other direction — more of the variance than a normal distribution would allow is coming from that one distant value.

Inference But with n = 10, both numbers are estimates with very wide error, and both are driven almost entirely by a single row. Delete Rowan Idris and both would collapse towards zero. These are descriptions of this table, not established facts about how the company pays people.

Common pitfall — treating skewness as a normality test

“Skewness is 0.4, so the data is normal” is wrong twice over. First, symmetry is necessary but nowhere near sufficient — a uniform distribution has zero skewness and is not remotely normal, and neither is a symmetric two-humped distribution. Second, skewness is itself a sample statistic with sampling error that is large at small n, so a value near zero from 30 rows is weak evidence of anything. Use skewness as a flag that prompts you to look at the histogram, not as a verdict that replaces looking.

What you actually do with these numbers

Shape statistics are decision inputs, not deliverables. Nobody wants a slide about kurtosis. What they change:

  1. Which centre you report. Absolute skewness above roughly 1 is a strong argument for leading with the median.
  2. Which outlier method you use. Meaningful skew rules out symmetric z-score fences in favour of IQR-based ones.
  3. Whether to transform. Right-skewed positive data often becomes roughly symmetric under a log transform, after which the standard toolkit applies again.
  4. How much you trust a t-test. Tests based on means tolerate skew reasonably well at large n (Lesson 9.14 explains why) and poorly at small n. High kurtosis makes it worse, because a single extreme value can swing the result.
  5. Whether the column is stable enough to monitor. A heavy-tailed metric will produce alarming-looking spikes forever. Monitor its median or a trimmed mean instead.

When not to compute them

  • Small samples. Kurtosis requires n > 3 merely to be defined (look at the n − 3 in the denominator) and is wildly unstable below about 50 rows. Reporting it to four decimal places on ten rows — as done above for teaching — is false precision, and saying so is part of the lesson.
  • Multi-modal data. A single skewness value for a two-humped distribution describes an average lean that no part of the data actually has. Split first.
  • When a histogram would do. If a human is going to read the output once, show them the shape. Reduce to numbers when you need to compare or monitor many columns at once.
  • Bounded or discrete columns with few distinct values. Skewness of a 0/1 conversion flag is a deterministic function of the conversion rate and adds nothing.
In the field

Insurance Claim severity is the textbook heavy-tailed quantity: most claims are small, a few are catastrophic, and the mean claim is not a claim anyone has ever made. Pricing a product on the mean without accounting for the tail is precisely how an insurer becomes insolvent in a bad year. The practical response is not to compute a prettier average but to model the body and the tail separately — and the trigger for doing so is exactly the diagnostic in this lesson: a large positive skewness with high kurtosis says the extremes are not decoration, they are the risk.

Interview angle

“What does a positive skew tell you about the relationship between the mean and the median?” — the mean sits above the median, because the long right tail pulls the mean and leaves the middle-of-the-queue value where it is. The stronger follow-up is practical: “you find skewness of 2.4 on revenue per user — what changes?” Good answers cover reporting the median, avoiding z-score outlier fences, considering a log transform, and being cautious with mean-based tests at small sample sizes. Naming the consequences beats reciting the definition.

Exercise 9.7

E-commerce For order-line value (quantity × unit_price), report n, mean, median, skewness and kurtosis in one row, plus a mean_minus_median column. Then state whether the sign of the skewness agrees with the sign of the mean-minus-median gap, and why you would expect it to.

Show solution
sqlshape_of_order_lines.sql
SELECT COUNT(*)                                                 AS n,
       ROUND(AVG(quantity * unit_price), 2)                     AS mean_val,
       ROUND(MEDIAN(quantity * unit_price), 2)                  AS median_val,
       ROUND(AVG(quantity * unit_price)
             - MEDIAN(quantity * unit_price), 2)                AS mean_minus_median,
       ROUND(SKEWNESS(quantity * unit_price), 4)                AS skew,
       ROUND(KURTOSIS(quantity * unit_price), 4)                AS excess_kurtosis
FROM order_items;

Both come out positive, and they agree because they are two views of the same asymmetry: a long right tail pulls the mean upward while leaving the median in place, so mean − median becomes positive at the same time as the cubed deviations on the right start to outweigh those on the left. They are not equivalent, though — the gap is in the data's own units and depends on scale, while skewness is unitless and therefore comparable across columns. That is why skewness is the one you put in a monitoring table across many metrics, and the mean-minus-median gap is the one you quote to a stakeholder who has never heard the word.

Knowledge check

A column has skewness of +2.8 and excess kurtosis of +9. Which combination of choices is most defensible?

Key takeaway

Skewness cubes standardised deviations so signs survive, giving asymmetry: positive means a long right tail and a mean above the median. Kurtosis uses the fourth power so only the tails matter, giving tailedness relative to a normal distribution. Neither is a deliverable — they are triggers that decide which centre you report, which outlier rule you apply, and how far you can trust a mean-based test. Both need decent n; on ten rows they describe one row.

Lesson 9.8·14 min read

Outlier Detection: IQR, z-score & Winsorising

Find extreme values with two defensible methods, and decide what to do about them without quietly deleting the truth.

An outlier is an observation far from the rest. That definition is deliberately vague, because the important word is not far — it is why. There are three reasons a value can be extreme, and they demand opposite responses:

  1. A measurement error. A duplicated event, a test transaction of 999,999, a timestamp in 1970. This is corrupt data and should be removed — it is not an observation of anything.
  2. A different population that got mixed in. A wholesale order in a table of consumer orders; internal staff traffic in customer analytics. Should be segmented out, not deleted — it is real, it just answers a different question.
  3. A genuine extreme. A real customer really did spend 40,000. This is the most important row in the table and deleting it is falsification.

No statistical method can tell these apart. Methods find candidates; domain knowledge decides. Anyone who runs a fence and deletes what falls outside it is not cleaning data, they are editing the answer.

Analogy

A smoke alarm goes off. It might be a fire, burnt toast, or a faulty sensor. The alarm cannot distinguish them — it only detects that something is unusual. Taking the battery out because the noise is inconvenient is what deleting outliers usually amounts to. Investigating is the job; the alarm just tells you where to look.

Method 1: IQR fences

Built on quartiles, so it makes no assumption about the distribution's shape and is not itself dragged by the values it is trying to detect. The rule:

lower_fence = Q1 - 1.5 * IQR
upper_fence = Q3 + 1.5 * IQR

where IQR = Q3 - Q1 from Lesson 9.4. Anything outside the fences is a candidate. The 1.5 multiplier is a convention, not a derivation — it was chosen to flag roughly 0.7% of values from a normal distribution, and it stuck. Using 3.0 instead gives a stricter fence for “far out” values, and that is an equally legitimate choice provided you state it.

Finance Fences on salary:

sqliqr_fences.sql
WITH f AS (
  SELECT QUANTILE_CONT(salary, 0.25) AS q1,
         QUANTILE_CONT(salary, 0.75) AS q3
  FROM employees
)
SELECT ROUND(q1, 2)                    AS q1,
       ROUND(q3, 2)                    AS q3,
       ROUND(q3 - q1, 2)               AS iqr,
       ROUND(q1 - 1.5 * (q3 - q1), 2)  AS lower_fence,
       ROUND(q3 + 1.5 * (q3 - q1), 2)  AS upper_fence
FROM f;
Result
q1q3iqrlower_fenceupper_fence
114500.00158750.0044250.0048125.00225125.00
sqliqr_outliers.sql
WITH f AS (
  SELECT QUANTILE_CONT(salary, 0.25) AS q1,
         QUANTILE_CONT(salary, 0.75) AS q3
  FROM employees
)
SELECT e.full_name, e.department, e.salary
FROM employees AS e
CROSS JOIN f
WHERE e.salary < f.q1 - 1.5 * (f.q3 - f.q1)
   OR e.salary > f.q3 + 1.5 * (f.q3 - f.q1);
Result
full_namedepartmentsalary
Rowan IdrisExecutive240000.00

One candidate — and the department column immediately explains it. This is category 2 from the list above: not an error, not a mysterious extreme, but a different population. The correct response is to analyse executive pay separately, not to delete a real employee from the payroll analysis.

Method 2: z-score thresholds

Flag anything with ABS(z) > 3, or 2 for a looser screen. Simple, and it inherits every weakness discussed in Lesson 9.6 — the mean and standard deviation used to compute z are themselves inflated by the outliers.

sqlz_score_outliers.sql
WITH s AS (
  SELECT AVG(salary) AS mu, STDDEV_SAMP(salary) AS sd FROM employees
)
SELECT e.full_name,
       e.salary,
       ROUND((e.salary - s.mu) / s.sd, 2) AS z
FROM employees AS e
CROSS JOIN s
WHERE ABS((e.salary - s.mu) / s.sd) > 2;
Result
full_namesalaryz
Rowan Idris240000.002.28

Same answer here — but note the z is only 2.28. A conventional 3-sigma rule would have flagged nothing at all, because with n = 10 no observation can reach a z of 3. The maximum possible z-score on a sample of size n is (n-1)/SQRT(n), which for n = 10 is about 2.85. Fact On small samples, a 3-sigma rule is not strict — it is mathematically incapable of firing, which is a far worse failure mode because it looks like a clean bill of health.

IQR fencesz-score
Assumes symmetry?NoYes, effectively
Affected by the outliers it seeks?Barely — quartiles ignore the extremesYes — both mean and sd are inflated by them
Works on skewed data?Yes, though it flags more on the long-tail sidePoorly
Needs how many rows?Enough for stable quartiles — tensEnough for a stable sd, and more than 10 for a 3-sigma rule to be possible at all
Best forMost business data, which is skewedData you have already confirmed is roughly symmetric

Winsorising: capping instead of deleting

Sometimes an extreme value is genuine but you do not want it to dominate an average. Winsorising is the middle path: pull extreme values in to a chosen percentile rather than removing their rows. Every observation still counts, but none of them counts unboundedly.

sqlwinsorise.sql
-- Cap at the 5th and 95th percentiles: nothing is deleted,
-- extremes are pulled in to the cap.
WITH lines AS (
  SELECT quantity * unit_price AS amount FROM order_items
),
c AS (
  SELECT QUANTILE_CONT(amount, 0.05) AS p5,
         QUANTILE_CONT(amount, 0.95) AS p95
  FROM lines
)
SELECT ROUND(AVG(l.amount), 2)                                AS raw_mean,
       ROUND(AVG(LEAST(GREATEST(l.amount, c.p5), c.p95)), 2)  AS winsorised_mean,
       ROUND(c.p5, 2)                                         AS p5,
       ROUND(c.p95, 2)                                        AS p95
FROM lines AS l
CROSS JOIN c
GROUP BY c.p5, c.p95;
Result
raw_meanwinsorised_meanp5p95
265.13263.33129.00431.99

GREATEST(x, p5) raises anything below the floor up to it; LEAST(..., p95) lowers anything above the ceiling down to it. The two nested together clamp the value into the range. The mean barely moves here — 265.13 to 263.33 — which is itself the finding: Inference this distribution has no observation extreme enough to be distorting the average, so winsorising is unnecessary. On a column where the winsorised mean differs substantially from the raw mean, you have learned that a handful of rows are steering your headline number.

Related options worth knowing by name: a trimmed mean discards the top and bottom few per cent rather than capping them (used in Olympic judging, where the extreme scores are simply dropped); and the plain median is the logical endpoint of trimming, discarding everything but the middle.

Common pitfall — removing outliers before the analysis, silently

A pipeline drops rows beyond three standard deviations “to clean the data”, and six months later nobody remembers it is there. Two things have quietly happened. The reported variance is now too small, so every confidence interval is too narrow and every significance test is too eager — you have manufactured precision that the data does not have. And the removed rows were disproportionately your largest customers, so the “average customer” the business now plans around is a customer with the profitable ones filtered out. Inference Outlier handling is an analytical decision that belongs in the analysis with a stated rule, not a hidden step in a transformation job. If it must live in the pipeline, flag the rows rather than deleting them, so the choice remains visible and reversible.

When not to detect outliers at all

  • When extremes are the subject. Fraud detection, systems monitoring, catastrophe risk — the outliers are the signal. Do not remove your findings.
  • Small samples. With n under about 20 there is no reliable way to distinguish an outlier from ordinary variation; both methods will produce arbitrary answers.
  • Multi-modal data. The smaller hump will be flagged wholesale. It is a population, not a set of errors. Split before screening.
  • Bounded metrics already at their limit. A conversion rate of 100% in a group of two visitors is not an outlier; it is a small denominator, and the fix is a minimum sample threshold.
  • When a robust statistic solves it more simply. If the only worry is that a mean is being dragged, report the median and stop. No detection, no deletion, no defensibility problem.
In the field

Healthcare Clinical datasets show why blanket rules are dangerous. A resting heart rate of 30 is an outlier in the general population and unremarkable in an elite endurance athlete; a systolic blood pressure of 250 is not a typing error to be discarded, it is a medical emergency to be escalated. The workable practice is a two-stage rule: physiologically impossible values (a negative age, a temperature of 200) are corrected or removed as data errors, while implausible but possible values are flagged for review and kept. The distinction cannot be automated because it is clinical, not statistical — and the same is true in every domain, it is just less obvious when the subject is revenue.

Interview angle

“How do you handle outliers?” is a judgement question wearing a technique question's clothes. Answering only “IQR or z-score” scores poorly. The answer that lands starts with investigate before deciding: is it a data error, a different population, or a real extreme? Then the method, chosen by shape — IQR for skewed data because it is not itself dragged by the outliers, z-scores only when the distribution is roughly symmetric. Then the treatment: remove only confirmed errors, segment different populations, and winsorise or use a robust statistic when the value is genuine. Finishing with “and whatever I do gets stated in the write-up, because the choice changes the answer” is what separates senior candidates.

Exercise 9.8

Advertising For ad_spend, compute cost-per-click as cost / clicks. Flag each row against IQR fences and against a 2-sigma z-score rule in the same output, so the two methods can be compared row by row. Then say which rows the two methods disagree about, and why disagreement is expected.

Show solution
sqlcpc_outlier_comparison.sql
WITH cpc AS (
  SELECT spend_date, channel, campaign,
         cost / NULLIF(clicks, 0) AS cost_per_click
  FROM ad_spend
),
b AS (
  SELECT QUANTILE_CONT(cost_per_click, 0.25) AS q1,
         QUANTILE_CONT(cost_per_click, 0.75) AS q3,
         AVG(cost_per_click)                 AS mu,
         STDDEV_SAMP(cost_per_click)         AS sd
  FROM cpc
)
SELECT c.spend_date, c.channel, c.campaign,
       ROUND(c.cost_per_click, 4)                              AS cpc,
       ROUND((c.cost_per_click - b.mu) / NULLIF(b.sd, 0), 2)   AS z,
       CASE WHEN c.cost_per_click < b.q1 - 1.5 * (b.q3 - b.q1)
              OR c.cost_per_click > b.q3 + 1.5 * (b.q3 - b.q1)
            THEN 1 ELSE 0 END                                  AS iqr_flag,
       CASE WHEN ABS((c.cost_per_click - b.mu) / NULLIF(b.sd, 0)) > 2
            THEN 1 ELSE 0 END                                  AS z_flag
FROM cpc AS c
CROSS JOIN b
ORDER BY cpc DESC;

NULLIF(clicks, 0) guards the division and NULLIF(b.sd, 0) guards the z. Running it, neither method flags anything: with twelve rows the fences are wide and, as established above, a 2-sigma z is already near the ceiling of what twelve observations can produce.

Disagreement between the two methods is the normal case, not a bug. They measure different things — distance from the middle half versus distance from the mean in units of a standard deviation that the extreme values themselves inflated. On skewed data the z-score rule systematically under-flags the long tail, because the tail has already widened the standard deviation that defines the threshold. That is exactly the reason to prefer IQR fences on business metrics, and the reason to show both when you are deciding which to adopt.

Knowledge check

Why are IQR fences generally preferred to z-scores for detecting outliers in business data?

Knowledge check

An analyst removes all orders above the 99th percentile before computing average order value, and does not mention it. What is the most serious consequence?

Key takeaway

Outlier methods find candidates, not verdicts — only domain knowledge separates a data error from a different population from a genuine extreme. Use IQR fences (Q1 - 1.5×IQR, Q3 + 1.5×IQR) by default, because quartiles are not inflated by the values they are detecting; use z-scores only on data you have confirmed is roughly symmetric, and remember a 3-sigma rule cannot fire below about ten rows. Prefer winsorising or a robust statistic to deletion, and always state the rule you applied.

Part 10

Experimental Design & A/B Testing

Randomisation, power, guardrails, bias, Simpson’s paradox and peeking — how to run an experiment that actually supports a decision.

Lesson 10.1·13 min read

Why Experiments Beat Observation: The Counterfactual Problem

You will be able to state, for any “X caused Y” claim someone makes from a dashboard, exactly which number is missing — and why no amount of SQL can conjure it.

A marketing manager sends a promotional email to a segment of customers. Three weeks later she opens a dashboard and reports: customers who received the email bought at twice the rate of customers who did not. The email works. Spend more on email.

The query behind that claim is trivially easy to write, and the claim is almost certainly wrong. Not because the arithmetic is wrong — it is fine — but because the number she needs does not exist anywhere in the warehouse, and no join will produce it.

E-commerce Here is the observational query, run against the canonical dataset. It compares how likely a customer is to place a completed order, grouped by the channel that acquired them:

sqlobservational_comparison.sql
-- The kind of query that gets mistaken for causal evidence.
WITH per_customer AS (
  SELECT c.customer_id,
         c.channel,
         MAX(CASE WHEN o.status = 'completed' THEN 1 ELSE 0 END) AS ordered
  FROM customers AS c
  LEFT JOIN orders AS o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id, c.channel
)
SELECT channel,
       COUNT(*)      AS customers,
       SUM(ordered)  AS buyers,
       ROUND(100.0 * SUM(ordered) / NULLIF(COUNT(*),0), 1) AS buy_rate_pct
FROM per_customer
GROUP BY channel
ORDER BY buy_rate_pct DESC, channel;
Result
channelcustomersbuyersbuy_rate_pct
email22100.0
paid_search44100.0
referral22100.0
organic5360.0
paid_social4250.0

Email customers bought at twice the rate of paid-social customers. Should the company move budget from paid social to email? Nothing in this table supports that decision, and the reason is worth being precise about.

The number that does not exist

To say “the email caused the purchase” you must compare two things:

  1. What happened to Greta after she received the email. This is recorded. You can query it.
  2. What would have happened to Greta had she not received the email, everything else about the world being identical. This is the counterfactual.

Fact The counterfactual is never observed. For any individual, exactly one of the two histories actually happens; the other is permanently unavailable. This is sometimes called the fundamental problem of causal inference, and it is a statement about reality, not about your data pipeline. A bigger warehouse does not help. A better join does not help.

You can make the absence literal in SQL. The second column below is the one the manager's argument depends on, and it can only ever be NULL:

sqlthe_missing_column.sql
-- Column 3 is measured. Column 4 is the counterfactual: unmeasurable, always NULL.
SELECT c.customer_id,
       c.full_name,
       SUM(CASE WHEN o.status = 'completed' THEN 1 ELSE 0 END) AS orders_observed,
       NULL AS orders_if_not_targeted
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
WHERE c.channel = 'email'
GROUP BY c.customer_id, c.full_name
ORDER BY c.customer_id;
Result
customer_idfull_nameorders_observedorders_if_not_targeted
7Greta Lindqvist1NULL
14Nadia Karim1NULL
Analogy

You take an umbrella on a day the forecast says rain. You stay dry. Did the umbrella work? To know, you would need to replay the same day, same clouds, same route, without the umbrella — and you cannot, because the day only happens once. What you can do is take a hundred people with the same forecast, flip a coin for each, give umbrellas to the heads. Now the tails group is not a replay of any individual's day, but it is a fair stand-in for what the heads group's day would have looked like. That substitution is the entire idea of an experiment.

Randomisation manufactures a substitute

Since the individual counterfactual is unavailable, experiments give up on it and settle for something weaker but achievable: a group whose average outcome stands in for what the treated group's average outcome would have been.

The trick is how you choose that group. If you let people choose — opt in to the beta, click the email, install the app — the group differs from the treated group in ways that also drive the outcome. Email subscribers are already engaged customers; they would have bought more anyway. The comparison measures who-they-are plus what-you-did, tangled together with no way to separate them.

Randomisation cuts the tangle. Assign by coin flip and the two groups differ only by chance — on every variable, including ones you never thought to record. That last clause is the point. Controlling for confounders statistically only works for confounders you know about and measured. Randomisation balances the ones you have never heard of.

Product analytics The experiment_assignments table is what a manufactured counterfactual looks like once it has been written down:

sqlarms.sql
SELECT variant,
       COUNT(*)       AS users,
       SUM(converted) AS conversions,
       ROUND(1.0 * SUM(converted) / COUNT(*), 4) AS conv_rate
FROM experiment_assignments
WHERE experiment = 'checkout_v2'
GROUP BY variant
ORDER BY variant;
Result
variantusersconversionsconv_rate
control720.2857
treatment860.7500

Structurally this looks like the channel query. Logically it is a different kind of object. In the channel query the groups were formed by the world; here they were formed by a coin flip that the analyst controlled. Only the second supports a sentence containing the word “caused”.

Inference Note carefully what randomisation does not buy: certainty. Fifteen users is fifteen users. Lesson 10.7 works through exactly how little this particular result licenses you to say. The design is sound; the sample is not. Those are separate failures and it matters that you can tell them apart.

flowchart TB
  A["Population of users"] --> B{"How is the group formed?"}
  B -->|"They choose
(clicked, opted in)"| C["Observational group"] B -->|"Coin flip
(you choose)"| D["Randomised group"] C --> E["Difference = effect
PLUS who they already were"] D --> F["Difference = effect
plus random noise only"]
The same SQL runs over both branches and returns a similar-looking table. The difference lives in how the rows came to exist, which no query can inspect.

When an experiment is the wrong tool

Experiments are the strongest evidence available and frequently unavailable. Do not run one when:

  • Withholding the treatment is unethical or illegal. Healthcare You cannot randomise patients into a group denied a treatment already known to work. Regulated finance and insurance have similar constraints on differential pricing.
  • The unit cannot be split. A brand campaign on national television reaches everyone. There is no control group because there is only one country. Geo-based designs and time-series methods exist for this, but they are not A/B tests.
  • Groups interfere with each other. In a marketplace, showing treatment sellers to more buyers takes those buyers away from control sellers. The control group is contaminated by the treatment, so the difference understates or overstates the true effect.
  • The effect takes longer than you can wait. A change to the onboarding flow may alter two-year retention. Nobody holds an experiment open for two years, so you settle for a proxy and accept the risk that the proxy misleads.
  • The decision is already made. If the change ships regardless, the experiment is theatre. Kill it and spend the traffic on a question whose answer could change something.
Common pitfall — the before/after comparison

The most common fake counterfactual is the past. “Conversion was 3.1% in March, we shipped the new checkout in April, conversion is 3.6% now — a 16% lift.” This treats March as the control group. But March also had different weather, different competitor pricing, a different marketing calendar, and different customers. Anything else that changed between the two months is baked into that 0.5 point. Before/after comparisons are not weak evidence of causation; on a business metric with strong seasonality they are frequently no evidence at all.

In the field

The most valuable thing an analyst does in a review meeting is often a single question: “compared to what?” It sounds pedantic and it is not. When someone says a campaign drove 400 signups, ask how many signups there would have been without it. If the answer is “well, zero, they came from the campaign link”, ask whether those people would have arrived by search instead. Most reported marketing effects shrink substantially the first time that question is taken seriously.

Interview angle

A standard prompt: “Users of our mobile app retain better than web users. Should we push everyone to the app?” Weak answers start optimising the push. Strong answers identify the selection problem — people who install an app are already more committed, so app usage is a symptom of loyalty as much as a cause of it — and then propose a design: randomise the install prompt, measure retention on all assigned users regardless of whether they installed. Naming the estimand (intention-to-treat, covered in 10.8) puts you in the top few percent of candidates.

Exercise 10.1

SaaS A colleague reports: “Customers on our enterprise plan have a 100% retention rate, whereas basic customers churn constantly. We should upsell everyone to enterprise.”

(a) Write a query against subscriptions that produces the numbers behind this claim. (b) State the counterfactual the claim requires and explain why the query cannot supply it. (c) Describe an experiment that could.

Show solution
sqlplan_retention.sql
SELECT plan,
       COUNT(*) AS subscriptions,
       SUM(CASE WHEN cancelled_on IS NULL THEN 1 ELSE 0 END) AS still_active,
       ROUND(100.0 * SUM(CASE WHEN cancelled_on IS NULL THEN 1 ELSE 0 END)
             / NULLIF(COUNT(*),0), 1) AS retention_pct
FROM subscriptions
GROUP BY plan
ORDER BY retention_pct DESC;

(b) The claim needs to know the churn rate of enterprise customers had they been on the basic plan instead. That row does not exist and cannot be created by joining. What the query actually measures is a mixture of two things: any real effect of the plan, and the fact that companies which choose an expensive plan are systematically different — larger, better funded, with more internal commitment to the tool. Those companies would churn less on any plan. Plan tier is a marker of that commitment at least as much as a cause of it.

(c) Randomise the offer, not the plan. Take customers eligible for an upgrade, coin-flip them into receiving an enterprise upgrade offer or not, and measure twelve-month retention across everyone assigned — including those offered who declined. That measures the effect of the intervention you actually control. You cannot randomise which plan people are on, because you cannot force a customer to pay more; you can only randomise what you offer them.

Knowledge check

Why can a query never establish causation from observational data alone, no matter how many columns you control for?

Key takeaway

Every causal claim needs two numbers: what happened, and what would have happened otherwise. Your warehouse only ever contains the first. Experiments do not recover the second — nothing does — they manufacture a fair substitute for it by forming a comparison group with a coin flip rather than with the world's own selection process. When someone shows you a group comparison, the only question that matters is how the groups came to be groups.

Lesson 10.2·13 min read

Randomisation: What It Buys You and How It Breaks

You will be able to explain what randomisation guarantees (less than people think), and run the two diagnostics that catch a broken assignment before it wastes a quarter.

Randomisation is a single coin flip doing an enormous amount of work. It is worth understanding precisely what it promises, because the promise is narrower than the folklore, and because the machinery that implements it fails quietly.

The promise: in expectation, the two arms are identical on every characteristic — age, tenure, device, prior spend, mood, and every variable nobody recorded. Not identical in any given experiment. Identical on average across the infinite set of experiments you could have run. In one experiment the arms will differ by chance, and the size of that chance difference shrinks as the sample grows.

That distinction — balanced in expectation, not balanced in fact — is where most confusion about A/B testing lives. Everything in Lessons 10.5 through 10.7 is machinery for quantifying how much chance imbalance is compatible with a given observed difference.

How assignment is actually implemented

Nobody flips real coins. The standard mechanism is a deterministic hash: take a stable identifier, concatenate a salt that identifies the experiment, hash it to a number, and take that number modulo the number of buckets.

sqlhash_bucketing.sql
-- Deterministic bucketing: the same customer always lands in the same arm
-- for this experiment, and in an unrelated arm for a different experiment.
WITH b AS (
  SELECT customer_id,
         CASE WHEN HASH(CAST(customer_id AS VARCHAR) || '|checkout_v2') % 100 < 50
              THEN 'control' ELSE 'treatment' END AS bucket
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2'
)
SELECT bucket,
       COUNT(*) AS users,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM b
GROUP BY bucket
ORDER BY bucket;
Result
bucketuserspct
control533.3
treatment1066.7

A perfectly good 50/50 hash produced a 33/67 split. Nothing is broken. With fifteen users, a fair coin produces splits like this routinely. This is the “balanced in expectation” point made concrete, and it is why the diagnostics below need a threshold rather than an eyeball.

Three properties make hash bucketing the standard choice. It is deterministic, so a user sees the same variant on every visit and across devices without any assignment table lookup. It is stateless, so it works at the edge with no database round trip. And salting by experiment name makes concurrent experiments independent — a user in treatment for the checkout test is not systematically in treatment for the pricing test.

Analogy

Randomisation is shuffling a deck before dealing. A good shuffle does not guarantee both players get equal numbers of aces in this hand — it guarantees neither player can arrange to get them. The protection is against systematic advantage, not against luck. And notice what breaks a shuffle in practice: not bad intentions, but the dealer who deals from a deck that was only half shuffled, or who deals the top cards to one player and the bottom to the other. Nearly every real randomisation failure is of that shape.

Diagnostic one: sample ratio mismatch

If you intended a 50/50 split and the arms come back 48/52 on a million users, something is wrong. Not unlucky — wrong. The check is a chi-square goodness-of-fit test against the intended ratio, which is simple enough to write directly:

sqlsrm_check.sql
-- Sample ratio mismatch: does the observed split match the intended 50/50?
-- chi-square with 1 degree of freedom; critical value at alpha = 0.05 is 3.841.
WITH a AS (
  SELECT SUM(CASE WHEN variant = 'control'   THEN 1 ELSE 0 END) AS obs_c,
         SUM(CASE WHEN variant = 'treatment' THEN 1 ELSE 0 END) AS obs_t,
         COUNT(*) AS n
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2'
)
SELECT obs_c,
       obs_t,
       n,
       ROUND(n / 2.0, 1) AS expected_each,
       ROUND(POWER(obs_c - n/2.0, 2) / (n/2.0)
           + POWER(obs_t - n/2.0, 2) / (n/2.0), 3) AS chi_square,
       3.841 AS crit_chisq_1df_005,
       CASE WHEN POWER(obs_c - n/2.0, 2)/(n/2.0)
               + POWER(obs_t - n/2.0, 2)/(n/2.0) > 3.841
            THEN 'SRM: investigate'
            ELSE 'no sample ratio mismatch detected' END AS verdict
FROM a;
Result
obs_cobs_tnexpected_eachchi_squarecrit_chisq_1df_005verdict
78157.50.0673.841no sample ratio mismatch detected

Note The critical value 3.841 is hard-coded because SQL engines do not ship a chi-square distribution function. You compute the statistic in SQL and compare it against a value from a table. Be honest about this in your code comments rather than inventing an approximation.

A failed SRM check is not a statistical result to be reported; it is a bug report. The usual causes are structural, and all of them bias the outcome:

  • Treatment-side errors. The new code path crashes for some users, so they never fire an assignment event. The crashed users are missing from treatment only — and they were disproportionately the ones on old browsers or slow connections.
  • Latency-dependent logging. Assignment is logged after the page renders. The treatment renders more slowly, so more treatment users abandon before the log fires.
  • Bot filtering applied after assignment. If bots hash disproportionately into one arm and are then removed, the ratio shifts.
  • Redirect tests. Sending treatment users to a different URL loses everyone who abandons during the redirect.
💡Intuition

SRM matters far out of proportion to its size. A 0.5 percentage point imbalance sounds trivial, but it means users were selectively removed from one arm, and the ones removed are never a random subset — they are the ones who hit the error, or gave up waiting. You are no longer comparing two random groups; you are comparing a random group against a random group minus its most frustrated members. That contamination can easily be larger than the effect you are trying to measure, and it always points in the flattering direction.

The other ways randomisation breaks

Gaming A studio randomises players into a new matchmaking algorithm. Four failure modes recur across every industry:

Wrong randomisation unit. If you randomise by session but the treatment changes something a user notices across sessions, the same person sees both variants and the arms blur together. Lesson 10.3 treats unit choice properly.

Carryover from a previous test. If the salt is only the experiment name and a user was in treatment for a previous test on the same surface, their behaviour may still be shifted. Re-randomising with a fresh salt each experiment is what breaks the correlation.

Interference between arms. In the matchmaking example, treatment players are matched against control players. Whatever you did to treatment changes control's experience directly. The control group is no longer a picture of a world without the treatment.

Randomisation performed downstream of a filter. If assignment happens after a login wall, you have randomised logged-in users only, and the result does not generalise to anonymous traffic. This is not a bug; it is a scope limit that gets forgotten by the time the deck is written.

Common pitfall — re-randomising until the arms look balanced

An analyst checks covariate balance, sees the treatment arm skews younger, and re-runs the assignment with a new seed. Repeat until the table looks tidy. This destroys the guarantee entirely: you are now selecting assignments on a property correlated with the outcome, which is exactly the selection bias randomisation existed to prevent, reintroduced by hand. If balance on a known variable matters, stratify before assigning — randomise separately within each age band — which is a legitimate design decision made in advance. Re-rolling after seeing the data is not.

When not to randomise at the user level

  • When users interact. Social features, marketplaces, and multiplayer systems violate the assumption that one user's assignment does not affect another's outcome. Cluster randomisation — by city, by server, by social graph component — is the usual response, at a steep cost in effective sample size.
  • When the treatment is inherently shared. Pricing changes visible to everyone, physical store layouts, and warehouse routing cannot be varied per user.
  • When the population is tiny and the covariates matter enormously. Manufacturing With eight production lines, a coin flip will produce badly imbalanced arms most of the time. Matched pairs or a crossover design where each line does both is more efficient.
  • When assignment leaks. If users can tell which arm they are in and switch — by clearing cookies, using another device, or telling each other — the arms contaminate. Randomising on a stable authenticated identifier mitigates this; nothing eliminates it.
Interview angle

“Your A/B test came back with 49.2% of users in control and 50.8% in treatment on two million users. What do you do?” The trap is to answer “that is a tiny difference, proceed”. The expected answer: at that sample size the split is far outside chance, so this is a sample ratio mismatch — stop, do not read the result, and debug the assignment pipeline. Strong candidates then list plausible causes (treatment-side errors dropping users, logging fired after render, redirect abandonment) and note that the missing users are systematically different, which is why the contamination cannot be waved away as small.

Exercise 10.2

Telecommunications You suspect assignment for checkout_v2 was influenced by when customers signed up — perhaps the rollout was staged and older accounts hit the new code first. Write a query that reports, per variant, the number of users, the average tenure in days at assignment, and the earliest and latest signup date. Then state what pattern would indicate a staged rollout leaking into assignment.

Show solution
sqltenure_by_arm.sql
SELECT e.variant,
       COUNT(*) AS users,
       ROUND(AVG(DATE_DIFF('day', c.signup_date, e.assigned_on)), 1) AS avg_tenure_days,
       MIN(c.signup_date) AS earliest_signup,
       MAX(c.signup_date) AS latest_signup
FROM experiment_assignments AS e
JOIN customers AS c ON c.customer_id = e.customer_id
WHERE e.experiment = 'checkout_v2'
GROUP BY e.variant
ORDER BY e.variant;

A staged rollout leaking into assignment shows up as non-overlapping or barely overlapping signup ranges — for example, every control user signing up before a date and every treatment user after it. That would mean the arms are separated by time, not by coin flip, and the comparison is a before/after study wearing an experiment's clothing.

A difference in average tenure with fully overlapping ranges is much weaker evidence, and at fifteen users it is entirely consistent with chance. This is the trap the exercise sets: imbalance on a covariate is expected in small samples and is not by itself proof that randomisation failed. What distinguishes a bug from bad luck is structure — a clean separation, a threshold, a boundary — not magnitude. Lesson 10.10 quantifies how much imbalance should worry you.

Knowledge check

What does randomisation actually guarantee about the two arms of an experiment?

Key takeaway

Randomisation balances the arms in expectation, across all variables including unmeasured ones — that last part is the whole reason it beats statistical adjustment. It does not balance them in any given run. Before reading any result, run two checks: sample ratio mismatch (are the arm sizes what you intended?) and covariate balance (do the arms look alike on things fixed before assignment?). An SRM failure is a bug report, not a finding — stop and debug rather than adjusting.

Lesson 10.3·13 min read

Designing an A/B Test: Hypothesis, Unit, Metric

You will be able to turn a vague product idea into a written specification that fixes what is being tested, on what, and how success is measured — before a single user is assigned.

“We want to test the new checkout.” That sentence contains no testable content. It does not say what will change, for whom, or what result would count as a win. Teams run experiments on this basis constantly, and then argue for weeks about what the numbers meant — because the numbers were never told in advance what they were supposed to mean.

A design is three commitments made before assignment starts: a hypothesis, a randomisation unit, and a primary metric. Each closes off a specific route by which experiments become unfalsifiable.

The hypothesis

A usable hypothesis has four parts: the change, the mechanism, the direction, and the metric. The mechanism is the one people skip, and it is the most valuable.

SaaS Compare these:

  • Weak: “The new checkout will improve conversion.”
  • Usable: “Removing the account-creation step from checkout will increase completed-purchase rate among assigned users, because session recordings show most abandonment happens on that screen.”

The mechanism earns its place because it makes the result informative either way. If the metric moves, you have evidence for a theory that generalises to other screens. If it does not move, you have learned that the account step was not the binding constraint — which redirects the next experiment. Without a mechanism, a null result teaches nothing except “that particular thing didn't work”.

Inference The discipline of writing a mechanism also filters out changes nobody can justify. If the team cannot articulate why a change should move a metric, that is usually a sign the change was chosen because it was easy to build, not because anyone had a model of user behaviour.

Analogy

A hypothesis is a bet placed before the race. Anyone can explain a horse's victory afterwards; the explanation costs nothing and predicts nothing. Writing down “this horse, to win, because it runs well on soft ground” before the start is what makes the outcome evidence about your judgement rather than about your fluency. An experiment analysed without a pre-registered hypothesis is a race report, not a bet.

The randomisation unit

The unit is what gets the coin flip. The candidates, roughly in order of how often they are correct:

UnitUse whenCost
User (stable id)The change is noticeable and persists across visitsRequires a stable identifier; anonymous users are hard
SessionThe change is invisible between visits, e.g. a ranking tweakThe same person can see both arms — blurs the contrast
Account / companyB2B, where colleagues talk to each otherFar fewer units, so far less statistical power
Geography (city, region)The treatment cannot be hidden — pricing, TV, logisticsVery few units; needs specialised analysis
Time period (switchback)Marketplaces where treatment and control interfereConfounded with time-of-day and day-of-week patterns

Two rules cover most cases. First, the unit must be at least as coarse as the level at which the treatment is experienced. If a user would notice inconsistency between visits, randomise by user. Second, the unit must be at least as coarse as the level at which units interfere. If treating one seller affects another seller's outcome, randomising by seller is invalid regardless of how convenient it is.

The unit also determines what the denominator of your metric must be. If you randomise by user, your metric must be computed per user — not per session, not per order — because those rows are not independent, and treating them as though they were understates the true variance.

The primary metric

One metric. Named in advance. With its exact SQL written before the data exists.

The reason for “one” is arithmetic and appears in full in Lesson 10.12: every additional metric you are willing to declare victory on multiplies your chance of a false positive. The reason for “in advance” is human: after the fact, everyone can find a slice where their change won.

Product analytics The primary metric for checkout_v2, written as the query that will be run at the end:

sqlprimary_metric.sql
-- PRIMARY METRIC (pre-registered): per-user completed-purchase rate.
-- Denominator = every assigned user, including those who never returned.
SELECT variant,
       COUNT(*)                                             AS n,
       SUM(converted)                                       AS conversions,
       ROUND(100.0 * SUM(converted) / NULLIF(COUNT(*),0),2) AS conv_rate_pct
FROM experiment_assignments
WHERE experiment = 'checkout_v2'
GROUP BY variant
ORDER BY variant;
Result
variantnconversionsconv_rate_pct
control7228.57
treatment8675.00

Note the comment about the denominator. It is doing real work: it commits you to counting assigned users who never came back as non-converters rather than quietly dropping them. That single decision is the difference between an unbiased estimate and the survivorship trap dissected in Lesson 10.8.

The lift, expressed both ways, because the two get confused constantly:

sqllift.sql
WITH arm AS (
  SELECT variant,
         COUNT(*)                     AS n,
         SUM(converted)               AS conv,
         1.0 * SUM(converted) / COUNT(*) AS p
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2'
  GROUP BY variant
)
SELECT ROUND(c.p, 4)                 AS control_rate,
       ROUND(t.p, 4)                 AS treatment_rate,
       ROUND(t.p - c.p, 4)           AS absolute_lift,
       ROUND(100.0 * (t.p - c.p) / NULLIF(c.p, 0), 1) AS relative_lift_pct
FROM arm AS c
JOIN arm AS t ON c.variant = 'control' AND t.variant = 'treatment';
Result
control_ratetreatment_rateabsolute_liftrelative_lift_pct
0.28570.750.4643162.5

Absolute lift is 46.4 percentage points. Relative lift is 162.5 percent. Both describe the same two numbers. Business cases should be built on absolute lift, because that is what multiplies by traffic to give incremental orders; relative lift is for comparing effects across metrics with different baselines. Saying “up 162%” without the baseline is how a two-conversion difference gets presented as a transformation.

Open And it must be said plainly: this result rests on four conversions versus two, out of fifteen users. Lesson 10.7 computes the interval, and it is wide enough to contain almost any conclusion. The design work in this lesson is sound; the sample is not remotely adequate. Keeping those two judgements separate is a skill in itself.

Common pitfall — the ratio-of-sums metric

“Revenue per session” computed as SUM(revenue) / COUNT(sessions) looks like an average but is not one, when the randomisation unit is the user. A single user with forty sessions dominates both numerator and denominator, and the standard error formulas you are about to apply assume independent observations. Either aggregate to the randomisation unit first (revenue per user, sessions per user) or use a variance estimator built for ratios. Silently mixing units is the most common way a correct-looking analysis produces confidence intervals that are far too narrow.

When a formal A/B design is the wrong overhead

  • Bug fixes and obvious defects. If the checkout button is broken on Android, fix it. Randomising half your users into the broken version to prove brokenness is expensive and unkind.
  • Changes required for legal or accessibility reasons. The decision is not yours to make, so the evidence cannot change it.
  • Effects too small to be worth the traffic. If a reliable answer needs six months of your entire traffic, and the change is a button colour, the design is telling you the question is not worth asking.
  • Genuinely exploratory work. Early qualitative research, usability sessions, and prototype reactions are not experiments and should not be dressed as them. They generate hypotheses; experiments test them.
In the field

Teams that experiment well have a one-page document per test, written before launch, containing: hypothesis with mechanism, randomisation unit, primary metric SQL, guardrail metric SQL, minimum detectable effect, planned duration, and a pre-committed decision rule of the form “if the primary metric is up and no guardrail breaches, we ship”. It takes about forty minutes. The alternative is a three-week argument after the fact, conducted by people who have each already decided what they want the answer to be.

Interview angle

“Design an experiment to test a new search ranking algorithm.” Interviewers are listening for structure, not cleverness. Walk the three commitments out loud: hypothesis with a mechanism, randomisation unit with a justification (user-level, because a user comparing results across visits would notice inconsistency), primary metric defined per user with its denominator stated. Then volunteer the interference problem — if the ranking change affects which items get impressions, sellers are affected across arms — and say what you would do about it. Candidates who jump straight to “measure click-through rate” have skipped every decision that determines whether the number means anything.

Exercise 10.3

Digital media A publisher wants to test an autoplaying video on article pages. Someone proposes randomising by page view and using video completion rate among users who started a video as the primary metric.

(a) Give two reasons the randomisation unit is wrong. (b) Give the fatal flaw in the metric. (c) Propose a corrected metric and write the SQL shape it would take against experiment_assignments.

Show solution

(a) First, autoplay is highly noticeable, so a reader who gets it on one article and not the next has an inconsistent experience — and their reaction to article two is contaminated by article one. Second, the effect of interest is almost certainly on session-level or return behaviour: does autoplay drive people away? Randomising per page view makes that unmeasurable, because the same person contributes page views to both arms. Randomise by user.

(b) Conditioning on “users who started a video” selects on a post-treatment outcome. Autoplay changes who starts a video — that is the whole point of it — so the two arms' denominators contain different kinds of people. Treatment sweeps in marginal, uninterested viewers who would never have clicked play; their low completion rate then makes the treatment look worse than it is. Any metric whose denominator is affected by the treatment is broken.

(c) Use a denominator fixed at assignment: video-completions per assigned user, or simply the rate of assigned users who complete at least one video.

sqlfixed_denominator.sql
-- The denominator is every assigned user, so the treatment cannot change it.
-- 'converted' stands in for 'completed at least one video'.
SELECT variant,
       COUNT(*)                                              AS assigned_users,
       SUM(converted)                                        AS users_with_success,
       ROUND(100.0 * SUM(converted) / NULLIF(COUNT(*),0), 2) AS rate_pct
FROM experiment_assignments
WHERE experiment = 'checkout_v2'
GROUP BY variant
ORDER BY variant;

The general rule to carry away: the denominator must be fixed at randomisation time. Anything the treatment can move belongs in the numerator, never the denominator.

Knowledge check

An experiment randomises by user. Which primary metric is correctly specified?

Key takeaway

A design is three things fixed in writing before launch: a hypothesis with a mechanism (so a null result still teaches you something), a randomisation unit at least as coarse as both the level the treatment is experienced and the level at which units interfere, and one primary metric whose denominator is fixed at assignment and computed per randomisation unit. Skip any of the three and you will be arguing about interpretation instead of reading a result.

Lesson 10.4·12 min read

Success Metrics vs Guardrail Metrics

You will be able to build a metric set that can detect the classic failure where a test wins on its own terms while damaging the business.

There is an easy way to increase the number of purchases: cut every price by ninety percent. Purchases rise, the experiment is declared a success, and the company loses money on every order. This is not a hypothetical failure mode; it is the shape of most experiment failures. The metric moved. The business got worse.

Guardrail metrics exist because almost every metric can be moved by damaging something the metric does not measure. A success metric asks “did the change do what we hoped?” A guardrail asks “did it do so by breaking something we care about but were not trying to improve?”

Three kinds of metric, three different jobs

KindQuestion it answersDecision rule
Primary / successDid the intended effect happen?Must improve to ship. Exactly one.
GuardrailDid anything important get worse?Must not degrade beyond a stated tolerance. Blocks shipping.
Diagnostic / secondaryIf it worked, is the mechanism the one we predicted?Never a decision input. Explains, does not decide.

The asymmetry between the first two rows matters. A success metric needs evidence of improvement to justify shipping. A guardrail needs only the absence of evidence of harm — you are not trying to prove the change was harmless, you are checking that no alarm sounded. This means guardrails are usually evaluated against a tolerance band rather than a significance threshold: “we will not ship if average order value drops by more than 2%”.

Marketing Here are all three computed together for checkout_v2. Conversion rate is the success metric; revenue per assigned user and average order value are the guardrails:

sqlmetric_set.sql
-- Success metric: conv_rate_pct
-- Guardrails:     revenue_per_user (business value), avg_order_value (basket health)
SELECT variant,
       COUNT(*)                                          AS n,
       ROUND(100.0 * SUM(converted) / NULLIF(COUNT(*),0), 2) AS conv_rate_pct,
       ROUND(SUM(revenue) / NULLIF(COUNT(*),0), 2)       AS revenue_per_user,
       ROUND(SUM(revenue) / NULLIF(SUM(converted),0), 2) AS avg_order_value
FROM experiment_assignments
WHERE experiment = 'checkout_v2'
GROUP BY variant
ORDER BY variant;
Result
variantnconv_rate_pctrevenue_per_useravg_order_value
control728.5761.14214.00
treatment875.00169.25225.67

Read the row structure, not the magnitudes. revenue_per_user uses the assigned-user denominator, so it is a proper guardrail: it cannot be gamed by converting more people at worse prices, because both effects land in the same number. avg_order_value uses the converter denominator, which makes it a diagnostic, not a guardrail — its denominator changes when the treatment changes who converts, exactly the flaw described in Lesson 10.3. It is useful for understanding what happened. It must not be used to decide.

Open With two converters in control and six in treatment, the average order values here are computed from two and six orders respectively. They should be read as illustrating the query's structure and nothing more.

Analogy

Guardrails are the engine temperature gauge, not the speedometer. You are trying to go faster, so you watch speed. But if the only instrument on the dashboard is speed, you will eventually discover that removing the coolant makes the car lighter. The temperature gauge does not tell you how well you are doing; it tells you when to stop. Nobody wins a race by watching it, and everybody who ignores it eventually stops racing.

Choosing guardrails that would actually fire

A guardrail is only worth its place if there is a plausible story in which this specific change breaks it. Generic dashboards of thirty metrics attached to every test are not guardrails; they are a multiple-comparisons machine (Lesson 10.12) that generates a scary red number on every experiment and trains the team to ignore all of them.

Ask: if this change is secretly bad, how would it be bad? Then measure that.

  • E-commerce Simplifying checkout by removing a confirmation step → guardrail on return and cancellation rate, because faster purchases may be less considered ones.
  • Advertising Increasing ad density to raise revenue per session → guardrails on session length and return visit rate, because the cost arrives later than the benefit.
  • Banking Streamlining an application form → guardrail on downstream default or fraud rate, because removing friction removes it for bad actors too.
  • Gaming A more generous reward loop to raise day-1 retention → guardrail on day-30 retention and spend, because giving things away early can remove the reason to pay later.

Three guardrails belong on nearly every experiment regardless of what is being tested, because they catch implementation damage rather than product damage: page load or response latency, error and crash rate, and overall revenue per assigned user. These catch the case where the change is fine in principle and the code shipping it is not.

💡Intuition — why the guardrail is usually a longer-horizon version of the success metric

Most ways of gaming a metric work by borrowing from the future. Aggressive notifications raise this week's engagement and raise next quarter's unsubscribe rate. Discounts raise this month's orders and lower next month's, because you pulled demand forward. So when you are struggling to pick a guardrail, ask what the same metric looks like over a longer window, or what the metric's cost side is. The guardrail for a volume metric is usually a quality or margin metric; the guardrail for a short-horizon metric is usually the same metric measured later.

When guardrails and success conflict

The hard case is the honest trade-off: conversion up 3%, average order value down 2%. Neither number is a bug. Someone must decide whether the trade is worth taking.

Two approaches, and the difference between them is more organisational than statistical. The first is an overall evaluation criterion — a single combined metric agreed in advance, typically revenue per assigned user, which prices the trade-off automatically. It is clean and it forces the argument to happen before the data exists, which is when it can be had honestly. The second is a tolerance band per guardrail, with a named person empowered to make the judgement call. It is messier but survives contact with metrics that cannot be converted into money, such as trust or accessibility.

Common pitfall — promoting a guardrail to a success metric after the fact

The primary metric comes back flat. Someone notices revenue per user is up and proposes shipping on that basis. This is the multiple-comparisons problem wearing a respectable coat: you declared one metric as the decision input precisely so that you could not go shopping among the others afterwards. Guardrails are one-directional instruments — they can block a ship, they can never justify one. If revenue per user was the thing you cared about, it should have been the primary metric, and the correct move now is to run a new experiment that says so.

When not to add a guardrail

  • When no plausible mechanism connects the change to it. A guardrail on server costs for a copy change is noise generation.
  • When the metric is too noisy to move detectably. A guardrail you are not powered to detect a breach on gives false reassurance, which is worse than no guardrail. If you cannot detect a 20% drop in a rare event, do not pretend the flat reading means anything.
  • When it duplicates another guardrail. Revenue per user and orders per user at a fixed price are largely the same instrument; two of them do not double the protection but do double the false alarms.
  • When the harm is better caught by monitoring than by the experiment. Crash rates are usually watched continuously with automatic rollback. That is a stronger safety mechanism than a metric read at the end of two weeks.
Interview angle

“Your test increased sign-ups by 8%. Would you ship it?” The expected answer is a question, not a yes. Ask what happened to the quality of those sign-ups: activation rate, 30-day retention, revenue per assigned user, and the share that turn out to be fraudulent or duplicate. Then name the mechanism you are worried about — removing email verification would raise sign-ups exactly this way while filling the database with junk. Interviewers use this question specifically to find out whether you think of a metric as a goal or as a proxy for a goal.

Exercise 10.4

Insurance An insurer tests a shortened online quote form. The success metric is quote-completion rate. (a) Name two guardrails with the mechanism by which the change could break each. (b) Write a single query returning, per variant, the success metric and a revenue-based guardrail, both using the assigned-user denominator, plus a flag showing whether the guardrail moved by more than a 5% relative tolerance.

Show solution

(a) First, quote-to-policy conversion: a shorter form collects less information, so quotes may be less accurate and more likely to be revised upward at underwriting, which loses the customer. Second, claims loss ratio on policies sold: fewer risk questions means worse risk selection, so the policies written may be systematically worse than those the long form produced. Both are cases where the removed friction was doing useful work.

sqlguardrail_tolerance.sql
WITH m AS (
  SELECT variant,
         COUNT(*)                                    AS n,
         1.0 * SUM(converted) / COUNT(*)             AS success_rate,
         SUM(revenue) / NULLIF(COUNT(*),0)           AS rev_per_user
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2'
  GROUP BY variant
)
SELECT ROUND(c.success_rate,4)  AS control_success,
       ROUND(t.success_rate,4)  AS treatment_success,
       ROUND(c.rev_per_user,2)  AS control_guardrail,
       ROUND(t.rev_per_user,2)  AS treatment_guardrail,
       ROUND(100.0 * (t.rev_per_user - c.rev_per_user)
             / NULLIF(c.rev_per_user,0), 1) AS guardrail_change_pct,
       CASE WHEN (t.rev_per_user - c.rev_per_user)
                 / NULLIF(c.rev_per_user,0) < -0.05
            THEN 'GUARDRAIL BREACH'
            ELSE 'within 5% tolerance' END AS guardrail_status
FROM m AS c
JOIN m AS t ON c.variant = 'control' AND t.variant = 'treatment';

Both metrics divide by COUNT(*) over assigned users, so neither denominator can be shifted by the treatment. The tolerance is one-sided: a guardrail rising is not a breach, which is what distinguishes a guardrail test from a two-sided significance test on a success metric.

Knowledge check

A test's primary metric is flat, but a guardrail metric improved significantly. What is the correct action?

Key takeaway

Every metric can be improved by breaking something it does not measure, so a success metric alone is never a decision. Pair it with a small number of guardrails chosen by asking “if this change is secretly bad, how?” Guardrails use the assigned-user denominator, are judged against a tolerance rather than a p-value, and are one-directional: they can stop a ship, never authorise one. When a guardrail is really the thing you care about, promote it to primary — in the next experiment, not this one.

Lesson 10.5·14 min read

Sample Size & Minimum Detectable Effect

You will be able to compute, before launching, the smallest effect your traffic can detect — and cancel tests that cannot possibly answer their question.

The single most useful calculation in experimentation takes five minutes and is skipped almost universally. It answers: given the traffic I have and the baseline rate I start from, what is the smallest true effect I have a realistic chance of detecting?

That number is the minimum detectable effect (MDE). If your MDE is a 30% relative lift and nobody in the room believes the change could plausibly produce more than 3%, the experiment is already finished. It will return a flat result regardless of whether the change works, and the team will conclude the change does not work, which is a different and false statement.

What determines how small an effect you can see

Three quantities, and it is worth having intuition for each before any formula appears.

Baseline rate. A conversion rate near 50% carries the most noise per user, because a coin flip is maximally unpredictable at even odds. Rates near 0% or 100% are quieter. This is why rare-event metrics need enormous samples in absolute terms but behave surprisingly well in relative terms.

Sample size per arm. Noise in an estimated rate shrinks with the square root of the sample. To halve your MDE you need four times the users. To detect an effect a tenth the size you need a hundred times the users. Fact This square-root relationship is why small effects are so expensive, and why the industry's ability to detect small effects is essentially a function of traffic volume rather than statistical sophistication.

The error rates you accept. How often you are willing to claim an effect that is not there (the significance level, conventionally 5%) and how often you are willing to miss one that is (one minus power, conventionally 20%). Tightening either costs sample size.

Computing the MDE for a fixed sample

For a two-proportion comparison with equal arms, the MDE in absolute terms is approximately:

  MDE  =  (z_alpha/2 + z_beta)  x  sqrt( 2 x p x (1 - p) / n )

  p  = baseline conversion rate
  n  = users per arm
  z_alpha/2 = 1.96   (two-sided, 5% significance)
  z_beta    = 0.84   (80% power)
  sum       = 2.80

The constant 2.80 is the sum of two values read from the standard normal distribution. SQL engines do not provide the inverse normal function, so the value is hard-coded — a limitation worth stating in the query itself rather than hiding.

Retail Applied to the checkout experiment's control arm:

sqlmde.sql
-- 2.8 = z(0.975) + z(0.80) = 1.96 + 0.84, hard-coded because SQL has no
-- inverse normal CDF. Do not attempt to approximate it inside the query.
WITH base AS (
  SELECT 1.0 * SUM(converted) / COUNT(*) AS p
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2' AND variant = 'control'
),
arms AS (
  SELECT COUNT(*) AS n
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2' AND variant = 'control'
)
SELECT ROUND(b.p, 4) AS baseline_rate,
       a.n          AS n_per_arm,
       ROUND(2.8 * SQRT(2.0 * b.p * (1 - b.p) / a.n), 4) AS mde_absolute,
       ROUND(100.0 * 2.8 * SQRT(2.0 * b.p * (1 - b.p) / a.n)
             / NULLIF(b.p, 0), 1) AS mde_relative_pct
FROM base AS b
CROSS JOIN arms AS a;
Result
baseline_raten_per_armmde_absolutemde_relative_pct
0.285770.6761236.6

Read that carefully, because it is the honest verdict on this dataset. With seven users per arm and a baseline near 29%, the smallest effect detectable at conventional error rates is an absolute increase of 67.6 percentage points — a relative lift of 237%. The treatment arm would need to convert at over 96% for this experiment to be capable of noticing.

No checkout redesign does that. This experiment, as sized, cannot distinguish a genuinely excellent change from no change at all. Every subsequent lesson in this part will keep returning to that fact, because it is the single most important thing about this dataset and pretending otherwise would teach the wrong instinct.

Analogy

MDE is the resolution of a camera. A blurry photo of an empty field is not evidence that the field is empty — it is evidence that your camera cannot resolve anything smaller than a bus. Before concluding there are no sheep, check whether a sheep would have been visible. The sample size calculation is that check, and it is done before you take the photograph, not after you fail to find anything in it.

The inverse question: how many users do I need?

Rearranged for n, with the effect stated as a relative lift on the baseline:

sqlsample_size.sql
-- 7.849 = 2.8 squared, i.e. (z_alpha/2 + z_beta)^2 at 5% significance, 80% power.
WITH grid AS (
  SELECT * FROM (VALUES (0.05), (0.10), (0.20), (0.50)) AS t(mde_rel)
),
base AS (
  SELECT 1.0 * SUM(converted) / COUNT(*) AS p
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2' AND variant = 'control'
)
SELECT ROUND(100 * g.mde_rel, 0) AS relative_lift_pct,
       ROUND(b.p, 4)             AS baseline,
       CAST(CEIL(7.849 * 2.0 * b.p * (1 - b.p)
                 / POWER(g.mde_rel * b.p, 2)) AS BIGINT) AS n_per_arm
FROM grid AS g
CROSS JOIN base AS b
ORDER BY g.mde_rel;
Result
relative_lift_pctbaselinen_per_arm
50.285715699
100.28573925
200.2857982
500.2857157

The quadratic penalty is visible in the column. Halving the effect you want to detect, from 10% to 5%, quadruples the required sample from 3,925 to 15,699 per arm. This is the fundamental economics of experimentation: small effects are not slightly more expensive to measure, they are quadratically more expensive.

It also explains a pattern you will see in practice. Large companies run experiments on 1% effects because they have the traffic; small companies cannot, and should therefore test bigger, bolder changes rather than button colours. The right experiment portfolio is a function of your traffic, not of your ambition.

💡Intuition — the MDE is a business question in statistical clothing

The formula asks you for an effect size, and people often try to answer it statistically — “what lift do we expect?” That is the wrong question and it has no defensible answer before the test. The right question is economic: what is the smallest lift that would change our decision? If a 2% lift would not justify the maintenance cost of the new code, you do not need to detect 2%; set the MDE at the level where the answer starts to matter. This reframing usually shrinks the required sample dramatically, and it forces the team to say what the change is worth.

Corrections the basic formula ignores

  • Unequal arms. A 90/10 split has far less power than 50/50 at the same total traffic, because power is governed by the smaller arm. Splitting unevenly to limit risk exposure is a real trade and should be priced.
  • Clustered randomisation. If you randomise by account and measure per user, your effective sample is closer to the number of accounts than the number of users. Ignoring this is the single most common cause of overconfident B2B experiment results.
  • Multiple variants. Testing three treatments against one control needs a stricter per-comparison threshold, which raises the sample requirement for every arm.
  • Continuous metrics. For revenue rather than conversion, replace p(1-p) with the metric's variance. Revenue is typically right-skewed with a heavy tail, so its variance is large and its required samples are much bigger than a conversion rate over the same users. Winsorising the top percentile is a common, and legitimate, pre-registered response.
Common pitfall — treating a flat result as proof of no effect

“We tested it and there was no difference” is almost always an overstatement. The honest version is “we did not detect a difference, and we were powered to detect effects of at least X”. Without X the sentence carries no information. This matters commercially: teams abandon genuinely good ideas on the strength of underpowered nulls, and the idea then cannot be revisited because “we already tested that”. Always report the MDE alongside a null result, and prefer stating the confidence interval, which shows directly what magnitudes remain plausible.

When the sample size calculation should not drive the decision

  • When you cannot get the sample, ever. Manufacturing With eleven factories, no calculation will produce an adequately powered A/B test. The answer is a different method — crossover designs, time-series analysis, or accepting qualitative evidence — not a badly powered experiment presented as a good one.
  • When the change is reversible and cheap. Ship it behind a flag, monitor, roll back if the metrics look bad. This is a legitimate strategy for low-risk changes and costs far less than a fully powered test.
  • When the effect is expected to be enormous. If the checkout is currently broken and the fix restores it, you do not need 15,000 users per arm to see it.
  • When the calculation is being used to avoid a decision. Demanding a fully powered experiment for every choice is a way of never choosing. Most decisions are made without experiments and always will be.
Interview angle

“We get 50,000 users a week and convert at 4%. How long should we run a test to detect a 5% relative lift?” Do the reasoning aloud rather than reaching for a number. Absolute lift is 0.2 percentage points. Required n per arm is roughly 7.85 x 2 x 0.04 x 0.96 / 0.002², which is on the order of 150,000 per arm, or 300,000 total — six weeks at that traffic. Then add the judgement that separates candidates: six weeks is long enough to hit seasonality and novelty effects, so either accept a larger MDE, or question whether a 5% relative lift is really the decision boundary.

Exercise 10.5

Supply chain A logistics platform wants to test a new delivery-slot picker. Baseline slot-selection rate is the conversion rate observed in the control arm of checkout_v2. Write one query that, for weekly traffic volumes of 500, 5,000 and 50,000 users split evenly across two arms, reports the MDE as both an absolute and a relative figure after four weeks of running.

Show solution
sqlmde_by_traffic.sql
WITH traffic AS (
  SELECT * FROM (VALUES (500), (5000), (50000)) AS t(weekly_users)
),
base AS (
  SELECT 1.0 * SUM(converted) / COUNT(*) AS p
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2' AND variant = 'control'
),
sized AS (
  SELECT t.weekly_users,
         b.p,
         t.weekly_users * 4 / 2 AS n_per_arm     -- 4 weeks, split evenly
  FROM traffic AS t CROSS JOIN base AS b
)
SELECT weekly_users,
       n_per_arm,
       ROUND(p, 4) AS baseline_rate,
       ROUND(2.8 * SQRT(2.0 * p * (1 - p) / n_per_arm), 4) AS mde_absolute,
       ROUND(100.0 * 2.8 * SQRT(2.0 * p * (1 - p) / n_per_arm)
             / NULLIF(p, 0), 2) AS mde_relative_pct
FROM sized
ORDER BY weekly_users;

Two things to notice in the output. The MDE falls as traffic rises, but not proportionally — a hundredfold increase in traffic buys only a tenfold improvement in resolution, because of the square root. And n_per_arm uses integer arithmetic on integer inputs; had the weekly figure been odd, the division would truncate. Casting to a numeric type before dividing is the safe habit whenever a sample size feeds a downstream calculation.

Knowledge check

Your test is powered to detect a 10% relative lift. To detect a 5% relative lift instead, roughly what happens to the required sample size?

Key takeaway

Compute the minimum detectable effect before launching, and cancel the test if the MDE exceeds any effect anyone believes is possible. Sample size scales with the inverse square of the effect — halving the detectable effect quadruples the cost — so small effects are a luxury purchased with traffic. Choose the MDE by asking what lift would change your decision, not what lift you hope for. And never report a null result without stating what you were powered to see.

Lesson 10.6·13 min read

Statistical Power and Why Underpowered Tests Waste Money

You will be able to explain why an underpowered experiment is worse than no experiment, including the counter-intuitive result that its winners are the least trustworthy of all.

Power is the probability that your experiment detects an effect, given that the effect is really there. Convention sets it at 80%: if the change genuinely works at the assumed size, you have a four-in-five chance of noticing.

Stated that way it sounds like a technical parameter. It is not. Power determines what the results of your experiment mean, and low power corrupts both possible outcomes — the null and the win — in different and equally expensive ways.

The two errors and their asymmetric attention

Effect is realEffect is not real
Test says significantCorrect detection (probability = power)Type I error — false positive (probability = alpha)
Test says not significantType II error — missed effect (probability = 1 − power)Correct null

Teams obsess over the top-right cell and set alpha at 5%. They then routinely accept a 20% miss rate — and in practice, because sample sizes are rarely checked, real miss rates far above that. Inference The imbalance is largely cultural rather than principled: a false positive produces a visible embarrassment when the effect fails to materialise, whereas a missed effect produces nothing at all. The good idea is quietly dropped, and nobody ever learns what it would have been worth.

Computing power for a given design

Power depends on how many standard errors the assumed true effect sits away from the critical threshold. Compute that distance in SQL, then read the power off a small hard-coded table — because SQL has no normal CDF and inventing an approximation for one would be worse than admitting the gap.

sqlpower_by_sample.sql
-- Power for detecting a 10% RELATIVE lift on the observed control baseline,
-- at various sample sizes. z_beta = effect/SE - 1.96; power is then read from
-- a hard-coded table because SQL lacks a normal CDF. The bands are approximate
-- and deliberately coarse: they support a go/no-go decision, nothing finer.
WITH grid AS (
  SELECT * FROM (VALUES (7), (50), (500), (5000), (50000)) AS t(n_per_arm)
),
base AS (
  SELECT 1.0 * SUM(converted) / COUNT(*) AS p
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2' AND variant = 'control'
),
calc AS (
  SELECT g.n_per_arm,
         b.p,
         0.10 * b.p AS true_diff,
         SQRT(2.0 * b.p * (1 - b.p) / g.n_per_arm) AS se
  FROM grid AS g CROSS JOIN base AS b
)
SELECT n_per_arm,
       ROUND(p, 4)         AS baseline_rate,
       ROUND(true_diff, 4) AS assumed_abs_lift,
       ROUND(true_diff / se - 1.96, 3) AS z_beta,
       CASE WHEN true_diff / se - 1.96 >= 1.282 THEN 'about 0.90 or more'
            WHEN true_diff / se - 1.96 >= 0.842 THEN 'about 0.80'
            WHEN true_diff / se - 1.96 >= 0.253 THEN 'about 0.60'
            WHEN true_diff / se - 1.96 >= 0.000 THEN 'about 0.50'
            WHEN true_diff / se - 1.96 >= -0.842 THEN 'about 0.20'
            ELSE 'below 0.20 - effectively blind' END AS approx_power
FROM calc
ORDER BY n_per_arm;
Result
n_per_armbaseline_rateassumed_abs_liftz_betaapprox_power
70.28570.0286-1.842below 0.20 - effectively blind
500.28570.0286-1.644below 0.20 - effectively blind
5000.28570.0286-0.960below 0.20 - effectively blind
50000.28570.02861.202about 0.80
500000.28570.02868.040about 0.90 or more

The first three rows are the lesson. At 7, 50, or even 500 users per arm, an experiment looking for a 10% relative lift is effectively blind: it will return “not significant” almost regardless of whether the change works. Running it produces a number, a slide, and a decision — all of which are noise dressed as evidence.

Note The bands above are coarse by design. A query that returned “power = 0.7431” from a hand-rolled polynomial would imply a precision that neither the approximation nor the input assumptions support. Where the honest answer is a range, return a range.

Analogy

An underpowered test is a metal detector with the sensitivity turned down. Sweep the beach, find nothing, conclude there is no treasure. But the setting means it only responds to something the size of a car door. Worse: on the rare occasion it does beep, the signal must have been enormous — so you dig, and find a buried fence post rather than the small gold ring that was actually there. The detector does not just miss things; it systematically misreports the size of whatever it happens to find.

The expensive part: underpowered winners are inflated

This is the result that surprises people, and it is the strongest practical argument for taking power seriously.

In a low-power test, the only way to cross the significance threshold is for random noise to push the estimate far from the truth in the favourable direction. A true 3% lift, in a study powered to detect 30%, cannot reach significance on its own merits — it can only get there on a lucky run. So conditional on being significant, the estimate is badly overstated. This is sometimes called the winner's curse or type M (magnitude) error.

Inference The practical consequence is a familiar organisational pattern: a portfolio of experiments each reporting double-digit wins, and an annual metric that has not moved. Every individual test was analysed correctly. The selection process — ship the ones that reached significance — guaranteed that the shipped set was dominated by overestimates. A team can therefore be rigorous test-by-test and systematically wrong in aggregate.

Worse still, at very low power a significant result can even have the wrong sign with non-trivial probability, because reaching the threshold requires such a large excursion that an excursion in the opposite direction to the true effect is also achievable.

Common pitfall — post-hoc power

After a null result, someone computes the power of the test using the observed effect size and reports “we only had 12% power”. This is circular and adds nothing. Observed power is a one-to-one function of the p-value: a non-significant result always yields low observed power, by construction, so the calculation cannot tell you anything the p-value did not. Power is a property of a design and an assumed effect, computed before the data exists. The correct post-hoc analysis is the confidence interval, which says directly which effect sizes remain compatible with what you saw.

Getting power without getting more traffic

  • Use a less noisy metric. A binary conversion flag discards information. If the outcome has a continuous version with lower relative variance, it may need a far smaller sample.
  • Reduce variance with pre-period data. Adjusting the outcome using each user's behaviour before assignment removes the part of the variation that has nothing to do with the treatment. On metrics with strong individual persistence, such as spend, the effective sample gain can be substantial.
  • Trim the tail. For revenue metrics, a pre-registered cap at a high percentile removes the handful of outliers that dominate the variance.
  • Split 50/50. Power is limited by the smaller arm; uneven splits waste traffic.
  • Test bigger changes. The cheapest way to raise power is to raise the effect. Gaming A studio with modest traffic should be testing whole loop redesigns, not reward-icon variants.

When to run an underpowered test anyway

Not never — but only with the framing changed in advance:

  • As a safety check rather than a hypothesis test. A small rollout that would catch a catastrophic regression is useful even if it cannot detect a 2% improvement. Say so explicitly, and pre-commit to not reading a small positive result as a win.
  • To validate instrumentation. An A/A test on a small sample checks the pipeline, not the product.
  • When results will be pooled. Several small experiments across markets can be combined in a meta-analysis, provided the design and metric are identical and the pooling was planned in advance.
In the field

The most useful thing an analyst can do for an experimentation programme is refuse to run tests. A pre-launch review that says “at our traffic this needs eleven weeks; the MDE at two weeks is 40% and nobody thinks the change does 40%” saves two weeks of traffic and, more valuably, prevents a false conclusion entering the organisation's memory as a fact. The alternative is a backlog of ideas marked “tested, didn't work” on the strength of evidence that could not have shown otherwise.

Interview angle

“A small test shows a 40% lift, significant at p < 0.05. Are you excited?” The expected answer is cautious, and the reasoning is what is being assessed: in a low-powered test the only estimates that reach significance are the ones noise inflated, so 40% is very likely a large overestimate of a smaller true effect — possibly of zero. Name the phenomenon (winner's curse, or type M error), say you would check the confidence interval width, and propose a properly powered replication before making a business commitment on the strength of it.

Exercise 10.6

Advertising An ad platform will accept 90% power rather than 80% for a launch decision. Write a query that, for the observed control baseline and assumed relative lifts of 5%, 10% and 25%, returns the sample size per arm required at both 80% and 90% power, plus the extra users the higher power demands. Use 2.80 and 3.24 as the summed z-values.

Show solution
sqlpower_cost.sql
-- 2.80 = 1.96 + 0.84 (80% power); 3.24 = 1.96 + 1.28 (90% power).
WITH lifts AS (
  SELECT * FROM (VALUES (0.05), (0.10), (0.25)) AS t(rel)
),
base AS (
  SELECT 1.0 * SUM(converted) / COUNT(*) AS p
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2' AND variant = 'control'
),
n AS (
  SELECT l.rel,
         b.p,
         CEIL(POWER(2.80,2) * 2.0 * b.p * (1-b.p)
              / POWER(l.rel * b.p, 2)) AS n80,
         CEIL(POWER(3.24,2) * 2.0 * b.p * (1-b.p)
              / POWER(l.rel * b.p, 2)) AS n90
  FROM lifts AS l CROSS JOIN base AS b
)
SELECT ROUND(100 * rel, 0)   AS relative_lift_pct,
       CAST(n80 AS BIGINT)   AS n_per_arm_80pct,
       CAST(n90 AS BIGINT)   AS n_per_arm_90pct,
       CAST(n90 - n80 AS BIGINT) AS extra_users_per_arm,
       ROUND(100.0 * (n90 - n80) / NULLIF(n80, 0), 1) AS pct_more_traffic
FROM n
ORDER BY rel;

The final column is constant across every row, at roughly 34%. That is not a coincidence: the ratio of required samples is (3.24/2.80)², which does not involve the effect size or the baseline at all. Moving from 80% to 90% power always costs about a third more traffic, whatever you are testing. That is a clean number to carry into a planning conversation.

Knowledge check

An underpowered experiment returns a statistically significant win. What is the most likely problem?

Key takeaway

Power is the chance of finding an effect that is really there, and low power damages both outcomes: nulls become uninformative, and wins become inflated by the winner's curse, because only noise-boosted estimates can clear the bar. Compute power from an assumed effect before launch — never from the observed effect afterwards, which is circular. When traffic is fixed, buy power by reducing variance or by testing bolder changes, and be willing to cancel tests that cannot answer their question.

Lesson 10.7·15 min read

Analysing an A/B Test in SQL

You will be able to compute rates, absolute and relative lift, the pooled z-statistic and a confidence interval in one query — and read the interval honestly when it refuses to support a conclusion.

Everything so far has been design. This lesson is the analysis: one query that takes an assignment table and returns everything needed to make a decision. It is worth writing out in full, once, so that the pieces stop being magic.

The four quantities

The rates. Conversions over assigned users in each arm. Nothing subtle, provided the denominator is right.

The difference. Treatment rate minus control rate, in percentage points.

The standard error. How much the difference would bounce around if you re-ran the same experiment on fresh users. Two versions exist and they are used for different purposes:

  pooled   SE = sqrt( p_pool x (1 - p_pool) x (1/n_c + 1/n_t) )
  unpooled SE = sqrt( p_c(1-p_c)/n_c  +  p_t(1-p_t)/n_t )

  pooled   -> for the TEST STATISTIC. Assumes the null (rates are equal),
              so it estimates one shared rate from both arms combined.
  unpooled -> for the CONFIDENCE INTERVAL. Makes no such assumption,
              so each arm keeps its own rate.

Fact This is not a stylistic choice. The test statistic is computed under the assumption that the null hypothesis is true, and under that assumption both arms share one rate, so pooling is the correct estimate of it. The confidence interval makes no such assumption, so pooling would be wrong there. Mixing them up produces small errors that occasionally flip a borderline call.

The comparison to a critical value. The z-statistic is the difference expressed in standard errors. Whether it is large enough is judged against a fixed number: 1.96 for a two-sided test at the 5% level.

The complete analysis query

Product analytics

sqlab_analysis.sql
-- Full two-proportion analysis.
-- NOTE ON P-VALUES: DuckDB (like most SQL engines) has no normal or t
-- distribution CDF, so a p-value cannot be computed here. We compute the
-- z-statistic and compare it to the hard-coded two-sided critical values:
--   alpha 0.10 -> 1.645   alpha 0.05 -> 1.960   alpha 0.01 -> 2.576
WITH arm AS (
  SELECT variant,
         COUNT(*)       AS n,
         SUM(converted) AS x
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2'
  GROUP BY variant
),
w AS (
  SELECT c.n AS n_c, c.x AS x_c,
         t.n AS n_t, t.x AS x_t,
         1.0 * c.x / c.n AS p_c,
         1.0 * t.x / t.n AS p_t,
         1.0 * (c.x + t.x) / (c.n + t.n) AS p_pool
  FROM arm AS c
  JOIN arm AS t ON c.variant = 'control' AND t.variant = 'treatment'
),
stat AS (
  SELECT *,
         SQRT(p_pool * (1 - p_pool) * (1.0/n_c + 1.0/n_t)) AS se_pooled,
         SQRT(p_c * (1 - p_c) / n_c + p_t * (1 - p_t) / n_t) AS se_unpooled
  FROM w
)
SELECT n_c, x_c, n_t, x_t,
       ROUND(p_c, 4)       AS p_control,
       ROUND(p_t, 4)       AS p_treatment,
       ROUND(p_t - p_c, 4) AS diff,
       ROUND((p_t - p_c) / NULLIF(se_pooled, 0), 3) AS z_stat,
       ROUND((p_t - p_c) - 1.96 * se_unpooled, 4)   AS ci_low,
       ROUND((p_t - p_c) + 1.96 * se_unpooled, 4)   AS ci_high,
       CASE WHEN ABS((p_t - p_c) / NULLIF(se_pooled, 0)) >= 1.96
            THEN 'exceeds 1.96'
            ELSE 'does NOT exceed 1.96' END AS vs_critical_value
FROM stat;
Result
n_cx_cn_tx_tp_controlp_treatmentdiffz_statci_lowci_highvs_critical_value
72860.28570.750.46431.7980.01480.9138does NOT exceed 1.96

Reading this result honestly

The treatment arm converted at 75% against control's 28.6% — an absolute difference of 46.4 percentage points, which as a relative lift is 162%. Presented on a slide, that is a triumph.

It is not a triumph. Three observations, in order of importance:

1. The z-statistic is 1.798, below the 1.96 threshold. By the conventional two-sided test at the 5% level, this result does not reach significance. A 162% apparent lift that fails the test is a good demonstration that lift magnitude and evidential strength are different things entirely.

2. The confidence interval runs from 1.5 to 91.4 percentage points. That interval is ninety percentage points wide. It is consistent with the treatment being trivially better and with it being transformative. Whatever decision you were hoping to inform, this interval does not inform it. The width of the interval, not whether it contains zero, is the honest summary of what the experiment learned — which is close to nothing.

3. The two methods disagree, and that itself is the diagnosis. The interval marginally excludes zero while the pooled test statistic falls short of 1.96. Both cannot be pointing at a clean conclusion. The disagreement arises because both procedures rest on a normal approximation to a binomial distribution, and at four and six successes that approximation is simply not valid. The usual rule of thumb wants at least five successes and five failures per arm; control has two conversions. Open With counts this small the appropriate tool is Fisher's exact test, which enumerates the possible tables directly rather than approximating — and which SQL is a poor vehicle for.

So the correct write-up of this experiment is: the design is valid, the estimate is 46 percentage points, the interval is far too wide to act on, the sample is roughly a thousandth of what Lesson 10.5 showed is needed, and the normal approximation underlying both numbers does not hold at this scale. The experiment is not evidence for the treatment. It is evidence that the experiment was too small.

Analogy

A confidence interval is a weather forecast's range. “Tomorrow: between 4 and 34 degrees” is technically a forecast and practically useless — you still do not know what coat to wear. “Between 17 and 19 degrees” is a forecast you can act on. Notice that neither statement is wrong; the first is simply so wide that it excludes nothing you cared about. Asking “is the difference significant?” is like asking whether the forecast excludes snow. Asking how wide the range is tells you whether to plan the picnic.

💡Intuition — report intervals, not verdicts

“Significant / not significant” compresses a rich estimate into one bit and throws away the part decisions depend on. An interval of [+0.1%, +0.3%] and an interval of [−40%, +45%] both fail to be significant when the second contains zero, but they say utterly different things: the first says the effect is real and small, the second says you learned nothing. Report the estimate and its interval; let the reader see the uncertainty rather than a binary verdict derived from it.

On p-values in SQL

You will be asked for a p-value. Be straightforward about the constraint: standard SQL has no normal, t, or chi-square cumulative distribution function. There are three legitimate responses.

  1. Compute the statistic in SQL, convert outside it. The warehouse produces z_stat; a notebook, a scheduled job, or the BI layer turns it into a p-value. This is the standard production pattern.
  2. Compare against hard-coded critical values, as the query above does. Sufficient for a go/no-go decision and completely transparent.
  3. Use an engine extension if one exists. Some warehouses ship distribution functions as vendor-specific additions. Portable ANSI SQL does not.

The illegitimate response is inventing a closed-form approximation to the normal CDF and presenting its output as a p-value. Such approximations exist and are used in numerical libraries, but embedding one in a business query produces a number nobody can audit, carried to four decimal places, that people will treat as exact.

Common pitfall — computing the metric at the wrong grain

The query above works because experiment_assignments already holds exactly one row per user. Real pipelines rarely do. If you join assignments to an orders table and count rows, a user with three orders contributes three times, the observations are no longer independent, and the standard error comes out too small — producing intervals that are too narrow and significance that is not there. Always aggregate to one row per randomisation unit before the statistics, and add a COUNT(*) = COUNT(DISTINCT customer_id) assertion if you want to sleep at night.

When this query is the wrong analysis

  • Continuous or heavy-tailed metrics. Revenue per user is not a proportion; use the mean and its standard error, and expect outliers to dominate. The proportion formula does not apply.
  • Very small counts. As here. Fewer than about five successes or failures in an arm breaks the normal approximation; use an exact test computed outside SQL.
  • Clustered assignment. If you randomised by account and are analysing users, this query's standard errors are too small by a factor related to the intra-cluster correlation. Aggregate to the cluster.
  • Sequential or continuously monitored tests. The 1.96 threshold assumes a single look at a pre-committed sample size. Lesson 10.12 explains what monitoring does to it.
  • When the arms are not comparable. If Lesson 10.2's SRM check failed, this query returns a precise measurement of a broken comparison.
Interview angle

A frequent live-SQL task: “given an assignments table, compute the lift and tell me whether it is significant.” Two things distinguish a strong answer. First, use the pooled proportion for the test statistic and the unpooled one for the interval, and say why — most candidates use one for both. Second, when asked for the p-value, do not fake it: say the engine has no normal CDF, that you would emit the z-statistic and convert downstream, and that for a decision you would compare against 1.96. Interviewers notice candidates who know the boundary of their tool.

Exercise 10.7

Finance Extend the analysis to revenue per assigned user rather than conversion rate. Report each arm's mean revenue, the sample standard deviation, the difference in means, and a 95% confidence interval using the standard error of a difference in means. Then state why the interval will be even less informative than the conversion one.

Show solution
sqlrevenue_ttest.sql
-- Difference in means. SE = sqrt(s_c^2/n_c + s_t^2/n_t) (Welch, unequal variances).
-- 1.96 is the large-sample critical value; with n this small the t-distribution
-- critical value is materially larger, which the query cannot supply.
WITH arm AS (
  SELECT variant,
         COUNT(*)              AS n,
         AVG(revenue)          AS mean_rev,
         VAR_SAMP(revenue)     AS var_rev,
         STDDEV_SAMP(revenue)  AS sd_rev
  FROM experiment_assignments
  WHERE experiment = 'checkout_v2'
  GROUP BY variant
),
w AS (
  SELECT c.n AS n_c, c.mean_rev AS m_c, c.var_rev AS v_c, c.sd_rev AS sd_c,
         t.n AS n_t, t.mean_rev AS m_t, t.var_rev AS v_t, t.sd_rev AS sd_t
  FROM arm AS c
  JOIN arm AS t ON c.variant = 'control' AND t.variant = 'treatment'
)
SELECT n_c, ROUND(m_c, 2) AS mean_control, ROUND(sd_c, 2) AS sd_control,
       n_t, ROUND(m_t, 2) AS mean_treatment, ROUND(sd_t, 2) AS sd_treatment,
       ROUND(m_t - m_c, 2) AS diff_in_means,
       ROUND((m_t - m_c) - 1.96 * SQRT(v_c/n_c + v_t/n_t), 2) AS ci_low,
       ROUND((m_t - m_c) + 1.96 * SQRT(v_c/n_c + v_t/n_t), 2) AS ci_high
FROM w;

Revenue is worse behaved than conversion for three compounding reasons. It is zero-inflated — most assigned users contribute exactly zero, so the distribution is a spike at zero plus a scattering of large values. It is right-skewed among those who do convert, since one expensive tent outweighs several pairs of shoes. And its variance is therefore large relative to its mean, which directly widens the standard error. On top of that, at seven and eight observations the correct critical value comes from a t-distribution with few degrees of freedom and is considerably larger than 1.96, so even the interval printed here is optimistically narrow. This is the general case: revenue metrics need substantially more traffic than conversion metrics measured over the same users.

Knowledge check

Why is the pooled proportion used for the test statistic but not for the confidence interval?

Knowledge check

An experiment reports a 162% relative lift with a confidence interval on the absolute difference of [1.5pp, 91.4pp]. What should the write-up conclude?

Key takeaway

One query gives you rates, difference, z-statistic and interval — using the pooled proportion for the statistic and the unpooled one for the interval, because they assume different things. SQL cannot produce a p-value; emit the statistic and compare against hard-coded critical values rather than fabricating a formula. And read the width of the interval, not just whether it crosses zero: a 90-point-wide interval on fifteen users is not a finding about the product, it is a finding about the sample.

Part 11

Machine Learning for SQL Users

What each algorithm does, when it applies, and how SQL does the feature engineering that determines whether any of it works.

Lesson 11.1·13 min read

What Machine Learning Actually Is (and Is Not)

By the end you will be able to say precisely what a model does that a CASE expression cannot — and, more usefully, when the CASE expression is the better answer.

Insurance A claims team wants to know which incoming motor claims deserve a manual investigation. Today an underwriter wrote the rule by hand: flag anything above £5,000, anything within 30 days of the policy start, anything from a postcode on a watchlist. The rule catches some fraud. It also drowns three assessors in false alarms, and everyone suspects it misses the patient fraudsters who wait 90 days and claim £4,800.

Someone proposes machine learning. The room hears “magic”. What is actually on offer is narrower and much easier to reason about: instead of a human choosing the thresholds, an algorithm chooses them, by looking at claims whose outcome is already known and finding the numbers that would have separated them best.

That is the whole idea. Machine learning is the automatic fitting of a rule's parameters to historical examples. It is not the automatic discovery of a rule's purpose, nor of what data should exist, nor of what a good outcome is. Those remain yours.

What came before, and why it ran out

Every analytical organisation begins with hand-written logic, and for good reason: it is transparent, instant to change, and auditable. In SQL it looks like a CASE expression. SaaS Here is a hand-written churn rule over the subscription book — a human decided that cheap plans churn and expensive ones do not:

sqlhand_written_rule.sql
-- A human chose the thresholds: plan name and a price cut-off.
-- The final column is the truth we can check the rule against.
SELECT s.customer_id,
       s.plan,
       s.mrr,
       CASE WHEN s.plan = 'basic' AND s.mrr < 25 THEN 'high risk'
            WHEN s.plan = 'pro'                   THEN 'medium risk'
            ELSE 'low risk' END                        AS rule_risk,
       CASE WHEN s.cancelled_on IS NULL THEN 0 ELSE 1 END AS actually_churned
FROM subscriptions AS s
ORDER BY s.customer_id;
Result
customer_idplanmrrrule_riskactually_churned
1pro49.00medium risk0
2basic19.00high risk1
3pro49.00medium risk0
5enterprise199.00low risk0
6basic19.00high risk1
7pro49.00medium risk0
8basic19.00high risk1
10enterprise199.00low risk0
12pro49.00medium risk0
15basic19.00high risk0

The rule is not bad. Every account it called high risk except one did churn, and nothing it called low risk did. But notice what the exercise just became: we produced a column of predictions next to a column of truth, and then judged the gap. Inference Once you have done that, you have already built the machinery machine learning needs. The only thing left to hand over is the choice of the numbers — why 25 and not 30, why plan name and not tenure, why these two inputs and not five.

The mechanism: a scoring function with adjustable knobs

Strip away the vocabulary and a supervised model is three things:

  1. A functional form — the shape of the rule. “Multiply each input by a weight, add them up.” That shape is chosen by the human.
  2. A set of parameters — the weights. These start arbitrary.
  3. A loss function — a single number measuring how wrong the current parameters are on the historical examples.

“Training” is then a search: nudge the parameters in whatever direction makes the loss smaller, repeat until nudging stops helping. Nothing more mystical happens. A model is a CASE expression whose constants were found by a search rather than typed by a person.

flowchart LR
  A["Historical rows
(inputs + known outcome)"] --> B["Functional form
(chosen by human)"] B --> C["Loss
(how wrong, as one number)"] C --> D["Adjust parameters
to reduce loss"] D --> C D --> E["Fitted model
(fixed numbers)"] E --> F["New row with no outcome
→ predicted outcome"]
The loop on the left is training and happens once. The arrow on the right is inference and happens millions of times. Notice that the human chooses the box marked “functional form” and, crucially, supplies the box marked “historical rows” — which is a SQL job.
Analogy

Think of a shower with two taps. The functional form is the plumbing: hot and cold mix, and that is the only thing this shower can ever do. The parameters are how far each tap is turned. The loss is how uncomfortable the water feels. You turn the taps a little, feel, turn again — that is training. Two things follow immediately. First, you will converge on a good setting without ever understanding thermodynamics. Second, no amount of tap-turning will give you water pressure the plumbing cannot produce. Choosing the plumbing is the human's job, and it is the job that matters most.

What machine learning is not

Four honest denials, each of which saves a project:

It is not causal. A model learns that certain inputs travel with certain outcomes; it cannot tell you that changing an input would change the outcome. A churn model may learn that customers who contact support churn more — cancelling the support desk will not fix churn. Fact Establishing causation requires an intervention or an explicit causal design, not a better model.

It is not a data-quality fix. If the historical outcome column is wrong, the model faithfully learns the wrong thing. Models amplify whatever discipline exists upstream; they do not create it.

It is not free of assumptions. Fitting on the past assumes the future resembles it. When a pricing change or a competitor breaks that, the model keeps producing confident numbers and nothing raises an error.

It is not intelligence about your business. The model has never seen your product. It has seen a rectangle of numbers you constructed with SQL, and its ceiling is set by that rectangle.

Common pitfall — treating the model as the project

Teams budget weeks for “the modelling” and days for “pulling the data”, then discover the split was backwards. Defining the outcome column precisely (what exactly is a churned account? cancelled, or lapsed, or downgraded?), assembling one row per entity, and making sure every input was knowable at prediction time is nearly all of the work — and all of it is SQL. Inference The reason this pitfall is so persistent is that the modelling step is the only step with a tidy, well-documented interface, so it looks like the substantive one.

When NOT to use machine learning

Reaching for a model is often the wrong call. Prefer explicit logic when:

  • The rule is already known and stable. VAT is 20%. A parcel over 30 kg needs a freight label. Fitting a model to rediscover a rule someone can simply write down adds error and removes auditability for nothing.
  • You have very few labelled examples. A search over parameters needs examples to search against. With a few dozen rows a model mostly memorises them, and a domain expert's rule will generalise better.
  • The decision must be explainable to a regulator or a customer. In lending and insurance you may be required to state the reason for an adverse decision. A rule states its own reason; a model requires extra machinery to approximate one.
  • The cost of being wrong is asymmetric and severe, and no human reviews the output. A model that is right most of the time is a poor fit for a decision where one wrong answer is catastrophic.
  • The relationship is deterministic. If revenue is quantity times price, compute it. Do not predict it.

Open The boundary genuinely moves. Problems once obviously rule-based — document parsing, address matching — have shifted to learned methods as data and tooling got cheaper. The durable test is not “is this fashionable” but “can a competent person write the rule down, and would the written rule survive contact with next quarter?”

Interview angle

“When would you not use machine learning?” is asked far more often than people expect, and the weak answer is “when the data is too small” and nothing else. Strong answers name the categories: known deterministic rules, explainability requirements, insufficient labelled outcomes, and problems that are really causal questions in disguise. The strongest add the diagnostic question they would ask first — “what decision changes based on this prediction, and who acts on it?” — because a prediction nobody acts on is a cost with no benefit, model or not.

Exercise 11.1

FMCG A planner replenishes stock with a hand-written rule: reorder when on-hand falls below the reorder point. A consultant proposes replacing it with a demand-forecasting model.

(a) Write the SQL that expresses the current rule over inventory joined to skus. (b) Argue whether a model should replace it, and identify the part of the problem where a model would genuinely add something the rule cannot.

Show solution
sqlreorder_rule.sql
SELECT i.sku_id,
       s.brand,
       i.warehouse,
       i.on_hand,
       i.reorder_point,
       CASE WHEN i.on_hand < i.reorder_point THEN 'reorder' ELSE 'hold' END AS rule_decision
FROM inventory AS i
JOIN skus      AS s ON s.sku_id = i.sku_id
ORDER BY i.sku_id;
Result
sku_idbrandwarehouseon_handreorder_pointrule_decision
FM_2025_1MarlowLEE42001500hold
FM_2025_2MarlowLEE11801200reorder
FM_2025_3KellowMAN26501000hold
FM_2025_4MarlowMAN640900reorder
FM_2025_5AshbyBRI3100800hold
FM_2025_6AshbyBRI450700reorder

(b) The decision should stay a rule. “Order when stock falls below a threshold” is deterministic, auditable, and instantly explainable to a warehouse manager — exactly the situation where a model subtracts value. What a model can genuinely add is the reorder point itself, which today is a fixed number per SKU and therefore cannot respond to seasonality, promotions or a regional demand shift. A forecast of demand over the lead time turns a static reorder_point into a moving one. Note the shape of the answer: keep the rule, learn one of its constants. That is the most common and most defensible way machine learning enters an existing process.

Knowledge check

A churn model learns that customers who open support tickets are much more likely to cancel. The retention lead proposes making the support form harder to find. What is wrong with this?

Key takeaway

A model is a rule whose constants were found by searching historical examples instead of typed by a person. It buys you thresholds you could not have guessed and interactions you could not have enumerated. It does not buy you causation, data quality, or business understanding — and when the rule is already known, stable, or legally required to be explainable, the CASE expression wins.

Lesson 11.2·13 min read

Supervised vs Unsupervised vs Reinforcement Learning

Three families, distinguished by one question — what does the answer column look like? — and you can answer it in SQL before anyone writes a model.

Three requests arrive in the same week. Marketing wants to know which trial users will convert. Product wants to know “what types of user do we actually have?” Growth wants to know which of five onboarding emails to send, and to keep improving that choice as results arrive.

These look like three variations on “use the data”. They are three different kinds of problem, and the difference is visible in the shape of the table you would build to answer them. That is the useful framing for a SQL practitioner: the learning family is determined by the answer column.

FamilyAnswer columnWhat is learnedHow you know it worked
SupervisedExists, one per row, known for historyA mapping from inputs to that columnCompare predictions to held-back known answers
UnsupervisedDoes not existStructure — groups, dimensions, odditiesJudgement: is the structure useful and stable?
ReinforcementArrives later, as a reward, only for the action you tookA policy — which action to take in which stateCumulative reward against a baseline policy

Supervised: you have the answers for the past

Supervised learning needs a table with one row per entity, some input columns, and one column holding the outcome you eventually want to predict. That last column is the label. It must be knowable for historical rows and unknown for the rows you will score.

Gaming A studio wants to predict player churn. The label is constructed — nobody stores a churned flag; you define it from behaviour:

sqlsupervised_table.sql
-- One row per player. Left columns are features; the last column is the label.
SELECT p.player_id,
       p.level,
       p.xp,
       p.is_premium,
       COUNT(d.login_date)                AS active_days,
       COALESCE(SUM(d.minutes_played),0)  AS total_minutes,
       CASE WHEN p.last_login < DATE '2025-01-01' THEN 1 ELSE 0 END AS churned_label
FROM players AS p
LEFT JOIN daily_logins AS d ON d.player_id = p.player_id
GROUP BY p.player_id, p.level, p.xp, p.is_premium, p.last_login
ORDER BY p.player_id;
Result
player_idlevelxpis_premiumactive_daystotal_minuteschurned_label
14218200true31560
2173400false001
35829800true21230
43311250false2540
581100false001
66133100true32740
7257300false001
84922400true2950

Two SQL details carry real weight here. The LEFT JOIN keeps players with no logins at all — precisely the players most likely to be churning, and the ones an INNER JOIN would silently delete from the training set. And COALESCE(...,0) turns their missing sum into an honest zero rather than a NULL that most modelling tools will either reject or quietly impute.

Supervised problems split in two by the label's type. A label that is a category (churned_label, fraud yes/no, which of five plans) is classification. A label that is a number on a continuous scale (revenue next quarter, days until reorder) is regression. Lessons 11.4 and 11.5 take one of each.

Unsupervised: there is no answer column

Now the product question: what types of customer do we have? There is no column recording a customer's true type, because “type” is not a fact about the world — it is a summary we are hoping to invent. So the table you build has features and stops:

Retail

sqlunsupervised_matrix.sql
-- Recency, frequency and value per customer. Note: NO label column.
SELECT c.customer_id,
       COUNT(DISTINCT o.order_id)                                            AS order_count,
       ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)),2) AS lifetime_value,
       DATE_DIFF('day', MAX(o.order_date), DATE '2025-02-01')                AS days_since_last_order
FROM customers   AS c
JOIN orders      AS o  ON o.customer_id = c.customer_id AND o.status = 'completed'
JOIN order_items AS oi ON oi.order_id = o.order_id
GROUP BY c.customer_id
ORDER BY lifetime_value DESC;
Result (first six rows)
customer_idorder_countlifetime_valuedays_since_last_order
531162.20138
152806.0561
13805.1023
102797.10102
82697.30166
122497.0079

A clustering algorithm takes this rectangle and groups rows that sit near each other. It returns a group number per customer. It does not return a name for each group — “lapsing high-spender” is something a human reads off the group averages afterwards. And there is no accuracy to compute, because there was never a right answer to compare against. This is the defining discomfort of unsupervised work: the output is a proposal, and its value is decided by whether the business can act differently on each group.

Analogy

Supervised is a language exam with an answer key: you attempt, you check, you get a score. Unsupervised is being handed a box of unlabelled keys and asked to sort them into piles — by length, by teeth, by brand? Every pile is defensible; the only test is whether the piles help you find the right key faster. Reinforcement is learning to cook without a recipe: you taste your own result, adjust, and cook again — and you never find out how the dish you didn't make would have tasted.

Reinforcement: the answer depends on what you did

The growth team's email problem is different in kind. If you send email B to a user and they convert, you learn something about email B for that user — and nothing at all about emails A, C, D or E. The outcome exists only for the action taken. This is the counterfactual problem, and it is what makes reinforcement learning its own family.

A reinforcement setup has a state (what we know about the user now), an action (which email), and a reward (conversion, revenue, retained days). The thing being learned is a policy: a mapping from state to action. Because it can only learn about actions it takes, it must deliberately spend some traffic on actions it currently believes are worse — exploration — in order to avoid locking itself into an early lucky winner.

The SQL relevance is direct: reinforcement learning needs your warehouse to log the action taken alongside the state and the reward. Inference Most warehouses record outcomes and forget which option was served, which makes the data useless for learning a policy after the fact. The experiment_assignments table in this course has exactly the right shape — a variant column next to a converted column — and it is that shape, not the algorithm, that is usually missing.

Common pitfall — inventing a label to force a supervised problem

Faced with “what kinds of customer do we have?”, teams often define segments by hand (“high value = spent over £500”), train a model to predict those segments, and report high accuracy. The accuracy is real and meaningless: the model has learned to reproduce the CASE expression that generated the label, not anything about customers. If your label is a deterministic function of your features, you have built an expensive re-implementation of a threshold. The tell is accuracy that looks too good.

When each family is the wrong choice

Do not use supervised learning when the label can only be obtained by waiting a year, or when it exists for a badly biased slice of rows. Banking A default model trained only on approved loan applicants never sees the applicants who were rejected, so it learns to predict default among people the old policy already liked — and cannot be trusted on anyone outside that envelope.

Do not use unsupervised learning when you actually have a label and a decision. Clustering customers is a common substitute for the harder, more valuable question “who will churn?”, and it is popular precisely because there is no score to fail. If a label exists, use it.

Do not use reinforcement learning when the reward is slow, rare, or noisy relative to traffic, when a bad action is expensive (pricing, credit limits, clinical decisions), or when a simple A/B test would answer the question in a fortnight. Exploration means deliberately serving worse options to real users; that cost has to be worth paying.

Interview angle

Expect a business scenario and “how would you frame this?” The discriminating move is to answer with the table: “I'd build one row per account with these features and a label defined as cancelled within 90 days of the cutoff — so it's supervised classification.” Interviewers are listening for whether you notice that the label is constructed rather than found, and whether you name the window. Candidates who jump straight to naming algorithms usually cannot say what a single row of their training data looks like.

Exercise 11.2

Digital media A streaming service asks: “can we predict, at release, which titles will become hits?” Build the one-row-per-title table that frames this as supervised learning, defining a hit as more than two million lifetime views. Then explain which of your columns must be discarded and why.

Show solution
sqlhit_label.sql
SELECT c.content_id,
       c.title,
       c.type,
       COUNT(v.view_id)                                          AS sampled_views,
       COALESCE(SUM(v.minutes),0)                                AS sampled_minutes,
       ROUND(AVG(CASE WHEN v.completed THEN 1.0 ELSE 0.0 END),3) AS completion_rate,
       CASE WHEN c.views > 2000000 THEN 1 ELSE 0 END             AS is_hit_label
FROM content AS c
LEFT JOIN views AS v ON v.content_id = c.content_id
GROUP BY c.content_id, c.title, c.type, c.views
ORDER BY c.content_id;
Result
content_idtitletypesampled_viewssampled_minutescompletion_rateis_hit_label
1The Salt Roadseries2600.5001
2Nightgalefilm1951.0001
3Deep Fielddocumentary1601.0000
4Harbour Lightsseries2730.5001
5Ashfallfilm1881.0000
6Quiet Machinesdocumentary1300.0000

What must be discarded. Every column derived from viewssampled_views, sampled_minutes, completion_rate — describes what happened after release. The question was “predict at release”, so at prediction time none of them exist. Worse, they are near-copies of the label itself: is_hit_label comes from content.views, and viewing counts move together. A model given these columns would score superbly in testing and be useless in production, because in production they are all zero. This is leakage, and Lesson 11.3 is about preventing it systematically. A legitimate feature set here contains only what is knowable before release: type, release month, genre, budget, cast, marketing spend — and note how much of that this schema does not have, which is itself the finding to report.

Knowledge check

A team clusters customers into five groups and reports 94% accuracy. What should you ask?

Key takeaway

The family follows from the answer column. Label present for history → supervised (categorical = classification, numeric = regression). No label, looking for structure → unsupervised, and there is no accuracy to report. Reward arrives only for the action you took → reinforcement, which requires your warehouse to have logged the action. Decide this with SQL, before anyone opens a modelling tool.

Lesson 11.3·14 min read

Features, Labels, Training & Inference

The four words that make up every modelling conversation — and the single SQL discipline, an as-of date, that separates a model that works from one that only appears to.

Banking A retail bank builds a model to predict which current-account holders will take a loan in the next six months. In testing it is close to perfect. It goes live and performs no better than guessing. The post-mortem finds one column: total_loan_balance, pulled from the current customer record. Everyone who took a loan had a balance. The model had been told the answer.

This is not an exotic failure. It is the default outcome of building training data with an ordinary GROUP BY, and avoiding it is almost entirely a SQL skill. To see why, the four words first.

The four words

Feature. One input column, one entity per row. “Orders in the last 90 days”, “days since signup”, “acquisition channel”. In SQL, a feature is nearly always an aggregate over a window of history, produced by a GROUP BY or a window function.

Label. The outcome column, present for historical rows only. Labels are constructed, not found: no table stores “will churn”. You define it — cancelled within 90 days, ordered again within 180 days — and the definition is a business decision with consequences.

Training. The one-off search that fits parameters to the historical feature/label table.

Inference. Applying the fitted parameters to a new row that has features but no label, to produce a prediction. Also called scoring.

Analogy

Training data is a set of past exam papers with the marker's answers attached. Inference is sitting this year's paper. The bank's mistake was revising from papers where a previous student had already pencilled the answers into the margin: revision felt effortless, and the real paper — with clean margins — was a shock. The margins are the future columns, and the only reliable way to rub them out is to fix a date and refuse to look past it.

The as-of date: the whole discipline in one idea

Pick a cut-off date. Then:

  • Features may only use rows dated on or before the cut-off.
  • The label may only use rows dated strictly after the cut-off, within a stated window.
  • Entities must have existed at the cut-off — you cannot make a prediction about a customer who has not signed up.

Every leakage bug is a violation of one of those three lines. Here is the correct construction. E-commerce The question is: at 30 June 2024, which existing customers will place a completed order in the following 180 days?

sqlpoint_in_time_training_set.sql
WITH cutoff AS (SELECT DATE '2024-06-30' AS as_of),

-- FEATURES: strictly on or before the cut-off.
features AS (
  SELECT c.customer_id,
         c.channel,
         DATE_DIFF('day', c.signup_date, k.as_of)              AS tenure_days,
         COUNT(DISTINCT o.order_id)                            AS orders_to_date,
         COALESCE(ROUND(SUM(oi.quantity * oi.unit_price),2),0) AS gross_to_date
  FROM customers AS c
  CROSS JOIN cutoff AS k
  LEFT JOIN orders AS o       ON o.customer_id = c.customer_id
                             AND o.order_date <= k.as_of
                             AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE c.signup_date <= k.as_of          -- entity must exist at the cut-off
  GROUP BY c.customer_id, c.channel, k.as_of, c.signup_date
),

-- LABEL: strictly after the cut-off, inside a stated window.
label AS (
  SELECT c.customer_id,
         MAX(CASE WHEN o.order_date >  k.as_of
                   AND o.order_date <= k.as_of + INTERVAL 180 DAY
                   AND o.status = 'completed' THEN 1 ELSE 0 END) AS ordered_next_180d
  FROM customers AS c
  CROSS JOIN cutoff AS k
  LEFT JOIN orders AS o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
)

SELECT f.customer_id, f.channel, f.tenure_days,
       f.orders_to_date, f.gross_to_date,
       l.ordered_next_180d
FROM features AS f
JOIN label AS l USING (customer_id)
ORDER BY f.customer_id;
Result
customer_idchanneltenure_daysorders_to_dategross_to_dateordered_next_180d
1paid_search1772547.000
2organic1631298.000
3paid_social1491478.000
4referral1371139.000
5paid_search1192657.001
6organic10100.000
7email831219.000
8paid_social641387.001
9organic501199.000
10paid_search311438.001
11referral131149.000

Three things to notice, each of which is a bug in most first attempts.

The date filter sits in the ON clause, not the WHERE. Put o.order_date <= k.as_of in the WHERE and the LEFT JOIN collapses to an inner join: customer 6, who had no completed orders by the cut-off, would vanish. Losing exactly the never-purchased customers from a repeat-purchase model is a serious and silent bias.

The label CTE re-derives customer rows from scratch rather than filtering the features. If you compute the label only for customers who already had orders, you have quietly restricted the population.

Customers 16 and 17 are absent, correctly — they signed up in October and November 2024, after the cut-off. Including them would be predicting the behaviour of people who did not yet exist.

What the leaky version looks like

Compare the same population built the natural, wrong way:

sqlleaky_do_not_ship.sql
-- WRONG. The feature counts orders over ALL time, including after the cut-off,
-- so it partly contains the answer it is meant to predict.
SELECT c.customer_id,
       COUNT(o.order_id) AS lifetime_orders_all_time,
       MAX(CASE WHEN o.order_date > DATE '2024-06-30' THEN 1 ELSE 0 END) AS ordered_after_cutoff
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id AND o.status = 'completed'
WHERE c.signup_date <= DATE '2024-06-30'
GROUP BY c.customer_id
ORDER BY c.customer_id;
Result (first five rows)
customer_idlifetime_orders_all_timeordered_after_cutoff
131
210
310
410
531

Customer 1 shows 3 lifetime orders against 2 as-of the cut-off; customer 5 shows 3 against 2. The extra order is the label event. The feature and the label are computed from the same rows, so the model learns “high order count implies ordered recently” — a tautology. Test scores soar. Production fails, because at scoring time the future orders have not happened and the feature reads low for everyone.

Common pitfall — leakage through slowly-changing dimension tables

The subtle version does not involve dates at all. Dimension tables are usually overwritten in place: customers.is_active, a customer-tier column, a status flag. Joining them into a training set imports today's value onto a row that is supposed to represent last June. If a customer was downgraded because they churned, the tier column now encodes the outcome. Inference This is why point-in-time correctness and slowly-changing dimensions (Part 7) are the same topic wearing different clothes: without a historised dimension, there is no honest way to know what a customer's tier was at the cut-off.

Training and inference must share one definition

A second failure mode: the training table is built by an analyst in SQL, and the production scoring path is rebuilt by an engineer in application code. Small differences — a 90-day window that is really 3 calendar months, a currency conversion applied in one place and not the other, a NULL filled with 0 in training and left NULL at serving — degrade the model quietly. It does not error; it just gets worse, and nobody can say when it started.

The structural fix is to define each feature once, as a query parameterised by an as-of date, and run the same definition for both purposes: many dates for training, today's date for scoring. This is precisely what a feature store sells, and you can get most of the benefit with a shared view or a dbt model.

flowchart TB
  D["One feature definition
parameterised by as_of"] --> T["as_of = many past dates
→ training table (+ labels)"] D --> S["as_of = today
→ scoring table (no labels)"] T --> M["Fit parameters"] M --> P["Apply to scoring table
→ predictions"] S --> P
The single definition at the top is the whole point. Two separately-written definitions will drift, and the drift is invisible in every dashboard you have.

When this discipline is not needed

Point-in-time construction costs real effort, and it is not always warranted. Skip it when the entity has no history — classifying an image or a document uses only what is inside the object itself, so there is no timeline to violate. Skip it when every feature is genuinely immutable, such as a product's launch dimensions. And skip it when the model is a one-off retrospective analysis whose output nobody will act on prospectively; if you are explaining last year rather than predicting next quarter, using the full year is the correct choice, not a bug.

The test is simple: will this model ever score a row whose outcome has not happened yet? If yes, you need the as-of date. If no, you do not.

Interview angle

“Your model scores 0.99 in testing. What do you check first?” The expected answer is leakage, and interviewers want the mechanism, not the word. Say: I would list every feature and ask whether its value was knowable at the prediction timestamp; I would check that date filters sit in the ON clause so the LEFT JOIN is not silently inner; and I would check whether any dimension column is overwritten in place rather than historised. Naming the ON-versus-WHERE trap in particular signals that you have built one of these rather than read about it.

Exercise 11.3

Telecommunications Using subscriptions as a contract book, build a point-in-time training row at 30 June 2024 predicting cancellation in the following 180 days. Include only contracts that were live at the cut-off, and make sure the tenure feature is measured as-of that date rather than today.

Show solution
sqltelco_pit.sql
WITH k AS (SELECT DATE '2024-06-30' AS as_of)
SELECT s.subscription_id,
       s.plan,
       s.mrr,
       DATE_DIFF('day', s.started_on, k.as_of) AS tenure_days_at_cutoff,
       CASE WHEN s.cancelled_on IS NOT NULL
             AND s.cancelled_on >  k.as_of
             AND s.cancelled_on <= k.as_of + INTERVAL 180 DAY
            THEN 1 ELSE 0 END                  AS cancelled_next_180d
FROM subscriptions AS s
CROSS JOIN k
WHERE s.started_on <= k.as_of                              -- existed at cut-off
  AND (s.cancelled_on IS NULL OR s.cancelled_on > k.as_of) -- still live at cut-off
ORDER BY s.subscription_id;
Result
subscription_idplanmrrtenure_days_at_cutoffcancelled_next_180d
1pro49.001740
2basic19.001571
3pro49.001420
4enterprise199.001130
6pro49.00770
7basic19.00591
8enterprise199.00260

Subscription 5 is correctly excluded: it was cancelled in May 2024, before the cut-off, so it was not a live contract at the moment of prediction. Including already-cancelled contracts would mean predicting churn for accounts that had already churned — a population error that flatters the model. The second WHERE condition is the one people forget, because the row still exists in the table; existence in the table and existence in the population are different questions. Note also that tenure_days_at_cutoff is measured to as_of, not to CURRENT_DATE: computing tenure to today would give every historical row a value it could not have had, which is leakage through a clock rather than through a join.

Knowledge check

In a point-in-time feature query, why must o.order_date <= as_of go in the LEFT JOIN's ON clause rather than the WHERE clause?

Key takeaway

Features look backwards from an as-of date; labels look forwards from it; the entity must have existed at it. Enforce all three in SQL and most modelling failures never happen. Keep the date filter in the ON clause, historise any dimension you join, and define each feature exactly once so training and inference cannot drift apart.

Lesson 11.4·14 min read

Linear Regression: Fitting a Line, Explained

The simplest possible model, computed for real in DuckDB with regr_slope, regr_intercept and regr_r2 — and a live demonstration of the trap that pooling groups sets for it.

Advertising A media buyer has monthly spend and clicks for two channels. Finance asks a straightforward question: if we add £1,000 to next month's paid search budget, roughly how many extra clicks should we expect?

You could eyeball it. You could compute the average cost per click, which answers a subtly different question: it tells you the average of the whole relationship including its fixed part, not the effect of the next pound. Linear regression answers the question actually asked, and DuckDB will compute it for you without leaving SQL.

The mechanism: one line, chosen by minimising squared error

You want a rule of the form predicted_y = intercept + slope × x. Infinitely many lines could be drawn through a scatter of points, so you need a rule for picking one. The standard rule: for each point, take the vertical gap between the actual value and the line — the residual — square it, and add all the squares up. The chosen line is the one making that total as small as possible. This is ordinary least squares.

  clicks
    |                    x
    |             x    /
    |        x      /   x      <- vertical gaps = residuals
    |   x        /
    |      x  /
    |      /
    +--------------------------- cost
       the line that makes the SUM of SQUARED gaps smallest

Why squares rather than plain distances? Two reasons. Squaring makes gaps positive so overshoots and undershoots cannot cancel out, and it penalises one large miss far more than several small ones. The second property is a choice, not a law — it is exactly why least squares is sensitive to outliers, which we return to below. Fact For a straight line the minimising slope and intercept have a closed-form solution, so no iterative search is needed — which is why a database can compute the fit in a single aggregate pass.

Fitting the line in SQL

DuckDB provides the regression aggregates directly. The argument order is (y, x) — dependent variable first — and getting it backwards is the single most common mistake with these functions:

  • regr_slope(y, x) — the change in y per one-unit change in x.
  • regr_intercept(y, x) — the fitted value of y when x is zero.
  • regr_r2(y, x) — the fraction of the variation in y that the line accounts for, between 0 and 1.
sqlfit_clicks_on_cost.sql
-- Fit clicks (y) against cost (x), separately per channel.
-- Argument order is (dependent, independent).
SELECT channel,
       ROUND(regr_slope(clicks, cost), 4)     AS clicks_per_pound,
       ROUND(regr_intercept(clicks, cost), 2) AS intercept,
       ROUND(regr_r2(clicks, cost), 4)        AS r2,
       COUNT(*)                               AS n_points
FROM ad_spend
GROUP BY channel
ORDER BY channel;
Result
channelclicks_per_poundinterceptr2n_points
paid_search0.75873348.630.84066
paid_social0.35792182.120.77306

Read the paid search row as a sentence: within the observed range, each additional pound of paid search spend is associated with about 0.76 additional clicks, and the line accounts for roughly 84% of the month-to-month variation in clicks. Finance's question — £1,000 more — gets the answer “on the order of 760 extra clicks”, with the caveat that we are extrapolating slightly beyond the largest month observed.

The intercept deserves suspicion. It says 3,349 clicks at zero spend, which is not a real prediction — the data contains no month anywhere near zero, and the line has no obligation to behave sensibly outside the range it was fitted on. Inference The intercept here is best read as a positioning constant for the line, not as an estimate of organic traffic.

Residuals: where the model is wrong, and how

The fitted parameters are just numbers, so you can apply them back to the rows and inspect the misses:

sqlresiduals.sql
WITH fit AS (
  SELECT regr_slope(clicks, cost)     AS b1,
         regr_intercept(clicks, cost) AS b0
  FROM ad_spend
  WHERE channel = 'paid_search'
)
SELECT a.spend_date,
       a.cost,
       a.clicks,
       ROUND(f.b0 + f.b1 * a.cost, 1)             AS predicted_clicks,
       ROUND(a.clicks - (f.b0 + f.b1 * a.cost), 1) AS residual
FROM ad_spend AS a
CROSS JOIN fit AS f
WHERE a.channel = 'paid_search'
ORDER BY a.spend_date;
Result
spend_datecostclickspredicted_clicksresidual
2024-01-012400.0048005169.4-369.4
2024-02-012650.0052805359.1-79.1
2024-03-012900.0058005548.7251.3
2024-04-014200.0063006535.0-235.0
2024-05-014560.0068406808.131.9
2024-06-013020.0060405639.8400.2

Residuals are the model's own confession. Structure in them — runs of the same sign, or size growing with x — is evidence the straight line is the wrong shape. Here the April point stands out: a large spend month with fewer clicks than the line expects, consistent with the campaign mix changing from brand_core to nonbrand that month — a real feature the model was never given.

Analogy

Fitting a line is hanging a straight curtain rail on a wall of unevenly-placed hooks. You cannot touch every hook, so you position the rail to minimise the total strain — and because strain grows with the square of the stretch, one hook far out of line pulls the rail toward it much harder than three hooks slightly off. That is least squares in one image, including its weakness: a single badly-placed hook can tilt the whole rail.

Pooling: the same data, a much worse line

Now fit one line to both channels at once, ignoring the channel column:

sqlpooled_fit.sql
-- The same twelve rows, but blind to which channel each belongs to.
SELECT ROUND(regr_slope(clicks, cost), 4)     AS slope,
       ROUND(regr_intercept(clicks, cost), 2) AS intercept,
       ROUND(regr_r2(clicks, cost), 4)        AS r2,
       COUNT(*)                               AS n
FROM ad_spend;
Result
slopeinterceptr2n
0.83141934.000.228712

Both channels individually reach an r² of 0.84 and 0.77. Pooled, the same twelve rows give 0.23. Nothing about the data changed — only which column the model was allowed to see. Paid search and paid social have genuinely different click economics, and forcing one line through both produces a slope that describes neither. The lesson generalises well beyond regression: a model can only separate groups it has been told about, and choosing what to condition on is a SQL decision made before any fitting happens.

Common pitfall — treating r² as a grade

r² measures how much of y's variation the line accounts for in the data it was fitted on. It says nothing about whether the relationship is causal, whether the line will hold outside the observed range, or whether the model will work on next quarter's data. It also rises mechanically as you add columns, so comparing r² across models with different numbers of inputs is meaningless. A high r² on six monthly points, as here, is especially weak evidence: with few points a line can fit closely by luck.

When NOT to use linear regression

Linear regression is the right first model surprisingly often, and the wrong one in identifiable situations:

  • The outcome is a category or a probability. A 0/1 label fitted with a line produces predictions below 0 and above 1. That is Lesson 11.5's subject.
  • The relationship saturates. Advertising response famously flattens: the tenth thousand pounds buys fewer clicks than the first. A straight line assumes the return per pound never changes, so it will overstate the value of large increases. Fitting on a log of spend, or a curve, respects the shape.
  • The outcome is bounded or a count near zero. Predicting units sold with a line will happily produce −3 units.
  • There are strong outliers. Squared error makes one extreme point dominate. Median-based or robust methods resist this; least squares does not.
  • Observations are not independent. Time series data has autocorrelation — this month resembles last month — which makes the fit look more certain than it is.
  • Inputs are near-duplicates of one another. Impressions and clicks move together; a regression on both splits the credit between them arbitrarily, so individual coefficients become unstable even while overall predictions stay fine.
Note — dialect support

The regr_* family is part of the SQL standard's aggregate set and is available in DuckDB, PostgreSQL, Snowflake, BigQuery and Oracle among others, but not in SQLite or MySQL, where you compute the slope by hand from covariance over variance. The companions regr_count, regr_avgx, regr_sxx and regr_sxy expose the raw sums. All of them ignore rows where either argument is NULL — convenient, but worth knowing: your fit may quietly rest on fewer rows than your table has.

Interview angle

Two things get tested. First, interpretation: “the slope is 0.76 — say that in a sentence a marketer would accept.” Answer “associated with about 0.76 more clicks per additional pound, within the range we observed” — the hedge is the point. Second: “compute a regression slope without a regr_ function.” That is the covariance-over-variance form built from AVG, SUM and COUNT, worth knowing since portable code cannot assume the aggregate exists.

Exercise 11.4

Supply chain A planner wants to convert a forecast of units into a forecast of revenue for the biscuits business. Fit revenue against units over the sales table, both pooled and per region, and explain which fit you would actually hand over — and why the pooled r² being high is not sufficient reassurance.

Show solution
sqlunits_to_revenue.sql
SELECT 'ALL'                             AS region,
       ROUND(regr_slope(revenue, units), 4)     AS revenue_per_unit,
       ROUND(regr_intercept(revenue, units), 2) AS intercept,
       ROUND(regr_r2(revenue, units), 4)        AS r2,
       COUNT(*)                                 AS n
FROM sales
UNION ALL
SELECT region,
       ROUND(regr_slope(revenue, units), 4),
       ROUND(regr_intercept(revenue, units), 2),
       ROUND(regr_r2(revenue, units), 4),
       COUNT(*)
FROM sales
GROUP BY region
ORDER BY region;
Result
regionrevenue_per_unitinterceptr2n
ALL2.2131294.020.865510
East3.7618-779.091.00002
North1.9026689.710.99773
South1.9211601.390.85853
West0.47201676.161.00002

Hand over the pooled fit, with caveats — and refuse to hand over East or West at all. Both have exactly two data points, and any two points define a line perfectly, which is why both report an r² of exactly 1.0000. That is not a good model; it is arithmetic — a line through two points has nothing left to explain. East's intercept of −779 makes the absurdity explicit: the fit predicts negative revenue at low volumes. North and South, with three points each, are only marginally better.

The deeper point is that r² cannot detect this problem: a perfect r² on two points and on two thousand points print the same number, and only the second means anything. Always report n alongside any fit. Finally, the genuinely correct answer here needs no regression at all — revenue is units times realised price, a deterministic identity. The real question is “what price will we realise”, which is about promotions and mix: exactly the “do not model a deterministic relationship” rule from Lesson 11.1.

Knowledge check

Fitting clicks on cost per channel gives r² values of 0.84 and 0.77. Pooling both channels into one fit gives 0.23 on exactly the same twelve rows. What happened?

Key takeaway

Linear regression picks the line minimising summed squared residuals, and DuckDB fits it in one pass with regr_slope(y,x), regr_intercept(y,x) and regr_r2(y,x). Read the slope as “change in y per unit of x, within the observed range”, distrust the intercept when zero is far from your data, always report n, and remember that fitting one line across genuinely different groups destroys a good model — the GROUP BY is part of the modelling.

Lesson 11.5·14 min read

Logistic Regression & Predicting Probabilities

Why a straight line cannot predict a yes/no outcome, what the S-curve fixes, and why the threshold that turns a probability into a decision is a business choice rather than a modelling one.

Gaming A studio wants to intervene before players stop playing. The label is binary: did this player go quiet, yes or no. The obvious move is to reuse Lesson 11.4's machinery — fit a line predicting the 0/1 column from player level — and it fails in an instructive way.

Where the straight line breaks

Run both approaches side by side on the same rows. The linear column uses a real fitted line; the logistic column uses an S-curve with illustrative coefficients chosen to show the shape:

sqlline_vs_scurve.sql
WITH labelled AS (
  SELECT player_id,
         level,
         CASE WHEN last_login < DATE '2025-01-01' THEN 1 ELSE 0 END AS churned
  FROM players
),
fit AS (   -- a genuine least-squares line fitted to the 0/1 column
  SELECT regr_slope(churned, level)     AS b1,
         regr_intercept(churned, level) AS b0
  FROM labelled
)
SELECT l.player_id,
       l.level,
       l.churned,
       ROUND(f.b0 + f.b1 * l.level, 3)                        AS linear_prediction,
       ROUND(1.0 / (1.0 + exp(-(3.0 - 0.10 * l.level))), 3)   AS logistic_prediction
FROM labelled AS l
CROSS JOIN fit AS f
ORDER BY l.level;
Result
player_idlevelchurnedlinear_predictionlogistic_prediction
5811.0380.900
21710.8290.786
72510.6440.622
43300.4590.426
14200.2510.231
84900.0880.130
3580-0.1200.057
6610-0.1890.043

Look at the first and last rows of linear_prediction: 1.038 and −0.189. A 103.8% chance of churning, and a −18.9% chance. These are not roundable inconveniences; they are evidence that the model's output is not a probability at all. And the problem worsens exactly where it hurts: extend the line to level 80 and it gets more confidently negative, because a straight line has no floor and no ceiling.

The logistic column never leaves the interval. It approaches 0 and 1 but cannot reach either, no matter how extreme the input.

The mechanism: a squashing function

Logistic regression keeps the linear part unchanged and wraps it in one extra step. Compute a score, z = intercept + slope × x, exactly as before — z can be any number at all. Then pass it through the logistic function, also called the sigmoid:

  probability = 1 / (1 + e^(-z))

   1.0 |                    ______________
       |               ____/
   0.5 |------------ /  <- z = 0 maps to exactly 0.5
       |        ____/
   0.0 |_______/____________________________
        -6    -3     0     3     6      z
sqlsigmoid_shape.sql
-- The squashing function itself, evaluated at a few scores.
SELECT s AS score,
       ROUND(1.0 / (1.0 + exp(-s)), 4) AS probability
FROM (SELECT unnest([-6.0, -3.0, -1.0, 0.0, 1.0, 3.0, 6.0]) AS s);
Result
scoreprobability
-6.00.0025
-3.00.0474
-1.00.2689
0.00.5000
1.00.7311
3.00.9526
6.00.9975

Three properties fall out of this table. The output is always strictly between 0 and 1. A score of zero means “even odds”. And the curve is steepest in the middle: moving the score from −1 to 0 changes the probability by about 0.23, while moving from 3 to 6 changes it by about 0.04. Near-certain cases are hard to move; borderline cases are where an input actually matters.

The second change is to the loss function. Squared error is the wrong measure for a probability, so logistic regression instead maximises the likelihood of the observed labels — equivalently, it minimises log loss, which punishes confident wrong answers extremely harshly. Predicting 0.99 for something that does not happen costs far more than predicting 0.6 and being wrong. That asymmetry is what pushes the model toward honest, well-spread probabilities rather than defensive ones clustered around 0.5.

Analogy

A straight line is a ramp — keep walking and you go through the floor or off the roof. The sigmoid is a hill: steep in the middle, flattening near the top and the bottom, so no matter how hard you push you approach the summit without passing it. That flattening is exactly the sensible statement “this player is already very likely to churn; ten more levels of evidence barely changes that.”

Reading the coefficients: log-odds

The linear part of logistic regression does not predict a probability — it predicts the log-odds. Odds are probability divided by its complement: a probability of 0.75 is odds of 3, or 3-to-1. The log of that is the score. This is why the coefficients are not directly readable as “this many percentage points”: a coefficient says how much the log-odds move per unit of input, and the same shift in log-odds means a large probability change in the middle of the curve and almost none at the ends.

The convention that survives translation to a stakeholder is the odds ratio — exponentiate the coefficient and say “each additional level multiplies the odds of churning by this much”. Multiplying odds is exact; adding percentage points is not.

Common pitfall — the 0.5 threshold on an imbalanced label

Tools default to calling anything above 0.5 a “yes”. When the outcome is rare — fraud, default, serious churn — a well-calibrated model may never produce a probability above 0.5, so at the default threshold it predicts “no” for every row and reports high accuracy. The model may be perfectly good; the threshold is wrong. Accuracy is the misleading metric here: if an event occurs in 1% of rows, predicting “no” always is 99% accurate and worth nothing. Inference The threshold belongs to the business, and it should be derived from the relative cost of a missed case versus a false alarm — not inherited from a library default.

Calibration: are the probabilities true?

A model can rank perfectly and still lie about magnitude. Healthcare If a readmission model gives a group of patients a 20% risk, then roughly 20 in every 100 of them should be readmitted. When it is 40, the ranking may still be useful for triage but the number cannot be used in any calculation — and clinical and financial decisions typically use the number.

Checking calibration is a SQL job, not a modelling one: bucket the scored rows by predicted probability, and compare the average prediction in each bucket with the observed rate. Systematic gaps mean the model needs recalibrating before anyone multiplies its output by a cost.

When NOT to use logistic regression

  • The relationship is strongly non-linear or interaction-heavy. Logistic regression is linear in the log-odds. If risk rises and then falls, or an input only matters for one segment, you must hand-build those terms — and tree-based methods find them for you.
  • You only need a ranking. If the decision is “call the top 200 accounts this week”, nothing depends on the probabilities being true, and a method that ranks better is preferable even if uncalibrated.
  • The events are extremely rare and the drivers are known. With a handful of positives, a hand-built rule from domain expertise is often more stable, and easier to defend.
  • The outcome is not really binary. Forcing “downgraded, paused, cancelled, dormant” into one churn flag discards information the business cares about; a multi-class or survival framing keeps it.
  • What you need is time-to-event. “Will they churn in 90 days” and “how long until they churn” are different questions, and the second is a survival problem — a binary window silently throws away everyone still active at the horizon.

Open Whether to prefer logistic regression or a gradient-boosted tree on ordinary tabular business data is genuinely contested. Trees typically capture interactions with less manual effort; logistic regression gives coefficients you can defend line by line, extrapolates more predictably, and is far easier to monitor. The choice usually turns on who has to justify the decision, not on which scores better.

Interview angle

The standard question is “why not linear regression for a binary outcome?” The complete answer has three parts, and most candidates give one: predictions escape the 0–1 range; squared error is the wrong loss for a probability; and the effect of an input is assumed constant when it should saturate. A frequent follow-up is “your model has 99% accuracy — are you happy?” The expected reply is to ask for the base rate, then move the discussion to precision, recall, and the cost of each error type. Volunteering that the decision threshold is a business input rather than a model output reliably reads as senior.

Exercise 11.5

Score every player with the sigmoid above, then sweep the decision threshold across 0.3, 0.5 and 0.7. For each threshold count true positives, false positives and missed churners. Then recommend a threshold for a campaign where the intervention is a cheap in-game reward.

Show solution
sqlthreshold_sweep.sql
WITH scored AS (
  SELECT player_id,
         level,
         CASE WHEN last_login < DATE '2025-01-01' THEN 1 ELSE 0 END AS churned,
         1.0 / (1.0 + exp(-(3.0 - 0.10 * level)))                   AS p_churn
  FROM players
)
SELECT t.threshold,
       SUM(CASE WHEN p_churn >= t.threshold AND churned = 1 THEN 1 ELSE 0 END) AS true_positives,
       SUM(CASE WHEN p_churn >= t.threshold AND churned = 0 THEN 1 ELSE 0 END) AS false_positives,
       SUM(CASE WHEN p_churn <  t.threshold AND churned = 1 THEN 1 ELSE 0 END) AS missed
FROM scored
CROSS JOIN (SELECT unnest([0.3, 0.5, 0.7]) AS threshold) AS t
GROUP BY t.threshold
ORDER BY t.threshold;
Result
thresholdtrue_positivesfalse_positivesmissed
0.3310
0.5310
0.7201

Recommend the lower threshold. Read the trade-off down the table: raising the bar to 0.7 removes the single false positive but loses a real churner. When the intervention is a cheap reward, a false positive costs almost nothing — a loyal player receives a small gift — while a miss costs a whole player's future value. The costs are wildly asymmetric, so you should accept false alarms freely and set the threshold low.

Reverse the economics and the answer reverses. If the intervention were an outbound call from a retention specialist, each false positive burns expensive human time and 0.7 becomes defensible. Nothing about the model changed between those two recommendations — only the cost of being wrong, which lives entirely outside it. One caveat for any real write-up: eight players is far too few to choose a threshold from, so treat the shape of the trade-off as the lesson and the numbers as illustration.

Knowledge check

A fraud model on a dataset where 1% of transactions are fraudulent reports 99% accuracy and flags nothing at the default 0.5 threshold. What is the most likely explanation?

Key takeaway

Logistic regression is linear regression with two changes: the score is squashed through 1/(1+e−z) so the output is always a genuine probability, and the loss becomes log loss so confident mistakes are punished hard. Coefficients live in log-odds, so report them as odds ratios. The model gives you a probability; turning it into a yes/no requires a threshold, which is a business decision derived from the cost of a false alarm against the cost of a miss — never a library default.

Lesson 11.6·14 min read

Decision Trees: Splitting on Questions

Understand a decision tree as nothing more than a stack of CASE WHEN conditions that a machine chose for you — and learn to write the SQL that decides which condition is worth choosing.

Insurance An underwriting team decides which motor policies go to a human assessor using a rule written years ago: if the driver is under 25, or the vehicle exceeds a value threshold, or there was a recent claim, escalate. It is a nested chain of IF statements, and it works reasonably well.

The tree that underwriter drew by hand and the tree a machine-learning library produces are the same object. The only difference is who chose the questions and thresholds. The underwriter chose from experience; the algorithm measures, for every possible question, how much cleaner the two resulting groups are than the group it started with, and keeps the winner.

That is the whole idea. A decision tree is a learned CASE WHEN. Everything else in this lesson is arithmetic.

What came before, and why it was not enough

Before trees, the standard tool for “predict yes or no” was logistic regression — a weighted sum of the inputs squashed into a probability. It is fast and easy to explain, but it assumes each input pushes the answer in one consistent direction, and that inputs act independently unless you manually multiply them together.

Real business behaviour breaks both. Claim risk is not monotonic in vehicle age — new cars are expensive to repair, very old ones are poorly maintained, and the middle is safest. And risk factors interact: a young driver on a small engine is unremarkable; a young driver on a large engine is not. A regression only learns that interaction if a human creates the term. A tree gets it free, because every split happens inside the region carved out by the splits above it. That nesting Inference is the single mechanical reason trees handle interactions without being told about them.

The mechanism: measuring how clean a split is

Start with a group of rows, each carrying a label — here, “did this customer come back and order again?” A group where 50% are repeat buyers is maximally uncertain: guessing tells you nothing. A group where 100% are repeat buyers is perfectly certain. We need a number for that, and the usual one is Gini impurity:

gini = 1 - (p_yes^2 + p_no^2)

  p_yes = 1.0  ->  gini = 1 - (1.00 + 0.00) = 0.00   perfectly pure
  p_yes = 0.8  ->  gini = 1 - (0.64 + 0.04) = 0.32
  p_yes = 0.5  ->  gini = 1 - (0.25 + 0.25) = 0.50   maximally impure
  p_yes = 0.0  ->  gini = 1 - (0.00 + 1.00) = 0.00   perfectly pure

The algorithm then does something brutally simple and computationally expensive: for every column, and every threshold in it, split the rows, compute each side's impurity, take a size-weighted average, and subtract that from the parent's impurity. Largest drop wins. Repeat inside each child until a stopping rule fires — too few rows, too deep, or no split improves anything.

You can do exactly this in SQL, because “split the rows and measure each side” is a GROUP BY. First, a training table: one row per subject, features, and one label column.

sqltraining_table.sql
-- The universal ML-prep shape: ONE ROW PER SUBJECT, features + label.
-- Everything a decision tree ever sees looks like this.
WITH cust AS (
  SELECT c.customer_id,
         c.channel,
         COUNT(DISTINCT o.order_id) AS orders,
         COALESCE(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)), 0) AS revenue
  FROM customers AS c
  LEFT JOIN orders AS o
         ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id, c.channel
)
SELECT customer_id, channel, orders,
       ROUND(revenue, 2) AS revenue,
       CASE WHEN orders >= 2 THEN 1 ELSE 0 END AS is_repeat   -- the label
FROM cust
ORDER BY customer_id;
Result (first six rows)
customer_idchannelordersrevenueis_repeat
1paid_search3805.101
2organic1283.100
3paid_social1478.000
4referral1139.000
5paid_search31162.201
6organic00.000

The LEFT JOIN matters more than it looks. Customers 6, 16 and 17 never completed an order; an inner join would delete them, and deleting your negative examples is how you train a model that thinks everybody buys. Now evaluate one candidate question — is lifetime revenue at least 300?

sqlsplit_purity.sql
-- Score ONE candidate split the way a tree-growing algorithm does.
WITH cust AS (
  SELECT c.customer_id,
         COUNT(DISTINCT o.order_id) AS orders,
         COALESCE(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)), 0) AS revenue
  FROM customers AS c
  LEFT JOIN orders AS o
         ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id
),
labelled AS (
  SELECT customer_id, revenue,
         CASE WHEN orders >= 2 THEN 1 ELSE 0 END AS is_repeat
  FROM cust
),
branched AS (
  SELECT CASE WHEN revenue >= 300 THEN 'revenue >= 300'
              ELSE 'revenue < 300' END AS branch,
         is_repeat
  FROM labelled
)
SELECT branch,
       COUNT(*)        AS rows_in_branch,
       SUM(is_repeat)  AS repeaters,
       ROUND(AVG(is_repeat * 1.0), 3) AS p_repeat,
       ROUND(1 - (AVG(is_repeat*1.0)*AVG(is_repeat*1.0)
                + (1-AVG(is_repeat*1.0))*(1-AVG(is_repeat*1.0))), 3) AS gini
FROM branched
GROUP BY branch
ORDER BY branch;
Result
branchrows_in_branchrepeatersp_repeatgini
revenue < 3001000.0000.000
revenue >= 300760.8570.245

The left branch is perfectly pure and needs no further questions. The right branch is nearly pure, so the algorithm would try to split it again. Grow one more level and you get a three-leaf tree:

                  [ all 17 customers ]
                   p_repeat = 0.35
                         |
              revenue >= 300 ?
              /                    \
           no                       yes
            |                        |
      [ leaf A: 10 rows ]      channel is paid ?
      p_repeat = 0.00            /          \
                              yes            no
                               |              |
                     [ leaf B: 6 rows ]  [ leaf C: 1 row ]
                     p_repeat = 0.83     p_repeat = 1.00

A prediction is just: walk down, answer the questions, report the leaf's proportion. Notice leaf C rests on a single customer — a warning we will return to.

Analogy

A decision tree is the game twenty questions. Each question splits the remaining possibilities into two piles, and a good player asks the question that splits most evenly-informatively rather than one that eliminates a single obscure answer. “Is it alive?” is a great first question; “is it a badger?” is a terrible one. Gini impurity is simply the scoreboard that lets a computer tell those two apart, and the tree is the transcript of the questions it settled on.

Where SQL does the real work

Nothing in the modelling step is the hard part. The hard part is producing that one-row-per-subject table without lying to yourself, and three details do most of the damage.

Leakage. Our label was orders >= 2 and one feature was orders. The tree could split on orders >= 2 and be perfect — and useless, because it needs the answer to compute the question. Every feature must be computable strictly before the prediction moment: date-bound the feature CTEs (WHERE o.order_date < :cutoff) and the label separately (WHERE o.order_date >= :cutoff).

NULLs. A tree needs a rule for rows where the split column is missing; some libraries send them down a default branch, some refuse to train. Decide explicitly in SQL — a COALESCE to a sentinel, plus a boolean was_null feature, because missingness itself is often informative. In customers, country is NULL for two people, and the model can only test whether that matters if you preserve it.

Categorical columns. A tree splits text by asking “is the value in this subset?”, so a column with thousands of distinct values offers thousands of ways to carve out tiny pure groups — overfitting by construction. Bucket high-cardinality columns in SQL before they reach the model.

Common pitfall — reading a deep tree as an explanation

Trees are called interpretable, and a three-leaf tree genuinely is. But a tree grown to depth fifteen has tens of thousands of leaves, and its individual splits are not stable: change a handful of training rows and the top split can flip to a different column entirely, producing a completely different-looking tree with nearly identical accuracy. Inference That instability is why “the model says region matters most because it split on region first” is a claim worth distrusting. Interpretability is a property of small trees, not of trees.

When not to use a decision tree

A single tree is rarely the right final answer, and there are four situations where it is clearly wrong:

  • You need extrapolation. A tree predicts a constant within each leaf, so its output is a staircase with a flat ceiling. Feed it a revenue figure ten times larger than anything it has seen and it returns the largest leaf's value. For a trending time series, that is disqualifying.
  • The true relationship is a smooth line. If risk rises steadily with exposure, a tree needs many splits to approximate what a regression captures in one coefficient — and it will be noisier at every point.
  • You have very few rows. With small data, a tree will happily carve out leaves containing one or two rows, memorising noise. Our leaf C above — one customer, predicted probability 1.00 — is precisely this failure in miniature.
  • You need calibrated probabilities. Leaf proportions from a deep tree cluster near 0 and 1 and are usually overconfident. For pricing or reserving decisions, that miscalibration is expensive.

Healthcare The realistic middle ground: a shallow, deliberately-limited tree is excellent as a communication device. A clinical triage team can read a four-leaf tree, argue with it, and sign it off. A model they cannot read will not be deployed regardless of accuracy. Where accuracy is what matters, you move to ensembles — which is Lesson 11.7.

Interview angle

“How does a decision tree decide where to split?” is a standard screen. Weak answers say “it picks the most important feature”, which restates the question. The expected answer names an impurity measure (Gini or entropy), states that the algorithm evaluates candidate thresholds exhaustively and greedily picks the largest weighted impurity reduction, and adds that greedy means locally optimal, not globally optimal. For a data role the follow-up is almost always SQL-shaped: “how would you build the training table?” — and the thing they are listening for is whether you mention leakage and time-bounding unprompted.

Exercise 11.6

Banking A growth team wants to know whether acquisition channel would make a better first split than revenue. Write SQL that treats each value of customers.channel as its own branch, and reports for each: the number of customers, how many are repeat buyers (two or more completed orders), the repeat proportion, and the Gini impurity of that branch. Order the results so that the purest branches appear first.

Show solution
sqlchannel_split.sql
WITH cust AS (
  SELECT c.customer_id, c.channel,
         COUNT(DISTINCT o.order_id) AS orders
  FROM customers AS c
  LEFT JOIN orders AS o
         ON o.customer_id = c.customer_id AND o.status = 'completed'
  GROUP BY c.customer_id, c.channel
)
SELECT channel,
       COUNT(*) AS customers,
       SUM(CASE WHEN orders >= 2 THEN 1 ELSE 0 END) AS repeaters,
       ROUND(AVG(CASE WHEN orders >= 2 THEN 1.0 ELSE 0.0 END), 3) AS p_repeat,
       ROUND(1 - (AVG(CASE WHEN orders >= 2 THEN 1.0 ELSE 0.0 END)
                * AVG(CASE WHEN orders >= 2 THEN 1.0 ELSE 0.0 END)
            + (1 - AVG(CASE WHEN orders >= 2 THEN 1.0 ELSE 0.0 END))
            * (1 - AVG(CASE WHEN orders >= 2 THEN 1.0 ELSE 0.0 END))), 3) AS gini
FROM cust
GROUP BY channel
ORDER BY gini, channel;
Result
channelcustomersrepeatersp_repeatgini
email200.0000.000
paid_search441.0000.000
referral200.0000.000
organic510.2000.320
paid_social410.2500.375

Three branches come back perfectly pure, which looks superb and is mostly an artefact of size: email and referral hold two customers each. This is the trap a real algorithm guards against with a minimum-rows-per-leaf rule. The honest reading is that paid_search shows a genuinely different repeat rate, and the other channels are too thin to distinguish. Note also that most implementations search binary subsets rather than splitting a category many ways — but the purity arithmetic is identical.

Knowledge check

A tree is trained to predict whether a customer will place a second order. One of the input features is “total number of completed orders”. What is wrong?

Knowledge check

A branch contains 20 rows, 10 positive and 10 negative. What is its Gini impurity, and what does that tell you?

Key takeaway

A decision tree is a learned nest of CASE WHEN conditions. It picks each condition greedily, by measuring which split most reduces impurity in the rows below it, and it predicts the average label of whichever leaf you land in. Its power over regression comes from nesting, which captures interactions and non-monotonic effects for free; its weakness is that it memorises noise and cannot extrapolate. Your job in SQL is the one-row-per-subject training table — features computed strictly before the prediction cutoff, label computed strictly after. Get that wrong and no algorithm can save you.

Lesson 11.7·14 min read

Random Forests & Gradient Boosting (XGBoost) Intuition

Two ways to turn many weak trees into one strong model — averaging independent guesses versus correcting your own mistakes — and what each one demands of your SQL.

Banking A retail bank builds a single decision tree to flag current accounts likely to close within ninety days. On the training data it is almost flawless. On last quarter's held-back data it is barely better than guessing, and when the modeller re-runs it after adding one week of fresh data, the top split changes from tenure to overdraft usage and the whole tree redraws itself.

That is not a bug in the code. It is the defining weakness of a single tree: high variance. A tree grown deep enough to capture real structure also captures the accidents of the particular rows it was handed. Change the rows a little, get a very different tree.

There are exactly two general strategies for fixing this, and every serious tree-based model in production uses one of them. You can build many trees on deliberately different views of the data and average them — that is a random forest. Or you can build trees in sequence, each one trained to fix what the previous ones got wrong — that is gradient boosting, of which XGBoost, LightGBM and CatBoost are implementations.

Random forests: average away the noise

The intuition rests on an old statistical fact: if you average many estimates that are individually noisy but whose errors are independent, the noise partly cancels while the signal does not. So the entire design problem is manufacturing independence between trees, and a forest does it twice over.

Row randomness (bagging). Each tree is trained on a bootstrap sample — a random draw of rows, with replacement, the same size as the original. Roughly a third of rows are absent from any given tree, which gives that tree a slightly different picture of the world. In SQL the operation is a sample:

sqlbootstrap_view.sql
-- One tree's view of the training rows. A forest builds hundreds of these,
-- each with a different seed, and averages what they conclude.
SELECT COUNT(*) AS rows_in_this_bag
FROM (
  SELECT customer_id, channel, country
  FROM customers
  USING SAMPLE 60% (bernoulli, 42)
) AS bag;

Column randomness. At every split, the tree is only allowed to consider a random subset of the available features. This is the less obvious half and it matters more. Without it, one dominant feature would be chosen as the first split by nearly every tree, and the trees would end up near-identical — averaging clones buys you nothing. Forcing trees to sometimes work without the strongest feature makes their errors genuinely different.

RANDOM FOREST  (parallel, independent, averaged)

   rows+cols sample --> tree 1 --> 0.71 \
   rows+cols sample --> tree 2 --> 0.44  |--> mean = 0.62
   rows+cols sample --> tree 3 --> 0.68  |
   rows+cols sample --> tree 4 --> 0.65 /

GRADIENT BOOSTING  (sequential, dependent, summed)

   baseline 384.59
        |
        +--> tree 1 fits the ERRORS  --> +105
                |
                +--> tree 2 fits what is LEFT --> +22
                        |
                        +--> tree 3 --> -6   ... prediction = sum

Gradient boosting: fit the leftovers

Boosting starts somewhere deliberately stupid — usually the overall average — and then asks a different question: where is this prediction wrong, and by how much? The gap between the truth and the current prediction is the residual. A small tree is trained to predict the residual, a fraction of its output is added to the running prediction, and the process repeats.

The residual step is the whole algorithm, and it is perfectly expressible in SQL. Here is stage zero of a revenue model — the flat baseline — and the average residual left over in each acquisition channel:

sqlboosting_stage0.sql
-- Stage 0: predict the global mean for everyone.
-- Then look at where that prediction is systematically wrong.
-- A boosting round 1 tree would split on exactly this structure.
WITH actual AS (
  SELECT c.customer_id, c.channel,
         COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS revenue
  FROM customers AS c
  LEFT JOIN orders AS o
         ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id, c.channel
),
stage0 AS (SELECT AVG(revenue) AS baseline FROM actual)
SELECT a.channel,
       COUNT(*) AS customers,
       ROUND(AVG(a.revenue), 2)                       AS avg_actual,
       ROUND(MAX(s.baseline), 2)                      AS stage0_prediction,
       ROUND(AVG(a.revenue) - MAX(s.baseline), 2)     AS avg_residual
FROM actual AS a CROSS JOIN stage0 AS s
GROUP BY a.channel
ORDER BY avg_residual DESC;
Result
channelcustomersavg_actualstage0_predictionavg_residual
paid_search4923.50384.59538.91
paid_social4303.50384.59-81.09
organic5198.80384.59-185.79
email2174.00384.59-210.59
referral2144.00384.59-240.59

Read that as the algorithm reads it. The flat prediction of 384.59 is badly too low for paid search and too high for everything else. A depth-one tree that splits on channel = 'paid_search' and predicts these residuals removes a large chunk of the error in one move. Multiply the correction by a learning rate — typically a small fraction, so you take a cautious step rather than the full one — add it, recompute residuals, and go again. Hundreds of small corrections compose into a model far more accurate than any tree in it.

Analogy

Ask a crowd to guess the weight of an ox. A random forest is taking the average of a thousand independent guesses, each person seeing the ox from a different angle and none allowed to hear the others — individual errors scatter in both directions and largely cancel. Boosting is a relay: the first person guesses, you tell the second “that guess was 40kg light, guess the remaining 40”, and so on. The relay converges faster and lands closer — but if one person mishears, everyone after inherits the mistake. That is the trade in one sentence: forests are robust because nobody listens; boosting is accurate because everybody does.

The trade-off in practice

 Random forestGradient boosting
Trees builtIndependently, in parallelIn sequence, each on the last one's errors
Individual treeDeep, low bias, high varianceShallow, high bias, low variance
Combining ruleAverage (or majority vote)Weighted sum of corrections
Effect of more treesPlateaus; extra trees rarely hurtKeeps improving, then overfits
Tuning burdenLow — sensible defaults usually workHigh — learning rate, depth, rounds interact
Failure modeUnderfits subtle structureOverfits noise and outliers, silently

Inference The asymmetry in the last two rows explains a lot of professional practice. Adding trees to a forest is safe, so a forest is a good first model when you want a defensible baseline quickly. Adding rounds to a booster is not safe, so boosting requires an honest validation set and early stopping — and it is where competition-winning accuracy on tabular data usually comes from.

Common pitfall — trusting the feature-importance chart

Every tree ensemble emits a feature-importance ranking, and it is the most over-interpreted output in applied machine learning. The default measure counts how much each feature reduced impurity across the ensemble, and it is biased towards features with many distinct values — a continuous column or a high-cardinality id gets more chances to be chosen than a boolean, regardless of real predictive value. It also splits credit arbitrarily between correlated features: put lifetime revenue and average order value in the same model and each may look half as important as either would alone. Inference Importance ranks a feature's usefulness to this particular model. It is not a causal statement and should never be handed to a business stakeholder as one.

When not to use an ensemble

  • When the decision must be explained line by line. Insurance Regulated pricing and lending decisions often require a reason code the customer can contest. Averaging five hundred trees produces a number nobody can trace to a rule. A shallow single tree or a regression is chosen here for accountability, at a real cost in accuracy.
  • When the data is not tabular. Ensembles are the strong default for rows-and-columns data. For images, audio, or free text with meaning in word order, they are the wrong family entirely.
  • When you must extrapolate. Every tree in the ensemble predicts a constant per leaf, so the ensemble inherits the flat ceiling. For a metric that trends upward over time, a forest trained on last year will systematically under-predict this year. Model the trend separately, or predict a ratio rather than a level.
  • When the honest answer is a small effect. If the signal is weak, a booster will find something — and that something will be noise. A model that beats the baseline by an amount you cannot distinguish from chance is worse than no model, because it will be believed.
  • When latency or footprint is tight. Hundreds of trees mean hundreds of traversals per prediction. That is fine in a batch job and can be too slow inside a request handler.
Interview angle

“What is the difference between bagging and boosting?” is asked constantly, and the memorised answer — “parallel versus sequential” — scores one mark. The full answer names the mechanism: bagging reduces variance by averaging independently-trained high-variance learners; boosting reduces bias by sequentially fitting high-bias learners to the residuals of the current ensemble. The strongest candidates then state the consequence without being asked: more trees cannot really hurt a forest, but more rounds will eventually overfit a booster, so boosting needs early stopping on a validation set. If the interviewer is hiring for analytics engineering, expect the conversation to turn to how you would build and time-bound the feature table — that is where the actual job is.

Exercise 11.7

Telecommunications You have run boosting stage one: the model now predicts each customer's revenue as the average revenue of their acquisition channel. Write SQL that computes the residual left after that stage, then groups those residuals by customers.is_active. If a boosting round two tree would find useful structure there, the average residuals for the two groups will differ.

Show solution
sqlboosting_stage2.sql
WITH actual AS (
  SELECT c.customer_id, c.channel, c.is_active,
         COALESCE(SUM(oi.quantity * oi.unit_price), 0) AS revenue
  FROM customers AS c
  LEFT JOIN orders AS o
         ON o.customer_id = c.customer_id AND o.status = 'completed'
  LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id, c.channel, c.is_active
),
stage1 AS (
  SELECT channel, AVG(revenue) AS channel_prediction
  FROM actual
  GROUP BY channel
),
resid AS (
  SELECT a.customer_id, a.is_active,
         a.revenue - s.channel_prediction AS residual
  FROM actual AS a
  JOIN stage1 AS s ON s.channel = a.channel
)
SELECT is_active,
       COUNT(*) AS customers,
       ROUND(AVG(residual), 2) AS avg_residual_after_stage1
FROM resid
GROUP BY is_active
ORDER BY is_active;
Result
is_activecustomersavg_residual_after_stage1
false2-251.15
true1533.49

Stage one absorbed the channel effect; what remains still tilts negative for inactive customers and slightly positive for active ones. A round-two stump splitting on is_active would therefore find a genuine reduction in error — which is exactly how boosting discovers structure the previous stage could not see. The caution: two rows in the inactive group is far too few to trust the size of that correction, and this is precisely the situation in which a learning rate earns its keep, by taking only a small fraction of the suggested step.

Knowledge check

Why does a random forest restrict each split to a random subset of features, rather than always considering all of them?

Key takeaway

One tree is unstable. Forests fix instability by averaging deliberately-decorrelated trees, reducing variance. Boosting fixes inaccuracy by fitting each new shallow tree to the residuals of everything built so far, reducing bias. Forests are safe and forgiving; boosting is more accurate and will overfit if you let it run unwatched. Neither can extrapolate beyond the training range, and neither gives you a causal explanation — feature importance ranks usefulness to that model, nothing more. The SQL work is unchanged from Lesson 11.6: a clean, time-bounded, one-row-per-subject table.

Lesson 11.8·15 min read

Clustering & K-Means

Learn what K-Means actually does to a table of numbers, write the SQL that produces the feature vectors it consumes, and see why scaling those features is not optional.

Retail A retailer's marketing team runs one email to everyone. Someone proposes segmentation, and the first attempt is a set of hand-drawn rules: spend over £500 is “VIP”, no purchase in six months is “lapsed”, everyone else is “core”. It works until the rules multiply. Where does a customer who spent £600 nine months ago belong? Every new rule creates a boundary dispute, and nobody can say whether the segments describe real groups or just the thresholds someone picked in a meeting.

Clustering inverts the question. Instead of you defining the groups and assigning members, you define what it means for two customers to be similar, and the algorithm finds groups such that members are close to each other and far from everyone else. This is unsupervised learning: there is no label, no right answer, and therefore no accuracy score. That absence changes how you must judge the output, and it is where most clustering projects go wrong.

The mechanism: alternate assign, then move

K-Means needs one thing from you up front: k, the number of groups. Then it repeats two steps until nothing changes.

  1. Place k centroids. A centroid is just a point in feature space — a made-up customer described by one value per feature. The first k are placed randomly or by a spreading heuristic.
  2. Assign. Every real customer joins the nearest centroid, measured by straight-line (Euclidean) distance.
  3. Move. Each centroid relocates to the mean position of the customers that joined it.
  4. Repeat steps 2 and 3. Assignments shuffle, centroids drift, and the process stops when no customer changes group.

What it is minimising is the total squared distance from each point to its own centroid — the within-cluster sum of squares. Two facts follow directly from that objective, and both matter. Because it uses squared distance, it is drawn to spherical, similarly-sized blobs. And because it averages, a single extreme outlier drags a centroid a long way.

The SQL job: building feature vectors

K-Means consumes a matrix of numbers. Every row is a subject, every column a numeric coordinate. There is no place for text, no place for NULLs, and — critically — no tolerance for columns measured on different scales. Producing that matrix is the analyst's real work. The classic starting vector for customer segmentation is RFM: recency, frequency, monetary value.

sqlfeature_vectors.sql
-- Build the numeric matrix K-Means consumes, and standardise it.
-- Raw columns first, then z-scores: (value - mean) / standard deviation.
WITH spend AS (
  SELECT c.customer_id,
         MAX(o.order_date) AS last_order,
         COUNT(DISTINCT o.order_id) AS frequency,
         COALESCE(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)), 0) AS monetary
  FROM customers AS c
  JOIN orders AS o
    ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id
),
vec AS (
  SELECT customer_id,
         DATE_DIFF('day', last_order, DATE '2025-02-01') AS recency_days,
         frequency,
         CAST(monetary AS DOUBLE) AS monetary
  FROM spend
),
stats AS (
  SELECT AVG(recency_days) AS r_mu, STDDEV_SAMP(recency_days) AS r_sd,
         AVG(frequency)    AS f_mu, STDDEV_SAMP(frequency)    AS f_sd,
         AVG(monetary)     AS m_mu, STDDEV_SAMP(monetary)     AS m_sd
  FROM vec
)
SELECT v.customer_id,
       v.recency_days, v.frequency, ROUND(v.monetary, 2) AS monetary,
       ROUND((v.recency_days - s.r_mu) / NULLIF(s.r_sd, 0), 2) AS z_recency,
       ROUND((v.frequency    - s.f_mu) / NULLIF(s.f_sd, 0), 2) AS z_frequency,
       ROUND((v.monetary     - s.m_mu) / NULLIF(s.m_sd, 0), 2) AS z_monetary
FROM vec AS v CROSS JOIN stats AS s
ORDER BY v.customer_id;
Result (first six rows)
customer_idrecency_daysfrequencymonetaryz_recencyz_frequencyz_monetary
1233805.10-1.481.800.94
23731283.101.46-0.80-0.61
33581478.001.33-0.80-0.03
43471139.001.24-0.80-1.04
513831162.20-0.521.802.00
72931219.000.79-0.80-0.80

Look at the raw columns and the reason for standardising is obvious. Recency spans tens to hundreds of days; frequency spans one to three. Euclidean distance adds squared differences across columns, so a hundred-day gap in recency would swamp every possible difference in frequency. Without scaling, K-Means on this table would silently become K-Means on recency alone. Fact Standardising rewrites each column as “standard deviations from its own mean”, which puts all columns on a comparable footing.

Note also what the JOIN did: customers 6, 13, 16 and 17 have no completed orders and vanish. Here that is deliberate — you cannot compute recency for someone with no orders, and never-purchasers are a segment you should handle separately rather than smuggle in with an invented value.

Assigning rows to the nearest centroid, in SQL

Given a set of centroids, the assignment step is a cross join and an ARG_MIN. This is exactly what a scoring pipeline does every night once the centroids are fixed:

sqlassign_clusters.sql
-- The assignment half of K-Means: nearest centroid wins.
WITH spend AS (
  SELECT c.customer_id,
         MAX(o.order_date) AS last_order,
         COUNT(DISTINCT o.order_id) AS frequency,
         COALESCE(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)), 0) AS monetary
  FROM customers AS c
  JOIN orders AS o ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id
),
vec AS (
  SELECT customer_id,
         DATE_DIFF('day', last_order, DATE '2025-02-01') AS recency_days,
         frequency, CAST(monetary AS DOUBLE) AS monetary
  FROM spend
),
stats AS (
  SELECT AVG(recency_days) r_mu, STDDEV_SAMP(recency_days) r_sd,
         AVG(frequency) f_mu,    STDDEV_SAMP(frequency) f_sd,
         AVG(monetary) m_mu,     STDDEV_SAMP(monetary) m_sd
  FROM vec
),
z AS (
  SELECT v.customer_id,
         (v.recency_days - s.r_mu) / NULLIF(s.r_sd,0) AS zr,
         (v.frequency    - s.f_mu) / NULLIF(s.f_sd,0) AS zf,
         (v.monetary     - s.m_mu) / NULLIF(s.m_sd,0) AS zm
  FROM vec AS v CROSS JOIN stats AS s
),
centroids(cluster_id, cr, cf, cm) AS (
  VALUES (1, -0.8,  1.0,  1.0),    -- recent, frequent, high value
         (2,  0.0, -0.3,  0.0),    -- middling on everything
         (3,  1.2, -0.6, -0.7)     -- long lapsed, one-off, low value
)
SELECT z.customer_id,
       ARG_MIN(c.cluster_id,
               (z.zr-c.cr)*(z.zr-c.cr) + (z.zf-c.cf)*(z.zf-c.cf)
             + (z.zm-c.cm)*(z.zm-c.cm)) AS assigned_cluster
FROM z CROSS JOIN centroids AS c
GROUP BY z.customer_id
ORDER BY assigned_cluster, z.customer_id;
Result
customer_idassigned_cluster
11
51
81
101
121
151
142
23
33
43
73
93
113

The squared distance is compared, not the square root — taking the root would not change which centroid is nearest, and skipping it saves work. Cluster 2 attracted a single customer, which is what happens when a centroid sits in an empty part of the space. A real implementation would either drop it or re-seed it.

Analogy

Picture organising a large room of strangers into three conversation circles. You drop three chairs at random and tell everyone to stand by the nearest chair. Then you slide each chair to the middle of the crowd that gathered around it — which makes some people now closer to a different chair, so they move, so the chairs move again. After a few rounds nobody wants to move and you have three circles. Two things follow immediately: where you put the chairs initially changes the final answer, and if you had brought four chairs you would have got four circles, whether or not the room contains four kinds of people.

Choosing k, and the fact that you cannot really

Adding clusters always reduces within-cluster sum of squares — with k equal to the row count, every point is its own centroid and the error is zero. So you cannot pick k by minimising error. The usual devices are the elbow method (plot the error against k and look for the bend where extra clusters stop helping much) and the silhouette score (per point, compare its distance to its own cluster against its distance to the nearest other cluster).

Open Both are heuristics, and the elbow is frequently ambiguous — on real business data the curve often bends gently with no clear corner. There is no purely statistical answer to “how many segments does this business have?”, because the question is partly operational: if the team can run four campaigns, k above four produces segments nobody will act on.

Common pitfall — clustering unscaled columns

This is the most common clustering error in analytics, and it is invisible because the algorithm never complains. Feed it lifetime revenue in the thousands alongside order count in single digits, and revenue accounts for essentially all the distance. You will get clean-looking clusters, present them confidently, and have produced nothing but revenue quartiles with extra steps. Inference A quick diagnostic: if your clusters can be reproduced by NTILE on one column, one column is all the model used. Standardise, and consider a log transform first for heavily skewed money columns.

When not to use K-Means

  • Non-spherical clusters. K-Means draws straight-line boundaries between centroids. Genuinely elongated, nested or crescent-shaped groups get sliced across. Density-based methods handle those; K-Means will confidently return the wrong answer.
  • Wildly unequal cluster sizes. The squared-error objective prefers balanced clusters, so a small tight group beside a large diffuse one tends to be absorbed rather than found — which is a problem when the small group is the valuable one.
  • Categorical or mixed data. There is no meaningful mean of “paid_search”. One-hot encoding text into 0/1 columns technically runs but distorts distances. K-Modes or Gower distance exist for this reason.
  • Outliers you have not handled. Means are not robust. One customer with an enormous order can pull a centroid away from the group it was meant to describe. Cap or exclude extremes before clustering, deliberately.
  • When you need stability over time. Re-run K-Means next month on slightly changed data and cluster 1 may not correspond to last month's cluster 1 at all. Labels are arbitrary and unstable, which makes “segment 3 grew this quarter” a treacherous claim unless you have explicitly matched centroids across runs.
  • When simple rules would do. Gaming If a studio only ever acts on “spender / non-spender / lapsed”, three CASE WHEN branches are transparent, stable, and arguable. Clustering earns its complexity only when you genuinely do not know what the groups are.
Interview angle

“How would you segment our customers?” is a favourite analytics-interview prompt, and the trap is to answer with an algorithm. Strong candidates start with the decision the segments will drive, then the feature vector, then scaling, then k — and they say out loud that clustering has no accuracy metric so the segments must be validated by whether they behave differently on a metric that was not used to build them. A frequent technical follow-up is “why standardise?”; the answer is that Euclidean distance sums squared differences across columns, so the column with the widest raw range dominates the geometry.

Exercise 11.8

Digital media Before committing to K-Means, produce the cheap baseline it has to beat. Split purchasing customers into three value buckets using NTILE(3) ordered by monetary value descending, then profile each bucket: how many customers, average days since last order, average order frequency, average spend. If K-Means later returns something that looks like this table, it has added nothing.

Show solution
sqlntile_baseline.sql
WITH spend AS (
  SELECT c.customer_id, c.channel,
         MAX(o.order_date) AS last_order,
         COUNT(DISTINCT o.order_id) AS frequency,
         COALESCE(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100.0)), 0) AS monetary
  FROM customers AS c
  JOIN orders AS o ON o.customer_id = c.customer_id AND o.status = 'completed'
  JOIN order_items AS oi ON oi.order_id = o.order_id
  GROUP BY c.customer_id, c.channel
),
vec AS (
  SELECT customer_id, channel,
         DATE_DIFF('day', last_order, DATE '2025-02-01') AS recency_days,
         frequency, CAST(monetary AS DOUBLE) AS monetary
  FROM spend
),
bucketed AS (
  SELECT *, NTILE(3) OVER (ORDER BY monetary DESC) AS value_bucket
  FROM vec
)
SELECT value_bucket,
       COUNT(*) AS customers,
       ROUND(AVG(recency_days), 1) AS avg_recency_days,
       ROUND(AVG(frequency), 2)    AS avg_frequency,
       ROUND(AVG(monetary), 2)     AS avg_monetary
FROM bucketed
GROUP BY value_bucket
ORDER BY value_bucket;
Result
value_bucketcustomersavg_recency_daysavg_frequencyavg_monetary
1598.02.40853.55
24275.81.25369.28
34249.81.00154.00

The buckets separate cleanly on frequency and spend, which is unsurprising since spend defined them. What is more interesting is recency: bucket 1 is markedly more recent, but buckets 2 and 3 are not ordered — the third bucket is more recent than the second. That non-monotonic behaviour is precisely the kind of structure a multi-dimensional method can exploit and a single-column ranking cannot. Always build this baseline first: it tells you whether clustering has anything left to find.

Knowledge check

You cluster customers on two raw columns: lifetime revenue (range roughly 100–1200) and order count (range 1–3). What will K-Means effectively do?

Key takeaway

K-Means alternates two steps — assign every point to its nearest centroid, then move each centroid to the mean of its members — until nothing changes. The algorithm is trivial; the SQL that builds and standardises the feature matrix is the job. One row per subject, numeric columns only, no NULLs, and every column z-scored so that no single unit of measurement decides the geometry. There is no accuracy score to hide behind: judge segments by whether they differ on something you did not feed the model, and by whether the business can actually act on that many of them.

Lesson 11.9·13 min read

Hierarchical Clustering & When to Prefer It

Build groups by repeatedly merging the two nearest things, get a whole tree of nested groupings instead of one flat answer — and learn why the pairwise distance matrix decides whether you can afford it.

Manufacturing A components manufacturer holds several thousand part numbers and wants a rationalised product hierarchy. The awkward part is not the grouping — it is that different teams need different levels of it. Procurement wants a dozen buying families. Engineering wants a few hundred fine distinctions. Finance wants four reporting lines. K-Means answers “here are your k groups” and you must pick k before you start, which means running it three times and hoping the three answers nest sensibly. They will not.

Hierarchical clustering answers a different question: what is the complete nesting structure of similarity in this data? It gives you every level at once, and you slice it wherever each audience needs. That single property — you choose the number of clusters after seeing the structure rather than before — is the main reason to prefer it.

The mechanism: merge the nearest pair, repeatedly

The common form is agglomerative, meaning bottom-up:

  1. Start with every item as its own cluster of one.
  2. Compute the distance between all pairs of clusters.
  3. Merge the closest pair into a single cluster.
  4. Repeat until one cluster contains everything.

Record the order of merges and the distance at each one, and you have a dendrogram — a tree whose height at every join shows how dissimilar the two merged branches were. Cutting the tree at a chosen height yields a flat set of clusters. Cut low for many tight groups, high for few broad ones.

Step 2 hides a decision. Once a cluster has more than one member, “the distance between two clusters” is ambiguous, and the choice of linkage changes the shape of the result:

LinkageCluster-to-cluster distance is…Tendency
Singlethe distance between the two closest membersFinds elongated, chain-like shapes; prone to linking separate groups through a bridge of stragglers
Completethe distance between the two furthest membersProduces compact, similar-sized clusters; sensitive to outliers
Averagethe mean distance over all cross-pairsA middle course; the common default
Wardthe increase in within-cluster variance caused by mergingBehaves most like K-Means; favours balanced, spherical clusters
DENDROGRAM  (height = distance at which the merge happened)

 distance
   3.0 |                    _______________
       |                   |               |
   2.0 |            _______|____           |
       |           |            |          |
   1.2 |        ___|___      ___|___       |
       |       |       |    |       |      |
   0.9 |    ___|___    |    |       |      |
       |   |       |   |    |       |      |
   0.0 |  VR8    RT3  VR7  RT4    ...     ...

   cut here ---> 2 clusters      cut lower ---> 4 clusters

The SQL job: the pairwise distance matrix

Where K-Means asks SQL for a feature matrix, hierarchical clustering asks for something more demanding: the distance between every pair. That is a self-join with an inequality, and it is the step that decides whether the method is viable at all.

sqldistance_matrix.sql
-- Standardise two features, then compute every pairwise distance.
-- a.product_id < b.product_id keeps each pair once and drops self-pairs.
WITH vec AS (
  SELECT p.product_id, p.product_name,
         CAST(p.unit_price AS DOUBLE)               AS price,
         CAST(p.unit_price - p.unit_cost AS DOUBLE) AS margin
  FROM products AS p
  WHERE p.category_id IN (1, 2)          -- Running Shoes and Trail Shoes
),
stats AS (
  SELECT AVG(price) AS pm, STDDEV_SAMP(price) AS ps,
         AVG(margin) AS mm, STDDEV_SAMP(margin) AS ms
  FROM vec
),
z AS (
  SELECT v.product_id, v.product_name,
         (v.price  - s.pm) / NULLIF(s.ps, 0) AS zp,
         (v.margin - s.mm) / NULLIF(s.ms, 0) AS zm
  FROM vec AS v CROSS JOIN stats AS s
)
SELECT a.product_name AS item_a,
       b.product_name AS item_b,
       ROUND(SQRT((a.zp-b.zp)*(a.zp-b.zp) + (a.zm-b.zm)*(a.zm-b.zm)), 3) AS distance
FROM z AS a
JOIN z AS b ON a.product_id < b.product_id
ORDER BY distance;
Result
item_aitem_bdistance
Velocity Road 8Ridgeline Trail 30.939
Ridgeline Trail 3Ridgeline Trail 41.210
Velocity Road 7Velocity Road 81.210
Velocity Road 8Ridgeline Trail 42.130
Velocity Road 7Ridgeline Trail 32.130
Velocity Road 7Ridgeline Trail 43.334

Read this as the algorithm would. The first merge joins Velocity Road 8 with Ridgeline Trail 3 at distance 0.939 — two products from different categories, because on price and margin they sit closer to each other than to their own category siblings. That is the useful, slightly uncomfortable thing clustering does: it tells you the structure in the features you supplied, not the structure in the labels you already had. If the merchandising hierarchy matters, it must be a feature, not an assumption.

Notice the two ties at 1.210. Ties are common in small or coarsely-measured data and they make the dendrogram non-unique — different implementations break them differently and produce different-looking trees from identical input.

Analogy

A family tree read backwards. Start with individuals, join each person to their nearest relative to make sibling pairs, join sibling groups into families, families into clans. Nothing is ever decided in advance about how many families there are — you build the whole structure and then draw a horizontal line wherever the question requires. “How many households?” cuts low. “How many surnames?” cuts high. Same tree, different cut. K-Means, by contrast, insists you announce the number of families before meeting anybody.

The cost that decides everything

The pairwise distance matrix grows quadratically with the number of items. Ten items give 45 pairs; a thousand give roughly half a million; a hundred thousand give roughly five billion. Fact Standard agglomerative implementations must hold and repeatedly scan this matrix, so memory and time both scale with the square of the row count — and the merging loop itself adds more on top.

This is why hierarchical clustering is routine for hundreds or a few thousand items and simply not an option for millions of customers, while K-Means — whose cost grows roughly linearly in rows per iteration — handles them comfortably. Inference The practical consequence is a common hybrid: cluster millions of rows into a few hundred groups with K-Means, then run hierarchical clustering on those group centroids to discover how the segments themselves nest.

Common pitfall — reading the dendrogram's horizontal axis

Only the height of a join carries meaning. The left-to-right order of the leaves does not: any branch can be flipped around its join without changing the tree at all, so two items drawn next to each other at the bottom may not be similar. Analysts routinely conclude “these two are neighbours, so they belong together” from adjacency on the page. Check the height at which they actually merge. A second version of the same mistake is comparing dendrograms built with different linkage methods as if the heights were on a common scale — they are not.

When not to use hierarchical clustering

  • Large row counts. The quadratic distance matrix is a hard wall. Above roughly the low tens of thousands of items, standard implementations become impractical regardless of hardware.
  • When you need to score new arrivals cheaply. K-Means leaves you k centroids, so classifying a new customer is k distance calculations. A dendrogram is a description of one fixed set of items; adding one item properly means rebuilding. For a nightly scoring job over changing data, that is disqualifying.
  • When merges cannot be undone and your data is noisy. Agglomeration is greedy: a merge made early on the basis of two outlying points is permanent, and everything above it inherits the error. K-Means at least revisits its assignments every iteration.
  • When the groups are genuinely flat. If the business has three unrelated customer types with no meaningful nesting, a dendrogram invents a hierarchy anyway — it always produces one — and readers will interpret the invented structure as a finding.
  • When the audience wants one answer. Supply chain The flexibility of choosing a cut afterwards becomes a liability when three stakeholders choose three different cuts and then argue. Sometimes committing to k up front is a feature.
Interview angle

“When would you use hierarchical clustering instead of K-Means?” expects three points: you do not need to choose the number of clusters in advance, you get nested structure that is meaningful when the domain is genuinely hierarchical, and the trade is a quadratic distance matrix that caps the data size. A sharp follow-up is “what does linkage change?” — single linkage chains and can bridge distinct groups through intermediate points; complete linkage forces compactness and reacts badly to outliers. For SQL-heavy roles the question often becomes practical: “write the pairwise distance query”, where the interviewer is checking that you use a.id < b.id rather than <>, and that you standardised the columns first.

Exercise 11.9

Supply chain A planner wants product categories grouped by commercial profile rather than by department. Build a two-feature vector per category — average unit price and average margin rate (margin divided by price) — standardise both, and return the five closest category pairs. Which pair would an agglomerative algorithm merge first?

Show solution
sqlcategory_distances.sql
WITH cat_vec AS (
  SELECT cat.category_name,
         AVG(CAST(p.unit_price AS DOUBLE)) AS avg_price,
         AVG(CAST(p.unit_price - p.unit_cost AS DOUBLE)
             / CAST(p.unit_price AS DOUBLE)) AS avg_margin_rate
  FROM products AS p
  JOIN categories AS cat ON cat.category_id = p.category_id
  GROUP BY cat.category_name
),
stats AS (
  SELECT AVG(avg_price) AS pm, STDDEV_SAMP(avg_price) AS ps,
         AVG(avg_margin_rate) AS mm, STDDEV_SAMP(avg_margin_rate) AS ms
  FROM cat_vec
),
z AS (
  SELECT v.category_name,
         (v.avg_price - s.pm) / NULLIF(s.ps, 0)       AS zp,
         (v.avg_margin_rate - s.mm) / NULLIF(s.ms, 0) AS zm
  FROM cat_vec AS v CROSS JOIN stats AS s
)
SELECT a.category_name AS cat_a,
       b.category_name AS cat_b,
       ROUND(SQRT((a.zp-b.zp)*(a.zp-b.zp) + (a.zm-b.zm)*(a.zm-b.zm)), 3) AS distance
FROM z AS a
JOIN z AS b ON a.category_name < b.category_name
ORDER BY distance
LIMIT 5;
Result
cat_acat_bdistance
JacketsRunning Shoes0.874
Running ShoesSleeping Bags0.924
TentsWatches1.011
JacketsSleeping Bags1.076
Sleeping BagsTrail Shoes1.194

Jackets and Running Shoes merge first, at 0.874 — despite sitting in different departments (Apparel and Footwear). Commercially they behave alike on price and margin rate, which is exactly what the planner asked about. The next merge would join that new two-member cluster to something, and which something depends on linkage: under single linkage the cluster's distance to Sleeping Bags is the smaller of the two constituent distances (0.924), while under complete linkage it is the larger (1.076), which could let Tents and Watches merge first instead. One choice of linkage, two different trees, from an identical distance matrix.

Knowledge check

Your table has 200,000 customers and you want hierarchical clustering. What is the blocking problem?

Key takeaway

Hierarchical clustering repeatedly merges the two nearest clusters and records the whole sequence as a dendrogram, so you choose the number of groups after seeing the structure rather than before. Prefer it when the domain is genuinely nested, when the item count is small, and when you must serve several audiences at different granularities. Avoid it at scale — the pairwise distance matrix grows with the square of the row count — and avoid it when new items must be scored cheaply. In SQL the whole method reduces to a standardised feature table and a self-join on a.id < b.id; read only the merge heights, never the left-to-right leaf order.

Lesson 11.10·15 min read

Recommendation Systems: Collaborative vs Content-Based

Two fundamentally different sources of “you might also like” — other people's behaviour versus the item's own attributes — both of which you can build directly in SQL.

Digital media A streaming catalogue holds tens of thousands of titles and a viewer will consider perhaps twenty. Whatever fills those twenty slots determines most of what gets watched. The naive filler is a popularity list — the same twenty titles for everyone — which is a reasonable baseline and a hard one to beat for brand-new users, but it never surfaces the long tail and it makes the popular more popular.

Personalisation means predicting, for a given person and a given item, how likely that person is to want it. There are exactly two families of signal available, and understanding which one you are using determines which failures you will hit.

Collaborative filtering uses behaviour: people who did what you did also did this other thing. It knows nothing about what the items are — the titles could be shuffled and it would work identically. Content-based filtering uses attributes: this item resembles items you already liked, on genre, price, category, brand. It knows nothing about anyone else.

Collaborative filtering: the co-occurrence self-join

The oldest and still one of the most effective recommenders is item-to-item co-occurrence: for a given item, find the items that appear alongside it most often. In SQL that is a self-join of the line-items table on the basket key.

sqlmarket_basket.sql
-- "Customers who bought X also bought Y", at the basket level.
-- ib.product_id > ia.product_id keeps each unordered pair once
-- and prevents an item pairing with itself.
SELECT pa.product_name AS product_a,
       pb.product_name AS product_b,
       COUNT(*)        AS baskets_together
FROM order_items AS ia
JOIN order_items AS ib
  ON ib.order_id = ia.order_id
 AND ib.product_id > ia.product_id
JOIN products AS pa ON pa.product_id = ia.product_id
JOIN products AS pb ON pb.product_id = ib.product_id
GROUP BY pa.product_name, pb.product_name
ORDER BY baskets_together DESC, product_a, product_b;
Result
product_aproduct_bbaskets_together
Nimbus 0C BagPacer GPS Watch Pro1
Ridgeline Trail 4Pacer GPS Watch Pro1
Velocity Road 7Basecamp 2P Tent1
Velocity Road 7Nimbus 0C Bag1
Velocity Road 8Pacer GPS Watch1

The join condition is the whole lesson in one line. Using <> instead of > would return each pair twice and each item paired with itself; using > gives you each unordered pair exactly once. Widen the basket from a single order to a whole customer history and you capture affinities that never shared a checkout:

sqlcustomer_cooccurrence.sql
-- Same idea, but "together" now means "same customer, ever".
-- DISTINCT first, so buying a product twice does not inflate the signal.
WITH cust_prod AS (
  SELECT DISTINCT o.customer_id, oi.product_id
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status = 'completed'
)
SELECT pa.product_name AS bought_this,
       pb.product_name AS also_bought,
       COUNT(DISTINCT a.customer_id) AS shared_customers
FROM cust_prod AS a
JOIN cust_prod AS b
  ON b.customer_id = a.customer_id
 AND b.product_id <> a.product_id
JOIN products AS pa ON pa.product_id = a.product_id
JOIN products AS pb ON pb.product_id = b.product_id
GROUP BY pa.product_name, pb.product_name
HAVING COUNT(DISTINCT a.customer_id) >= 2
ORDER BY shared_customers DESC, bought_this, also_bought;
Result
bought_thisalso_boughtshared_customers
Basecamp 2P TentVelocity Road 72
Featherlite JacketRidgeline Trail 42
Nimbus 0C BagStormshell Jacket2
Pacer GPS WatchPacer GPS Watch Pro2
Ridgeline Trail 4Featherlite Jacket2
Stormshell JacketNimbus 0C Bag2
Velocity Road 7Basecamp 2P Tent2

Here <> is correct rather than wrong, because we want directed recommendations — “given they bought A, suggest B” is a different row from its reverse, and you will look up one or the other.

Raw counts, though, are a trap. The best-selling item co-occurs with everything simply because it appears in most baskets. The fix is lift: how much more often do A and B appear together than they would if purchases were independent?

sqllift.sql
-- lift = P(A and B) / (P(A) * P(B)).
-- lift > 1 means the pair co-occurs more than chance would predict.
WITH basket_count AS (SELECT COUNT(DISTINCT order_id) AS n FROM order_items),
item_orders AS (
  SELECT product_id, COUNT(DISTINCT order_id) AS orders
  FROM order_items GROUP BY product_id
),
pairs AS (
  SELECT ia.product_id AS a, ib.product_id AS b,
         COUNT(DISTINCT ia.order_id) AS both
  FROM order_items AS ia
  JOIN order_items AS ib
    ON ib.order_id = ia.order_id AND ib.product_id > ia.product_id
  GROUP BY ia.product_id, ib.product_id
)
SELECT pa.product_name AS product_a,
       pb.product_name AS product_b,
       pr.both,
       ROUND(CAST(pr.both AS DOUBLE) / ioa.orders, 3) AS confidence_a_to_b,
       ROUND((CAST(pr.both AS DOUBLE) / bc.n)
           / ((CAST(ioa.orders AS DOUBLE) / bc.n)
            * (CAST(iob.orders AS DOUBLE) / bc.n)), 2) AS lift
FROM pairs AS pr
JOIN item_orders AS ioa ON ioa.product_id = pr.a
JOIN item_orders AS iob ON iob.product_id = pr.b
JOIN products AS pa ON pa.product_id = pr.a
JOIN products AS pb ON pb.product_id = pr.b
CROSS JOIN basket_count AS bc
ORDER BY lift DESC, product_a
LIMIT 5;
Result
product_aproduct_bbothconfidence_a_to_blift
Ridgeline Trail 4Pacer GPS Watch Pro10.5004.17
Velocity Road 8Pacer GPS Watch10.3334.17
Velocity Road 7Basecamp 2P Tent10.2503.13
Nimbus 0C BagPacer GPS Watch Pro10.3332.78
Velocity Road 7Nimbus 0C Bag10.2502.08

Every pair here rests on a single basket, so the lift values are unstable — which is the honest lesson. A production rule would require a minimum support count before a pair is allowed to recommend anything at all.

Content-based filtering: similarity from attributes

Collaborative filtering cannot say anything about an item nobody has interacted with yet. Content-based filtering can, because it only needs the item's own description. You define a similarity score over attributes and rank candidates against a seed the user already liked:

sqlcontent_similarity.sql
-- Similar to product 101, from attributes only. No behaviour used at all,
-- so this works for a product launched five minutes ago.
WITH seed AS (SELECT * FROM products WHERE product_id = 101)
SELECT p.product_name AS candidate,
       cat.category_name,
       cat.department,
       ROUND(
           0.6 * (CASE WHEN p.category_id = s.category_id THEN 1 ELSE 0 END)
         + 0.2 * (CASE WHEN cat.department = scat.department THEN 1 ELSE 0 END)
         + 0.2 * (1 - LEAST(1.0,
             ABS(CAST(p.unit_price - s.unit_price AS DOUBLE)) / 300.0))
       , 3) AS similarity
FROM products AS p
JOIN categories AS cat  ON cat.category_id = p.category_id
CROSS JOIN seed AS s
JOIN categories AS scat ON scat.category_id = s.category_id
WHERE p.product_id <> s.product_id
ORDER BY similarity DESC, p.product_id
LIMIT 5;
Result
candidatecategory_namedepartmentsimilarity
Velocity Road 8Running ShoesFootwear0.993
Ridgeline Trail 3Trail ShoesFootwear0.387
Ridgeline Trail 4Trail ShoesFootwear0.380
Featherlite JacketJacketsApparel0.167
Nimbus 0C BagSleeping BagsOutdoor0.153

And there is the characteristic weakness in plain sight. The top recommendation for the Velocity Road 7 is the Velocity Road 8 — the direct successor of the shoe they just bought. Technically correct, commercially useless. Content-based systems are over-specialised: they recommend more of the same and never introduce anything new. The co-occurrence query above suggested a tent alongside those shoes, which no attribute-based score would ever have produced.

Analogy

Two ways to be recommended a book. The collaborative way is a friend who says “everyone I know who loved that one also loved this” — they may not have read either book, and their advice can be surprising and excellent. The content-based way is a shop assistant who says “that was Nordic crime fiction, here is more Nordic crime fiction” — they can advise on a book published this morning that nobody has read, but they will never send you somewhere you would not have gone yourself. Serious systems employ both, because each is strong exactly where the other is blind.

The cold-start problem and sparsity

Collaborative filtering's failure has a name. A new item has no interactions, so it appears in no co-occurrence pair and can never be recommended — which means it never gets interactions. A new user has no history to match against anyone. Content-based filtering solves both, which is why almost every production system is a hybrid: content-based while the item or user is cold, collaborative once enough behaviour accumulates.

The second structural issue is sparsity. Think of the customer-by-item matrix: nearly every cell is empty, and the emptier it is, the less any two users overlap. In our dataset, 17 customers by 12 products gives 204 possible cells and only 30 are filled — and this is a toy. Real catalogues are far emptier still, which is the motivation for matrix factorisation: compress users and items into a small number of latent factors so that similarity can be computed even between users who never touched the same item.

Common pitfall — recommending what people were already going to buy

Offline metrics reward predicting the next purchase, and the easiest purchases to predict are the ones that would have happened anyway. A recommender that surfaces the item already in the basket, the obvious replenishment, or the sequel to what was just bought will score beautifully and add no revenue — you have paid for a slot to show someone what they already intended. Inference The metric that matters is incremental conversion, measured against a holdout that sees no recommendations. Filtering out already-owned items and near-identical variants is a small piece of SQL that often matters more than the model.

When not to build a recommender

  • Small catalogues. If you sell twelve products, the customer can see all twelve. Personalisation adds machinery and no discovery. The break-even is roughly where a person cannot scan the whole catalogue in a sitting.
  • Very thin interaction data. Collaborative filtering needs overlap. If most items have been bought once, every co-occurrence rests on one basket and the recommendations are noise dressed as insight — visible in the single-basket lifts above.
  • Purchases that are one-off or highly considered. Advertising For a product bought once a decade, past behaviour predicts almost nothing about the next purchase, and item-to-item affinity is mostly an artefact of catalogue structure.
  • When the display slot has a better use. Stock availability, delivery speed, or margin may beat relevance. A perfectly targeted recommendation for an out-of-stock item is worse than a popular in-stock one.
  • When feedback loops are dangerous. Recommenders train on data their own output generated. Items that get shown get bought, get shown more. Without deliberate exploration the catalogue narrows over time — and popularity, not preference, becomes what the model has learned.
Interview angle

“Write a query for customers who bought X also bought Y” is a genuinely common SQL round, and it is a self-join test wearing a machine-learning hat. The interviewer is watching for three things: that you join order_items to itself on order_id; that you use ib.product_id > ia.product_id to get each unordered pair once and exclude self-pairs; and that you notice raw counts favour popular items, so you offer lift or a support threshold unprompted. If the conversation turns conceptual, the expected trade-off is cold start: collaborative filtering is stronger once behaviour exists but cannot touch a new item, content-based works from day one but over-specialises, and production systems hybridise.

Exercise 11.10

Before shipping a co-occurrence recommender you need to know its blind spot. Write SQL that lists every product which has never shared a basket with any other product, together with its category. These are the items collaborative filtering can never recommend, and the ones a content-based fallback must cover.

Show solution
sqlcold_start_products.sql
-- Anti-join: products that appear in no co-occurrence pair at all.
WITH co_purchased AS (
  SELECT DISTINCT ia.product_id
  FROM order_items AS ia
  JOIN order_items AS ib
    ON ib.order_id = ia.order_id
   AND ib.product_id <> ia.product_id
)
SELECT p.product_id, p.product_name, cat.category_name
FROM products AS p
JOIN categories AS cat ON cat.category_id = p.category_id
LEFT JOIN co_purchased AS cp ON cp.product_id = p.product_id
WHERE cp.product_id IS NULL
ORDER BY p.product_id;
Result
product_idproduct_namecategory_name
103Ridgeline Trail 3Trail Shoes
105Stormshell JacketJackets
106Featherlite JacketJackets
108Summit 3P TentTents
110Nimbus -10C BagSleeping Bags

Five of twelve products — including both jackets and an entire tent — are invisible to the basket-level recommender. Note the join uses <> here, not >: we want a product flagged as co-purchased whether it sits on the left or right of a pair, so the asymmetric > would wrongly exclude items that only ever appear as the higher id. The business response is not to fix the query but to route these products through the content-based path until they accumulate behaviour, and to accept that a co-occurrence recommender is only ever as broad as its basket data.

Knowledge check

A retailer launches a new product today. Which approach can recommend it, and why?

Knowledge check

In a basket self-join, why write ON ib.order_id = ia.order_id AND ib.product_id > ia.product_id rather than <>?

Key takeaway

Collaborative filtering reads behaviour; content-based filtering reads attributes. Collaborative is a self-join on the basket key — join order_items to itself, use > for unordered pairs, and rank by lift rather than raw count so popular items do not dominate. It produces genuinely surprising recommendations and dies on cold start and sparsity. Content-based is a similarity score over item columns; it works from day one and over-specialises into more-of-the-same. Real systems hybridise, and the metric to insist on is incremental conversion against a holdout — not accuracy at predicting purchases that were going to happen regardless.

Lesson 11.11·12 min read

Time Series & Forecasting Concepts

Understand trend, seasonality, stationarity, lags and autocorrelation well enough to build forecasting features in SQL — and to know when a forecast is honest.

Supply chain A warehouse planner asks what sounds like a simple question: how many units will we ship next week? She is not asking for a number to admire. A lorry has to be booked, a shift rota signed, pallet space reserved — each costing money if the number is wrong in either direction.

Every other query in this course answers a question about the past. A forecast is a claim about rows that do not exist yet, and that difference invalidates most of your habits: you can no longer shuffle rows freely, join a table to itself without asking “was this knowable at the time?”, or evaluate by holding out a random sample.

What a series is made of

The oldest useful idea in forecasting is that a series is a sum of separable parts. Trend is the slow drift of the level — the business growing, a product decaying. Seasonality is a pattern repeating on a fixed period: higher on Saturdays, higher in December. Confusing the two is the classic beginner error; a retailer whose December is triple November has not discovered growth, they have discovered Christmas. Residual is what remains, and it is unpredictable by definition, which is why it sets the floor on how good any forecast can be. Inference If the residual is large, no algorithm will save you — you need a new feature, not a new model.

Stationarity: why models want a boring series

A series is stationary when its behaviour does not depend on when you look: roughly constant mean and variance, and a relationship between neighbouring points that depends only on the gap, not the calendar date. Most classical methods assume it, because a method that learns “the average is 5,000” is useless if the average climbs 200 a week. The standard remedy is differencing — model the change rather than the level.

sqldifferencing.sql
-- The level differs sharply between regions; the DIFFERENCES are
-- much more comparable. That is what "closer to stationary" looks like.
WITH d AS (
  SELECT week_start, region, revenue,
         revenue - LAG(revenue) OVER (PARTITION BY region ORDER BY week_start) AS diff_1
  FROM weekly_sales
)
SELECT region,
       ROUND(AVG(revenue), 2)        AS mean_level,
       ROUND(AVG(diff_1), 2)         AS mean_of_differences,
       ROUND(STDDEV_SAMP(diff_1), 2) AS sd_of_differences
FROM d
GROUP BY region
ORDER BY region;
Result
regionmean_levelmean_of_differencessd_of_differences
North5537.5240.0646.45
South4582.75174.67321.71

A positive mean of differences is the arithmetic signature of an upward trend. Open Four weeks per region is far too short to conclude anything real; this demonstrates the mechanism, not a finding. Genuine stationarity work needs several full seasonal cycles and formal tests.

Lags, moving averages, autocorrelation

Models do not understand time. They see a table of rows and have no idea row 12 came after row 11. Your job in SQL is to carry the past forward into each row explicitly. A lag is the value from k periods ago placed on the current row — lag 52 is the same week last year, which is how seasonality becomes a plain column. A moving average smooths recent values so the model sees the level rather than the wobble. Autocorrelation is the correlation of a series with a lagged copy of itself: how much does last week tell you about this week?

sqllag_and_moving_average.sql
-- Standard forecasting feature set: the previous value, the change,
-- and a trailing 3-week average. PARTITION BY region keeps regions
-- from bleeding into one another.
SELECT region, week_start, revenue,
       LAG(revenue) OVER w                       AS revenue_lag_1,
       revenue - LAG(revenue) OVER w             AS wow_change,
       ROUND(AVG(revenue) OVER (PARTITION BY region ORDER BY week_start
             ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS ma_3w
FROM weekly_sales
WINDOW w AS (PARTITION BY region ORDER BY week_start)
ORDER BY region, week_start;
Result
regionweek_startrevenuerevenue_lag_1wow_changema_3w
North2024-09-025320.00NULLNULL5320.0
North2024-09-095610.005320.00290.005465.0
North2024-09-165180.005610.00-430.005370.0
North2024-09-236040.005180.00860.005610.0
South2024-09-024206.00NULLNULL4206.0
South2024-09-094480.004206.00274.004343.0
South2024-09-164915.004480.00435.004533.67
South2024-09-234730.004915.00-185.004708.33

Two details matter enormously. The frame is ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — strictly backward-looking. A centred average is a lovely chart and a catastrophic feature, because it uses the future. And the first row of each partition has a NULL lag, which is correct: there genuinely is no prior week. Filling it with zero would teach the model the business began at zero revenue.

Analogy

A train journey. Trend is that you are steadily heading north. Seasonality is the regular slowdown at every station. Noise is the signal failure outside Crewe. Explain your position using only average speed and you are baffled at every platform; use only station stops and you never notice you have travelled 200 miles. Separate them and each becomes simple. A lag feature is looking out of the window at the last mile marker before guessing the next.

sqlautocorrelation.sql
-- Lag-1 autocorrelation: correlate each value with the previous value.
WITH paired AS (
  SELECT region, revenue,
         LAG(revenue) OVER (PARTITION BY region ORDER BY week_start) AS revenue_lag_1
  FROM weekly_sales
)
SELECT region,
       COUNT(*)                               AS usable_pairs,
       ROUND(CORR(revenue, revenue_lag_1), 4) AS lag1_autocorrelation
FROM paired
WHERE revenue_lag_1 IS NOT NULL
GROUP BY region
ORDER BY region;
Result
regionusable_pairslag1_autocorrelation
North3-0.9803
South30.4612

Read that as a warning, not an insight. Three pairs cannot support a correlation estimate — one point moves it from strongly negative to strongly positive. This is how spurious forecasting signals are born: a plausible statistic computed on far too little history.

Backtesting with a rolling origin

You cannot evaluate a forecast with a random hold-out, because that lets the model train on next month to predict last month. The correct procedure is backtesting with a rolling origin: pick a cut-off, train only on data before it, predict after it, record the error, advance the cut-off, repeat. Every fold simulates a day you would actually have stood on.

flowchart LR
  A["Fold 1: train wk1, test wk2"] --> B["Fold 2: train wk1-2, test wk3"]
  B --> C["Fold 3: train wk1-3, test wk4"]
  C --> D["Average the errors"]
The origin moves forward; training data only ever grows backwards from it. No fold trains on a week later than the week it predicts.
sqlrolling_origin_backtest.sql
-- Backtest the simplest possible forecast ("next week equals this week")
-- with a rolling origin. Each fold predicts exactly one week ahead.
WITH ordered AS (
  SELECT week_start, revenue,
         ROW_NUMBER() OVER (ORDER BY week_start) AS rn
  FROM weekly_sales
  WHERE region = 'North'
)
SELECT t.rn                       AS fold,
       t.week_start               AS forecast_origin,
       h.week_start               AS held_out_week,
       t.revenue                  AS naive_forecast,
       h.revenue                  AS actual,
       ABS(h.revenue - t.revenue) AS abs_error
FROM ordered t
JOIN ordered h ON h.rn = t.rn + 1
ORDER BY fold;
Result
foldforecast_originheld_out_weeknaive_forecastactualabs_error
12024-09-022024-09-095320.005610.00290.00
22024-09-092024-09-165610.005180.00430.00
32024-09-162024-09-235180.006040.00860.00

That naive forecast is the baseline every sophisticated model must beat. It is free, needs no training, and is startlingly hard to improve on at short horizons. A forecasting project that does not report it alongside the model is hiding something.

Common pitfall — the centred window

Analysts reach for ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING because it draws a beautifully smooth line. As a chart, fine. As a model feature it is fatal: the value on 1 March is computed partly from 4 March, so at prediction time it cannot exist. Models trained this way post extraordinary validation scores and fail on their first live day. The rule is absolute — for modelling, the frame must end at CURRENT ROW, and the ordering column must be the event timestamp, not when the row landed in the warehouse.

When NOT to forecast

  • Too little history. Yearly seasonality needs several years. With one year you cannot distinguish “December is big” from “that December was big”.
  • The mechanism just changed. Telecommunications An operator switching from prepaid to contract billing has not gained a forecasting problem; it has lost its training data.
  • The driver is external and known. If next quarter's volume is set by a signed contract, read the contract. A model fitted to noise only adds error to a known quantity.
  • The series is shock-driven. Outages, recalls and viral moments are better served by anomaly detection than by a point forecast that sits confidently in the middle of nothing.
  • Nobody will act on it. If the same lorry is booked regardless, the forecast is decoration.
Interview angle

“Compute a 7-day moving average and a week-over-week change” is one of the most common analytics screens. The mechanical answer is AVG(...) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) plus LAG(...). What separates candidates is what they volunteer: that ROWS and RANGE differ when dates are missing, that calendar gaps silently corrupt a ROWS frame so you should join to a date spine first, and that the frame must not extend past the current row if the output feeds a model. Interviewers listen for “would this have been knowable at prediction time?”

Exercise 11.11

Retail The South region's planner wants a weekly report with the percentage change on the previous week and a trailing two-week moving average. Write it against weekly_sales, protecting the percentage against a zero denominator.

Show solution
sqlsouth_weekly.sql
SELECT week_start, revenue,
       ROUND(100.0 * (revenue - LAG(revenue) OVER w)
             / NULLIF(LAG(revenue) OVER w, 0), 2)          AS wow_pct_change,
       ROUND(AVG(revenue) OVER (ORDER BY week_start
             ROWS BETWEEN 1 PRECEDING AND CURRENT ROW), 2) AS ma_2w
FROM weekly_sales
WHERE region = 'South'
WINDOW w AS (ORDER BY week_start)
ORDER BY week_start;
Result
week_startrevenuewow_pct_changema_2w
2024-09-024206.00NULL4206.0
2024-09-094480.006.514343.0
2024-09-164915.009.714697.5
2024-09-234730.00-3.764822.5

Because WHERE restricts to one region no PARTITION BY is needed — but the moment someone removes that filter, the window silently averages across regions. Defensive habit: keep PARTITION BY region anyway, so the query stays correct under edit.

Knowledge check

A model predicting next week's revenue uses AVG(revenue) OVER (ORDER BY week_start ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING). It validates almost perfectly and fails immediately in production. Why?

Key takeaway

A time series is trend + seasonality + noise; models want it stationary, and differencing is how you get there. Because models cannot see time, SQL must hand them the past explicitly as lags and trailing moving averages — frames ending at CURRENT ROW, always. Evaluate with a rolling origin, never a random split, and always report the naive baseline you had to beat.

Lesson 11.12·16 min read

Feature Engineering in SQL — the Analyst's Real ML Job

Build a complete, production-shaped customer feature table in one query — and understand the encoding, scaling, NULL and as-of-date decisions that make it trustworthy.

A model is a function from a row of numbers to a prediction. It cannot see your warehouse, cannot join orders to order_items, and has no notion that a customer with four orders differs from one with none — unless somebody wrote a column called frequency_orders and put a 4 in it.

That somebody is you. Feature engineering is turning a normalised schema into one wide flat table with exactly one row per thing you predict about, and one column per fact the model is allowed to know. Inference It is where most of the value in an applied ML project is created, and it is almost entirely SQL — good news for anyone reading a SQL course, since the modelling step is often a handful of lines somebody else writes.

Start with the grain and the as-of date

What is the grain? One row per what — customer, customer-month, order, session? Every feature must be an attribute of that unit. Mixed grain is the commonest structural bug: an accidental fan-out from joining orders to order_items without re-aggregating multiplies your customer rows and triple-counts their revenue.

What is the as-of date? “Days since last order” is not a fact about a customer; it is a fact about a customer at a moment. Every feature must be computed as though you stood on that date and could see nothing after it. Making the date an explicit parameter — a one-row CTE rather than a value scattered through the query — is the highest-leverage habit in this lesson, because it lets you regenerate the whole table for a different date by editing one line. That is exactly what backfilling training data requires.

Analogy

Feature engineering is preparing a case file for a magistrate who has never met the defendant and will never ask a question. Everything they are permitted to know must be on the sheet in front of them, in numbers, and dated. Staple in a document produced after the verdict and the magistrate looks brilliant in rehearsal and useless in court. The as-of date is the stamp saying: this is what was on the desk that morning, and nothing else.

The canonical customer feature table

E-commerce Here is a real one. The grain is one row per customer signed up by the as-of date. The features are the classic RFM trio — recency, frequency, monetary — plus tenure, breadth of purchasing, and acquisition channel: legitimate inputs to a churn, lifetime-value or propensity model.

sqlcustomer_features.sql
-- ONE row per customer, as of a single explicit date.
-- Change the date in ONE place to regenerate the table for any point in time.
WITH params AS (SELECT DATE '2024-12-31' AS as_of),

-- Net revenue per order line: quantity x price, less the discount.
-- Multiply by 0.01 rather than divide by 100 so the maths stays NUMERIC.
item_rev AS (
  SELECT o.customer_id, o.order_id, o.order_date, p.category_id,
         CAST(oi.quantity * oi.unit_price * (1 - oi.discount_pct * 0.01)
              AS NUMERIC(12,2)) AS net_revenue
  FROM orders       o
  JOIN order_items  oi ON oi.order_id   = o.order_id
  JOIN products     p  ON p.product_id  = oi.product_id
  CROSS JOIN params
  WHERE o.status = 'completed'          -- cancelled/returned are not revenue
    AND o.order_date <= params.as_of    -- nothing after the as-of date
),

-- Re-aggregate back to ONE row per customer before joining out.
per_customer AS (
  SELECT customer_id,
         MAX(order_date)             AS last_order_date,
         COUNT(DISTINCT order_id)    AS frequency_orders,
         SUM(net_revenue)            AS monetary_net_revenue,
         COUNT(DISTINCT category_id) AS category_diversity
  FROM item_rev
  GROUP BY customer_id
)

SELECT c.customer_id,
       c.channel                                          AS acquisition_channel,
       DATE_DIFF('day', c.signup_date, p.as_of)           AS tenure_days,
       DATE_DIFF('day', pc.last_order_date, p.as_of)      AS recency_days,
       COALESCE(pc.frequency_orders, 0)                   AS frequency_orders,
       COALESCE(pc.monetary_net_revenue, 0)               AS monetary_net_revenue,
       COALESCE(pc.category_diversity, 0)                 AS category_diversity,
       CAST(COALESCE(pc.monetary_net_revenue, 0)
            / NULLIF(pc.frequency_orders, 0) AS NUMERIC(12,2)) AS avg_order_value,
       CASE WHEN pc.customer_id IS NULL THEN 1 ELSE 0 END AS never_ordered_flag
FROM customers c
CROSS JOIN params p
LEFT JOIN per_customer pc ON pc.customer_id = c.customer_id
WHERE c.signup_date <= p.as_of          -- customers who did not exist yet are excluded
ORDER BY c.customer_id;
Result
customer_idacquisition_channeltenure_daysrecency_daysfrequency_ordersmonetary_net_revenuecategory_diversityavg_order_valuenever_ordered_flag
1paid_search3612942527.103263.550
2organic3473411283.101283.100
3paid_social3333261478.002478.000
4referral3213151139.001139.000
5paid_search30310631162.203387.400
6organic285NULL00.000NULL1
7email2672611219.001219.000
8paid_social2481342697.302348.650
9organic2342271199.001199.000
10paid_search215702797.102398.550
11referral1971911149.001149.000
12organic182472497.002248.500
13paid_social159NULL00.000NULL1
14email1441381129.001129.000
15paid_search121292806.053403.030
16organic80NULL00.000NULL1
17paid_social58NULL00.000NULL1

Each decision in that query is a bug somebody else has shipped. The LEFT JOIN is not optional — customers 6, 13, 16 and 17 have no completed orders, an inner join drops them, and they are precisely the population a churn or activation model exists to identify. Re-aggregate before joining out: item_rev is at line grain, so joining it straight onto customers would repeat a customer once per line. Use COUNT(DISTINCT order_id), not COUNT(*) — at line grain the latter counts items; customer 1's two orders hold three lines. Keep money exact: discount_pct * 0.01 stays decimal, whereas / 100 drops into floating point where 527.10 becomes 527.0999999999999. Protect division with NULLIF, so never-ordered customers get a NULL average order value instead of an error.

NULL handling: three different absences

Customer 6 has frequency_orders 0, monetary_net_revenue 0.00, and recency_days NULL. That asymmetry is deliberate. There are three kinds of missing:

  1. Structurally zero. No orders means the count really is zero. COALESCE(..., 0) states a fact.
  2. Genuinely undefined. “Days since last order” has no value when there is no last order. Filling it with 0 says this customer ordered today — the opposite of the truth. Filling it with 9999 invents a magic number the model treats as a real quantity. Either leave it NULL for a model that handles nulls natively (most gradient-boosted tree libraries do), or impute deliberately with something defensible such as tenure — always shipping never_ordered_flag alongside so imputed and observed stay distinguishable.
  3. Unknown but existent. customers.country is NULL for two people who certainly live somewhere. Here missingness is itself a signal, so use an explicit 'unknown' category rather than dropping the row.
Common pitfall — blanket COALESCE(x, 0)

Wrapping every column in COALESCE(col, 0) to “clean” the table destroys information and injects lies. Zero is a real value with real meaning — zero days since last order means today — and the model cannot distinguish your filler zeros from measured ones. Before every COALESCE, say out loud what the zero would mean to a reader of that column. If the sentence is false, do not write it.

Encoding categoricals

acquisition_channel is text and most models need numbers. The safe default is one-hot encoding: one 0/1 column per level. It makes no ordering claim, which matters because numbering channels 1–5 would tell the model that referral sits numerically between organic and email — a relationship that does not exist and which linear models will dutifully fit.

sqlone_hot_channel.sql
-- One column per level. Rare levels and NULL are folded into 'other'
-- so a channel that appears twice cannot create a near-empty column.
SELECT c.customer_id, c.channel,
       CASE WHEN c.channel = 'paid_search' THEN 1 ELSE 0 END AS ch_paid_search,
       CASE WHEN c.channel = 'paid_social' THEN 1 ELSE 0 END AS ch_paid_social,
       CASE WHEN c.channel = 'organic'     THEN 1 ELSE 0 END AS ch_organic,
       CASE WHEN c.channel IN ('referral','email') OR c.channel IS NULL
            THEN 1 ELSE 0 END                              AS ch_other
FROM customers c
ORDER BY c.customer_id
LIMIT 6;
Result
customer_idchannelch_paid_searchch_paid_socialch_organicch_other
1paid_search1000
2organic0010
3paid_social0100
4referral0001
5paid_search1000
6organic0010

Two cautions. One-hot encoding explodes on high-cardinality columns: a product_id with fifty thousand levels gives fifty thousand columns and a model that memorises. For those, prefer aggregation — how many distinct products, what share of spend in the top category. And the level list must be frozen at training time: routing an unseen channel to ch_other is a defensible choice, producing a brand-new column is a broken pipeline.

Scaling: who needs it and who does not

monetary_net_revenue reaches the thousands while category_diversity maxes at 3. For distance-based methods (k-nearest neighbours, k-means) and regularised linear models, revenue then dominates purely because its units are bigger. Scaling puts features on comparable footing: min-max squashes to [0,1], standardisation recentres to mean 0 with unit spread.

sqlscaling.sql
WITH params AS (SELECT DATE '2024-12-31' AS as_of),
per_customer AS (
  SELECT o.customer_id,
         SUM(CAST(oi.quantity * oi.unit_price * (1 - oi.discount_pct * 0.01)
                  AS NUMERIC(12,2))) AS monetary
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.order_id
  CROSS JOIN params
  WHERE o.status = 'completed' AND o.order_date <= params.as_of
  GROUP BY o.customer_id
)
SELECT customer_id,
       monetary,
       ROUND((monetary - MIN(monetary) OVER ())
             / NULLIF(MAX(monetary) OVER () - MIN(monetary) OVER (), 0), 3) AS minmax_scaled,
       ROUND((monetary - AVG(monetary) OVER ())
             / NULLIF(STDDEV_SAMP(monetary) OVER (), 0), 3)                 AS z_score
FROM per_customer
ORDER BY customer_id
LIMIT 8;
Result
customer_idmonetaryminmax_scaledz_score
1527.100.3850.183
2283.100.149-0.571
3478.000.3380.031
4139.000.01-1.017
51162.201.02.147
7219.000.087-0.77
8697.300.550.709
9199.000.068-0.832
Note

Tree-based models — decision trees, random forests, gradient boosting — are invariant to monotonic rescaling, because they only ask “is this above or below a threshold?” Since boosted trees are the usual first choice for tabular business data, scaling is often a step you can skip. Note also the trap above: computing min, max, mean and standard deviation over all rows means the scaling parameters have seen the test set. In production they must be computed on training data only and applied unchanged — the subject of the next lesson.

When NOT to hand-engineer features

  • Unstructured inputs. For raw text, images or audio, hand-built features have been comprehensively outperformed by learned representations. Do not write SQL to count adjectives in a review.
  • A baseline already answers the question. Banking If flagging every account that went overdrawn twice this quarter catches what the business needs, a fifty-column feature table adds cost and no decision.
  • You cannot recompute it at serving time. A feature needing an hour of batch SQL is unusable in a model that must score at checkout in milliseconds.
  • It will not exist for new entities. Features requiring twelve months of history cannot score someone who signed up yesterday — often exactly the population you care about.
  • Legally or ethically off-limits. Insurance Proxies for protected characteristics remain proxies however cleverly aggregated. Postcode-derived features deserve scrutiny before entering a pricing model.
In the field

The recurring failure on real teams is not a bad feature; it is two definitions of the same feature. The training table is built by an analyst in a notebook, the serving pipeline rebuilt by an engineer in application code, and six months later “orders in the last 90 days” means completed orders in one place and all orders in the other. The model degrades in a way no dashboard explains. Feature stores exist for this; the cheap version is to define every feature once, in one version-controlled SQL model, and have training and serving both read it.

Interview angle

“Build a customer feature table for a churn model” is a standard take-home. Almost everyone produces the RFM columns. Far fewer state the grain and as-of date before writing SQL, use a LEFT JOIN so never-ordered customers survive, re-aggregate line items before joining to the dimension, and distinguish a structural zero from an undefined value. Say those four things while you write and the exercise is effectively over. The strongest candidates also ask what the prediction horizon is — because “churn” is undefined until someone says over what window.

Exercise 11.12

Retail Add two behavioural columns, as of 2024-12-31, for customers with at least one completed order: active_span_days, the days between first and last order; and footwear_revenue_share, the proportion of net revenue spent in the Footwear department. Protect the division.

Show solution
sqlbehavioural_features.sql
WITH params AS (SELECT DATE '2024-12-31' AS as_of),
item_rev AS (
  SELECT o.customer_id, o.order_date, cat.department,
         CAST(oi.quantity * oi.unit_price * (1 - oi.discount_pct * 0.01)
              AS NUMERIC(12,2)) AS net_revenue
  FROM orders       o
  JOIN order_items  oi  ON oi.order_id    = o.order_id
  JOIN products     p   ON p.product_id   = oi.product_id
  JOIN categories   cat ON cat.category_id = p.category_id
  CROSS JOIN params
  WHERE o.status = 'completed' AND o.order_date <= params.as_of
)
SELECT customer_id,
       DATE_DIFF('day', MIN(order_date), MAX(order_date)) AS active_span_days,
       SUM(net_revenue)                                   AS total_net_revenue,
       CAST(SUM(CASE WHEN department = 'Footwear' THEN net_revenue ELSE 0 END)
            / NULLIF(SUM(net_revenue), 0) AS NUMERIC(6,3)) AS footwear_revenue_share
FROM item_rev
GROUP BY customer_id
ORDER BY customer_id;
Result
customer_idactive_span_daystotal_net_revenuefootwear_revenue_share
164527.100.245
20283.101.000
30478.000.270
40139.001.000
51911162.200.130
70219.000.000
8109697.300.499
90199.000.000
10140797.100.174
110149.001.000
12129497.000.640
140129.001.000
1586806.050.000

Note the design consequence: the inner join to order_items means the four never-ordered customers are absent. That is fine for computing the columns, but the result must be LEFT JOINed back onto customers before it becomes a feature table. Note too that active_span_days = 0 means “exactly one order”, which the column cannot distinguish from “two orders on the same day” — an ambiguity worth a companion frequency_orders column.

Knowledge check

A churn feature table joins customers to orders to order_items with inner joins, then groups by customer. What is the most serious problem?

Knowledge check

For a customer who has never ordered, what should recency_days (“days since last order”) contain?

Key takeaway

Feature engineering is SQL, and it is where applied machine learning is won. Fix the grain and the as-of date first, and make the date a parameter you change in one place. LEFT JOIN so the negative class survives; re-aggregate to the grain before joining out; keep money in NUMERIC; protect every division with NULLIF. Treat a structural zero and an undefined value as different things, one-hot categoricals rather than numbering them, and scale only when the model cares.

Lesson 11.13·13 min read

Train/Test Splits, Leakage & Why SQL Causes Most Leakage Bugs

See the classic leakage bug written in SQL, understand why it produces spectacular scores and useless models, and learn the splitting discipline that prevents it.

A model that has memorised its training data scores perfectly on that data and knows nothing. So you hold rows back: fit on the training set, score on the untouched test set, and the gap tells you whether the model learned a pattern or a phone book.

That works only if the test set is genuinely untouched. Data leakage is information reaching the features that would not be available at prediction time. The result is always the same and always seductive: an extraordinary validation score, a delighted stakeholder, and a model that collapses in its first week live.

Inference Leakage is overwhelmingly a SQL bug rather than a modelling bug, because SQL is where features are built and nothing in the syntax warns you that a row is from the future. The database has no concept of “now” relative to your prediction, so the discipline lives entirely in your WHERE clauses.

The prediction problem

Insurance Take a task shaped like a renewal model. Standing on 30 June 2024, predict for each customer: will they place a completed order in the second half of the year? Everything before the cut-off is a legitimate feature. Everything after it is the answer.

  feature window            |  cut-off  |      label window
  <-------------------------|30 Jun 2024|------------------------->
  anything here is INPUT    |           |  anything here is TARGET
                            |           |
  a feature computed from this side is LEAKAGE ---->

The wrong query — and it runs perfectly

Here is the bug as it actually gets written: someone needs an order-count feature, there is already a tested query computing lifetime order count per customer, and they reuse it.

sqlleaky_features.sql
-- WRONG. Valid SQL, sensible-looking, and completely broken.
-- The feature counts ALL completed orders, including those in the label window.
WITH labels AS (
  SELECT c.customer_id,
         MAX(CASE WHEN o.status = 'completed'
                   AND o.order_date >  DATE '2024-06-30'
                   AND o.order_date <= DATE '2024-12-31' THEN 1 ELSE 0 END) AS label_orders_in_h2
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
),
leaky_feature AS (
  SELECT c.customer_id,
         COUNT(o.order_id) AS lifetime_completed_orders   -- no date filter at all
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'completed'
  GROUP BY c.customer_id
)
SELECT l.customer_id, f.lifetime_completed_orders, l.label_orders_in_h2
FROM labels l
JOIN leaky_feature f USING (customer_id)
ORDER BY l.customer_id;

The corrected query

The fix is one line. The date filter moves into the feature's join condition, so the count stops at the cut-off.

sqlsafe_features.sql
-- CORRECT. The feature can only see orders on or before the cut-off.
WITH labels AS (
  SELECT c.customer_id,
         MAX(CASE WHEN o.status = 'completed'
                   AND o.order_date >  DATE '2024-06-30'
                   AND o.order_date <= DATE '2024-12-31' THEN 1 ELSE 0 END) AS label_orders_in_h2
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
),
safe_feature AS (
  SELECT c.customer_id,
         COUNT(o.order_id) AS orders_to_cutoff
  FROM customers c
  LEFT JOIN orders o
         ON o.customer_id = c.customer_id
        AND o.status      = 'completed'
        AND o.order_date <= DATE '2024-06-30'   -- the whole fix
  GROUP BY c.customer_id
)
SELECT l.customer_id, f.orders_to_cutoff, l.label_orders_in_h2
FROM labels l
JOIN safe_feature f USING (customer_id)
ORDER BY l.customer_id;

Put the two features beside each other and the mechanism becomes impossible to miss.

sqlleak_side_by_side.sql
WITH labels AS (
  SELECT c.customer_id,
         MAX(CASE WHEN o.status = 'completed'
                   AND o.order_date >  DATE '2024-06-30'
                   AND o.order_date <= DATE '2024-12-31' THEN 1 ELSE 0 END) AS label_orders_in_h2
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
),
feats AS (
  SELECT c.customer_id,
         COUNT(*) FILTER (WHERE o.status = 'completed')            AS leaky_lifetime_orders,
         COUNT(*) FILTER (WHERE o.status = 'completed'
                            AND o.order_date <= DATE '2024-06-30') AS safe_orders_to_cutoff
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
)
SELECT f.customer_id, f.leaky_lifetime_orders, f.safe_orders_to_cutoff, l.label_orders_in_h2
FROM feats f
JOIN labels l USING (customer_id)
ORDER BY f.customer_id;
Result
customer_idleaky_lifetime_orderssafe_orders_to_cutofflabel_orders_in_h2
1320
2110
3110
4110
5321
6000
7110
8211
9110
10211
11110
12201
13000
14101
15201
16000
17000

Look at customers 12, 14 and 15. Their honest feature is 0 — on 30 June 2024 they had placed no completed order at all. Their leaky feature is 2, 1 and 2, because those orders happened in July, August, September and December: inside the label window. The leaky feature is not correlated with the answer, it is partly made of the answer. Any customer whose lifetime count exceeds their pre-cut-off count must, by construction, have ordered after the cut-off — which is the label.

sqlleak_signal_strength.sql
-- How strongly does each feature "predict" the label?
WITH labels AS (
  SELECT c.customer_id,
         MAX(CASE WHEN o.status = 'completed'
                   AND o.order_date >  DATE '2024-06-30'
                   AND o.order_date <= DATE '2024-12-31' THEN 1 ELSE 0 END) AS y
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
),
feats AS (
  SELECT c.customer_id,
         COUNT(*) FILTER (WHERE o.status = 'completed')            AS leaky_lifetime_orders,
         COUNT(*) FILTER (WHERE o.status = 'completed'
                            AND o.order_date <= DATE '2024-06-30') AS safe_orders_to_cutoff
  FROM customers c
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
)
SELECT ROUND(CORR(f.leaky_lifetime_orders, l.y), 4) AS corr_leaky_feature_with_label,
       ROUND(CORR(f.safe_orders_to_cutoff, l.y), 4) AS corr_safe_feature_with_label
FROM feats f
JOIN labels l USING (customer_id);
Result
corr_leaky_feature_with_labelcorr_safe_feature_with_label
0.6001-0.0435

There is the whole tragedy in one row. The leaky feature shows a strong positive relationship with the label; the honest one shows essentially none. A team shipping the first version reports a model that works. A team shipping the second reports that past order count does not predict future ordering here — which is the truth, and far more useful. Open Seventeen customers cannot establish either as a real-world fact; what the contrast shows reliably is the direction of the distortion, which is what leakage always does.

This is why leakage is dangerous in practice: it never announces itself. A leaky model does not error and does not warn. It produces the best score anyone on the team has seen, which is exactly when scrutiny is lowest.

Analogy

Leakage is sitting an exam with tomorrow's newspaper on the desk. Every question about “what will happen” is answered correctly, your mock score is flawless, and you conclude you understand the subject. On the real day the newspaper is gone and you discover you never learned anything — you were reading, not reasoning. The corrected query is the invigilator checking the desk before the paper starts.

Random splits vs temporal splits

Even leakage-free features can be ruined by how you split. A random split is correct when observations are exchangeable — no time ordering matters and each row is an independent draw.

For anything unfolding over time, a random split is a subtler leak. Scatter twelve months of orders randomly and the training set contains December while the test set contains February, so the model predicts the past from the future and sees the effects of promotions it is supposedly forecasting. A temporal split — everything before a date trains, everything after tests — is the only honest simulation of deployment, because deployment is always “fit on what happened, apply to what has not”.

sqltemporal_split.sql
-- A temporal split: the two folds do not overlap in time, by construction.
SELECT CASE WHEN o.order_date <= DATE '2024-08-31' THEN 'train' ELSE 'test' END AS split,
       COUNT(*)          AS orders,
       MIN(o.order_date) AS first_date,
       MAX(o.order_date) AS last_date
FROM orders o
GROUP BY 1
ORDER BY 1;
Result
splitordersfirst_datelast_date
test62024-09-072025-01-09
train192024-01-082024-08-19

The date ranges do not overlap. That is the property you are buying, and it is checkable — make it an assertion in your pipeline rather than a hope.

Group leakage

The third family is group leakage: the split is clean in time and the features are honest, but the same entity appears on both sides. Split orders randomly and a customer with three orders will likely land in both folds. The model then partially memorises that customer and gets credit for it in the test set, so the score measures recall of known customers rather than generalisation to new ones.

sqlgroup_leakage_check.sql
-- Diagnostic: split orders arbitrarily, then count customers straddling both folds.
WITH split AS (
  SELECT o.order_id, o.customer_id,
         CASE WHEN o.order_id % 2 = 0 THEN 'train' ELSE 'test' END AS fold
  FROM orders o
)
SELECT COUNT(*) AS customers_in_both_folds
FROM (
  SELECT customer_id
  FROM split
  GROUP BY customer_id
  HAVING COUNT(DISTINCT fold) > 1
) t;
Result
customers_in_both_folds
7

Seven customers are contaminating the evaluation. The remedy is grouped splitting: assign the group to a fold, then take all its rows — hash the customer_id and split on the hash, so each customer lands deterministically in one fold. The same applies to any repeated entity: patients across visits in Healthcare, devices across sessions, stores across weeks.

Common pitfall — the mutable dimension table

The hardest leakage to spot involves no date filter at all. Dimension tables are frequently overwritten in place: customers.is_active reflects today, not the cut-off. Join it into a churn feature table and you have imported the answer through a column with no timestamp on it. Status flags, value tiers, segment labels and risk scores are all typically current-state fields. If a column can change and is not versioned with an effective date, treat it as leakage until proven otherwise — and reach for a slowly changing dimension so the historical value is recoverable.

When NOT to use a temporal split

  • There is no meaningful time dimension. Classifying images, or scoring a fixed population observed simultaneously — a stratified random split is correct and a temporal one invents an ordering.
  • The data is short and positives are rare. A temporal split can leave the test period with almost no positive cases, making the estimate too noisy to act on. Grouped stratified cross-validation may be the lesser evil, provided you state the assumption.
  • You need many folds from little history. A single cut gives one estimate; rolling-origin backtesting (Lesson 11.11) gives several while preserving time order.
  • The process is genuinely stationary. Rare, and it should be argued rather than assumed — but where it holds, random splitting is more statistically efficient.
Interview angle

A standard senior question: “your model gets 0.99 AUC on validation — what do you do?” The wrong answer is “ship it”. The expected answer: assume leakage until disproved, inspect the highest-importance features one by one, and ask of each whether its value could have been computed at the moment of prediction. Then check the split for time overlap and for entities in both folds. Naming the three families — target leakage from a future window, temporal leakage from a random split, group leakage from a repeated entity — and knowing that a mutable dimension column leaks silently, marks you out immediately.

Exercise 11.13

Telecommunications Rebuild the setup with a cut-off of 30 September 2024 and a label window running to 31 December 2024. Produce two leakage-free features — completed orders up to the cut-off, and days since the last completed order at the cut-off — then summarise their averages by label to see whether either separates the classes.

Show solution
sqlq4_label_summary.sql
WITH params AS (SELECT DATE '2024-09-30' AS cutoff, DATE '2024-12-31' AS horizon_end),
labels AS (
  SELECT c.customer_id,
         MAX(CASE WHEN o.status = 'completed'
                   AND o.order_date >  p.cutoff
                   AND o.order_date <= p.horizon_end THEN 1 ELSE 0 END) AS y
  FROM customers c
  CROSS JOIN params p
  LEFT JOIN orders o ON o.customer_id = c.customer_id
  GROUP BY c.customer_id
),
feats AS (
  SELECT c.customer_id,
         COUNT(o.order_id)                                  AS orders_to_cutoff,
         DATE_DIFF('day', MAX(o.order_date), MIN(p.cutoff)) AS recency_days_at_cutoff
  FROM customers c
  CROSS JOIN params p
  LEFT JOIN orders o
         ON o.customer_id = c.customer_id
        AND o.status      = 'completed'
        AND o.order_date <= p.cutoff
  GROUP BY c.customer_id
)
SELECT l.y                                     AS label,
       COUNT(*)                                AS customers,
       ROUND(AVG(f.orders_to_cutoff), 2)       AS avg_orders_to_cutoff,
       ROUND(AVG(f.recency_days_at_cutoff), 1) AS avg_recency_days
FROM feats f
JOIN labels l USING (customer_id)
GROUP BY l.y
ORDER BY l.y;
Result
labelcustomersavg_orders_to_cutoffavg_recency_days
0141.0141.3
131.075.0

Both windows are anchored to params, so moving the cut-off is a one-line edit — and the feature's date filter sits in the LEFT JOIN condition rather than the WHERE clause, which is what keeps never-ordered customers in the result. Reading the output: order count does not separate the classes at all, while recency plausibly does. Note also that AVG silently ignores the NULL recency of never-ordered customers, so the two averages cover different populations — a caveat worth stating before anyone quotes them.

Knowledge check

A churn model uses customers.is_active as a feature. The column is overwritten nightly to reflect status today. Why is this leakage even though no date appears in the query?

Key takeaway

Leakage is any information in a feature that would not exist at prediction time, and it is a SQL bug: an unfiltered join, a random split over time-ordered rows, an entity in both folds, or a dimension column overwritten in place. It never errors — it just makes the score wonderful. Anchor every feature to an explicit cut-off, split temporally when time matters and by group when entities repeat, and treat an unexpectedly excellent validation score as a bug report rather than a result.

Lesson 11.14·14 min read

Evaluating Models: Accuracy, Precision, Recall, ROC-AUC, RMSE

Build a confusion matrix from scratch in SQL, compute precision and recall by hand, and learn which metric matches which business decision.

Healthcare A screening test for a condition affecting one person in a thousand can be made 99.9% accurate in an afternoon: return “negative” every time. It is right for 999 of every 1,000 patients and misses every single case. The number is true. The test is worthless.

That gap — between a metric that is arithmetically correct and one that reflects the decision being made — is what this lesson is about. Every classification metric is a different summary of the same four counts, and choosing among them is a business judgement about which mistake hurts more.

The confusion matrix from scratch

A binary classifier predicts 1 or 0 about an outcome that is also 1 or 0. Cross the two and there are exactly four possibilities. That grid is the confusion matrix, and every metric below is built from it.

                       ACTUAL
                     1          0
                +----------+----------+
              1 |    TP    |    FP    |   predicted positive
   PREDICTED    | (hit)    | (false   |
                |          |  alarm)  |
                +----------+----------+
              0 |    FN    |    TN    |   predicted negative
                | (miss)   | (correct |
                |          |  reject) |
                +----------+----------+
  • TP — true positive. Predicted 1, actually 1. You flagged it and were right.
  • FP — false positive. Predicted 1, actually 0. A false alarm; effort spent on nothing.
  • FN — false negative. Predicted 0, actually 1. A miss; it happened and you did not see it.
  • TN — true negative. Predicted 0, actually 0. Correctly left alone.

Accuracy = (TP + TN) / everything — “what fraction of all calls were right?”
Precision = TP / (TP + FP) — “when I said yes, how often was I right?”
Recall = TP / (TP + FN) — “of everything actually yes, how much did I catch?”

Precision is about the cost of acting; recall about the cost of missing. They pull against each other, because the only way to catch more positives is to flag more things, and the only way to be surer about what you flag is to flag less.

Analogy

A smoke alarm. Recall is the fraction of real fires it wakes you for — miss one and the house burns down. Precision is the fraction of its alarms that were real fires — if it screams every time you make toast, you take the battery out, and recall becomes zero anyway. Manufacturers deliberately tune towards recall, accepting burnt toast, because the two errors are not equally expensive. Every threshold you set on a model is that same decision, made explicitly.

Computing it in SQL

Truth comes from experiment_assignments.converted. The “model” is a transparent rule: predict conversion if the customer's net revenue before the assignment date reached 250. That is a legitimate baseline classifier, with the pedagogical virtue that you can see exactly why every prediction was made.

sqlconfusion_matrix.sql
-- Truth: experiment_assignments.converted
-- Prediction: pre-assignment net revenue >= 250
WITH params AS (SELECT DATE '2024-06-01' AS cutoff),
pre_revenue AS (
  SELECT o.customer_id,
         SUM(CAST(oi.quantity * oi.unit_price * (1 - oi.discount_pct * 0.01)
                  AS NUMERIC(12,2))) AS net_revenue_pre
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.order_id
  CROSS JOIN params
  WHERE o.status = 'completed'
    AND o.order_date < params.cutoff        -- strictly before: no leakage
  GROUP BY o.customer_id
),
scored AS (
  SELECT e.customer_id,
         e.converted                                                       AS actual,
         CASE WHEN COALESCE(r.net_revenue_pre, 0) >= 250 THEN 1 ELSE 0 END AS predicted
  FROM experiment_assignments e
  LEFT JOIN pre_revenue r ON r.customer_id = e.customer_id
  WHERE e.experiment = 'checkout_v2'
),
cm AS (
  SELECT COUNT(*) FILTER (WHERE predicted = 1 AND actual = 1) AS tp,
         COUNT(*) FILTER (WHERE predicted = 1 AND actual = 0) AS fp,
         COUNT(*) FILTER (WHERE predicted = 0 AND actual = 0) AS tn,
         COUNT(*) FILTER (WHERE predicted = 0 AND actual = 1) AS fn
  FROM scored
)
SELECT tp, fp, tn, fn,
       ROUND(tp * 1.0 / NULLIF(tp + fp, 0), 3)                  AS precision_pos,
       ROUND(tp * 1.0 / NULLIF(tp + fn, 0), 3)                  AS recall_pos,
       ROUND((tp + tn) * 1.0 / NULLIF(tp + fp + tn + fn, 0), 3) AS accuracy,
       ROUND(2.0 * tp / NULLIF(2 * tp + fp + fn, 0), 3)         AS f1_score
FROM cm;
Result
tpfptnfnprecision_posrecall_posaccuracyf1_score
41640.80.50.6670.615

Read it as a sentence. Of the five customers the rule flagged, four converted — precision 0.8, so when it speaks it is usually right. But eight converted in total and it found four — recall 0.5, so it misses half. Accuracy sits at 0.667 between them, revealing neither. The F1 score, the harmonic mean of precision and recall, compresses both into 0.615; a reasonable default when the two errors cost alike, and misleading when they do not.

Two SQL details are load-bearing. COUNT(*) FILTER (WHERE ...) builds all four cells in one pass. And tp * 1.0 forces the division out of integer arithmetic — without it, 4 / 5 is 0 in many engines and your precision silently becomes zero.

Why accuracy fails under class imbalance

Accuracy weights every row equally, so when one class dominates it dictates the score and the minority class — usually the one you care about — contributes almost nothing.

sqlbase_rate.sql
-- Always compute the base rate BEFORE celebrating an accuracy figure.
SELECT COUNT(*)                           AS n,
       SUM(converted)                     AS positives,
       COUNT(*) - SUM(converted)          AS negatives,
       ROUND(AVG(converted * 1.0), 3)     AS base_rate,
       ROUND(1 - AVG(converted * 1.0), 3) AS accuracy_of_always_predicting_zero
FROM experiment_assignments
WHERE experiment = 'checkout_v2';
Result
npositivesnegativesbase_rateaccuracy_of_always_predicting_zero
15870.5330.467

Here the classes are nearly balanced, so the do-nothing classifier scores 0.467 and the rule's 0.667 is a genuine improvement. Now imagine fraud, where the base rate might be a fraction of a percent: the do-nothing classifier posts an accuracy in the high nineties and the fraud team catches nothing. Inference The base rate therefore belongs in every evaluation report — without it you cannot tell whether the model beat the trivial baseline or merely inherited it.

Common pitfall — reporting one number

A single metric can always be gamed by moving the threshold. Push it low and recall approaches 1 while precision collapses; push it high and the reverse. A report of “precision 0.95” with no recall, or “recall 0.99” with no precision, is not an evaluation — it is a selected statistic. Report the confusion matrix itself, or at minimum precision and recall together at a stated threshold, alongside the base rate.

ROC-AUC: grading the ranking, not the threshold

Precision and recall describe a model at one threshold. Often the threshold is a business choice made later — how many customers can the retention team actually call this week? — and you want to know how good the underlying ordering is, independent of where the line is drawn.

The ROC curve plots the true positive rate against the false positive rate as the threshold sweeps every possible value. ROC-AUC is the area under it, and its interpretation is far more intuitive than its construction: Fact AUC equals the probability that a randomly chosen positive scores higher than a randomly chosen negative. 0.5 is coin-flipping; 1.0 is a perfect separator. That definition is directly computable — compare every positive against every negative and count the wins.

sqlroc_auc_from_pairs.sql
-- AUC = P(score of a random positive > score of a random negative),
-- with ties counted as half. Every positive is paired with every negative.
WITH params AS (SELECT DATE '2024-06-01' AS cutoff),
pre_revenue AS (
  SELECT o.customer_id,
         SUM(CAST(oi.quantity * oi.unit_price * (1 - oi.discount_pct * 0.01)
                  AS NUMERIC(12,2))) AS net_revenue_pre
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.order_id
  CROSS JOIN params
  WHERE o.status = 'completed' AND o.order_date < params.cutoff
  GROUP BY o.customer_id
),
scored AS (
  SELECT e.customer_id, e.converted AS actual,
         COALESCE(r.net_revenue_pre, 0) AS score
  FROM experiment_assignments e
  LEFT JOIN pre_revenue r ON r.customer_id = e.customer_id
  WHERE e.experiment = 'checkout_v2'
)
SELECT COUNT(*)                                   AS pairs_compared,
       ROUND(AVG(CASE WHEN pos.score > neg.score THEN 1.0
                      WHEN pos.score = neg.score THEN 0.5
                      ELSE 0.0 END), 4)           AS roc_auc
FROM scored pos
JOIN scored neg ON pos.actual = 1 AND neg.actual = 0;
Result
pairs_comparedroc_auc
560.5893

Eight positives times seven negatives gives 56 pairs, and the positive outranks the negative in about 59% of them — barely better than chance. That is an honest verdict on a one-variable rule, and far more informative than the accuracy figure. Fact AUC has a known weakness: under severe imbalance it flatters, because the enormous negative class makes the false positive rate move slowly. When positives are rare and you care about them, the precision-recall curve is the more honest summary.

Regression: RMSE and MAE

When the target is a number rather than a class, the confusion matrix does not apply and you measure the size of the errors instead. MAE (mean absolute error) averages the absolute differences: “on average we are off by this much”, in the units of the target, and it is the easiest metric to explain to a stakeholder. RMSE squares the errors before averaging, then takes the square root, so one error of 100 counts far more than ten errors of 10.

Choose RMSE when a big miss is much worse than several small ones — a warehouse under-forecasting by 1,000 units once has a crisis, while being off by 10 units a hundred times is a Tuesday. When errors of all sizes cost alike, MAE fits better. RMSE is always at least as large as MAE, and the gap is itself informative: a wide gap means a few large errors dominate.

In the field

The question that turns a metric into a decision is: what does each error cost? SaaS If a false positive on a churn model means a small monthly discount and a false negative means losing the account, false negatives cost far more and you tune hard towards recall. Get both costs from the business, write them down, and pick the operating threshold that minimises expected cost. That conversation is worth more than any amount of model tuning, because it reframes a statistical debate as an arithmetic one.

When NOT to trust these metrics

  • The test set is not representative. A metric computed on a leaky or badly split test set measures nothing, however carefully calculated. Lesson 11.13 comes logically before this one.
  • The sample is tiny. Fifteen rows produce metrics that move wholesale when one row changes. Do not quote three decimal places on fifteen observations.
  • The model changes the world it predicts. A churn model that triggers a retention call makes its own positives less likely to churn, so measured precision falls precisely because the intervention worked. Evaluating an intervention needs an experiment, not a hold-out.
  • You need calibrated probabilities, not a ranking. AUC is invariant to any monotonic transformation of the scores, so a model can rank perfectly and still say “90%” when it means “30%”. If the number feeds an expected-value calculation, measure calibration explicitly.
  • Errors are not exchangeable across groups. Banking Overall AUC can be strong while performance is materially worse for one segment. Aggregate metrics hide that by construction; compute them per group.
Interview angle

Two questions recur. “A fraud model is 99% accurate — is it good?” The answer is a question: what is the base rate? If fraud is 1% of transactions, 99% accuracy is exactly what predicting “never fraud” achieves. And “precision or recall?” has no context-free answer; you decide by naming the cost of each error. Cancer screening favours recall because a missed case is fatal and a false alarm is a follow-up test; a spam filter favours precision because a lost invoice costs more than a nuisance email. Being asked to compute precision and recall in SQL from a table of predictions and labels is also common — practise COUNT(*) FILTER (WHERE ...) until it is automatic.

Exercise 11.14

The threshold of 250 was arbitrary. Sweep it across 0, 150, 250 and 400, reporting TP, FP, FN, precision and recall at each. Then say which threshold you would ship if a false negative cost roughly five times a false positive.

Show solution
sqlthreshold_sweep.sql
WITH params AS (SELECT DATE '2024-06-01' AS cutoff),
pre_revenue AS (
  SELECT o.customer_id,
         SUM(CAST(oi.quantity * oi.unit_price * (1 - oi.discount_pct * 0.01)
                  AS NUMERIC(12,2))) AS net_revenue_pre
  FROM orders o
  JOIN order_items oi ON oi.order_id = o.order_id
  CROSS JOIN params
  WHERE o.status = 'completed' AND o.order_date < params.cutoff
  GROUP BY o.customer_id
),
scored AS (
  SELECT e.customer_id, e.converted AS actual,
         COALESCE(r.net_revenue_pre, 0) AS score
  FROM experiment_assignments e
  LEFT JOIN pre_revenue r ON r.customer_id = e.customer_id
  WHERE e.experiment = 'checkout_v2'
),
thresholds(t) AS (VALUES (0), (150), (250), (400))
SELECT th.t AS threshold,
       COUNT(*) FILTER (WHERE s.score >= th.t AND s.actual = 1) AS tp,
       COUNT(*) FILTER (WHERE s.score >= th.t AND s.actual = 0) AS fp,
       COUNT(*) FILTER (WHERE s.score <  th.t AND s.actual = 1) AS fn,
       ROUND(COUNT(*) FILTER (WHERE s.score >= th.t AND s.actual = 1) * 1.0
             / NULLIF(COUNT(*) FILTER (WHERE s.score >= th.t), 0), 3) AS precision_pos,
       ROUND(COUNT(*) FILTER (WHERE s.score >= th.t AND s.actual = 1) * 1.0
             / NULLIF(COUNT(*) FILTER (WHERE s.actual = 1), 0), 3)    AS recall_pos
FROM thresholds th
CROSS JOIN scored s
GROUP BY th.t
ORDER BY th.t;
Result
thresholdtpfpfnprecision_posrecall_pos
08700.5331.0
1504340.5710.5
2504140.80.5
4002061.00.25

The trade-off is textbook: as the threshold rises, precision climbs from 0.533 to 1.0 while recall falls from 1.0 to 0.25. With a false negative costing five times a false positive, score each row as 5×FN + 1×FP and take the minimum: threshold 0 costs 7, 150 costs 23, 250 costs 21, 400 costs 30. Flagging everyone wins — the correct answer to the question as posed, and a reminder that when misses are expensive and capacity is unlimited, the optimal policy is often to act on everybody. The moment capacity is finite the calculation changes, and that constraint belongs in the brief.

Knowledge check

A model flags 100 transactions as fraud; 90 really are. There were 300 fraudulent transactions in total. What are precision and recall?

Knowledge check

A demand forecast is scored with both RMSE and MAE. RMSE is far larger than MAE. What does that tell you?

Key takeaway

Every classification metric is a view of four numbers: TP, FP, TN, FN. Precision asks “when I said yes, was I right?”; recall asks “did I catch everything?”; accuracy becomes meaningless as classes grow imbalanced, so always report the base rate beside it. ROC-AUC grades the ranking independently of the threshold and equals the chance a random positive outranks a random negative. For numeric targets, MAE when all errors cost alike, RMSE when large misses cost more. The metric is not a statistical preference — it is a statement about which mistake your business can least afford.

Part 12

Performance Engineering

Execution plans, B-trees, join algorithms, partitioning, pushdown and vectorised execution — why queries are slow and how to fix them.

Lesson 12.1·13 min read

How a Query Actually Executes: Parse, Plan, Execute

By the end you will be able to describe the four stages a SQL string passes through before a single byte is read — and say which stage your slow query is stuck in.

Banking A fraud analyst runs a report that has always returned in a few seconds. This morning it has been running for twenty minutes. Nothing in the SQL changed. Nothing in the schema changed. The only thing that changed is that overnight the transactions table grew, and a statistics refresh job failed.

That story only makes sense once you understand a fact that most people never learn explicitly: the SQL you write is not what runs. Your text is a description of a result, not a description of a procedure. Between the text and the answer sits a compiler and a decision-maker, and both of them can change their minds without you touching a line.

Why the engine gets to choose

Every other language you might have used is imperative: you say open the file, loop, accumulate. SQL is declarative: you say what the answer looks like and the engine invents the loops. That is a genuine loss of control, and it was a deliberate trade.

The pre-relational systems it replaced made you write the navigation yourself: follow this pointer, then that one. Fact When the data grew or the physical layout changed, every program that touched it had to be rewritten. The relational model separated the logical question from the physical access path precisely so the engine could re-optimise while your query text stayed still. The price is that somebody else picks the loop.

The four stages

flowchart LR
  A["SQL text"] --> B["1. Parse
(syntax tree)"] B --> C["2. Bind / resolve
(names, types)"] C --> D["3. Plan / optimise
(choose access paths)"] D --> E["4. Execute
(operator pipeline)"] F["Catalogue + statistics"] --> C F --> D
Stages 1 and 2 depend only on your text and the schema. Stage 3 also depends on statistics — which is why an unchanged query can suddenly change behaviour.

1. Parse. The text is checked against SQL's grammar and turned into a tree. A missing comma or an unbalanced bracket dies here. Parsing knows nothing about your tables — SELECT * FROM unicorns parses perfectly.

2. Bind (also called resolve, or semantic analysis). Every identifier is looked up in the catalogue: does orders exist, does it have a column called status, is status = 'completed' a legal comparison between a VARCHAR and a string literal? Unknown-column and type-mismatch errors come from here. The output is a logical plan: a tree of relational operations (scan, filter, join, aggregate) that says what, still not how.

3. Plan and optimise. This is where the interesting decisions happen. The optimiser rewrites the logical tree into equivalent-but-cheaper shapes, then chooses a physical implementation for each node. Two families of work:

  • Rewrites that are always safe — pushing a filter down so it runs before a join rather than after; collapsing a subquery into a join; removing a column nobody selected so it is never read from disk.
  • Cost-based choices — which table to scan first, whether to join by hashing or by sorting or by index lookup, whether to aggregate by hash table or by sorting. Each candidate is assigned an estimated cost, and the cheapest estimate wins.

4. Execute. The physical plan becomes a pipeline of operators, and rows (or, in a columnar engine, batches of column values) flow from the leaves upward. Nothing is negotiable any more: the plan is fixed for the life of the query.

Analogy

Booking a journey. You tell the app “Manchester to Lisbon by Friday” — that is your SQL. The app checks the phrasing makes sense (parse), confirms both places exist and Friday is a real date (bind), then compares routes: direct flight, train plus flight, two connecting flights (plan). It picks one using a price table it downloaded earlier. If that price table is out of date, it will confidently route you the long way round and tell you it is the cheapest. The journey itself — the actual travelling — is execution, and by then the route is no longer up for discussion.

Statistics: the reason plans change on their own

The optimiser cannot afford to look at your data to decide how to look at your data. Instead it consults a summary the engine maintains: row counts per table, number of distinct values per column, most-common values, histograms of ranges, and the fraction of NULLs. From these it estimates how many rows will survive each filter and join.

Inference This is the whole explanation for the fraud analyst's morning. The table doubled; the statistics still described yesterday's smaller table; the optimiser estimated a filter would return a handful of rows, chose a strategy that is excellent for a handful and terrible for millions, and executed it faithfully. The query did not get slower. The plan got wrong.

Here is a query whose shape gives the optimiser several decisions to make — which side of the join to build from, whether to filter before or after joining, how to group:

sqlorders_by_country.sql
-- Logically: join everything, then filter, then group.
-- Physically: the engine will filter orders FIRST, because
-- shrinking the input to a join is nearly always cheaper.
SELECT c.country,
       COUNT(*) AS completed_orders
FROM orders    AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.country
HAVING COUNT(*) >= 2
ORDER BY completed_orders DESC, c.country;
Result
countrycompleted_orders
NULL4
GB3
SE3
IT2
JP2
US2

The logical order — FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY — is what explains why you cannot reference a SELECT alias in WHERE. The physical order differs again: the engine evaluates the WHERE on orders before the join touches customers, because doing so cannot change the answer and reduces the work.

💡Intuition

Three different orderings live in your head at once, and confusing them causes most performance confusion. Written order is what you type. Logical order defines what the answer means. Physical order is what the machine does. Only the second is fixed by the standard; the third is the optimiser's business and may differ between two runs of identical text.

Common pitfall — rewriting SQL to “force” an order

People shuffle join order or wrap things in subqueries hoping to make the engine work the way they picture it. Usually the optimiser flattens it straight back into the same plan and the query is merely harder to read. Occasionally it works — which is worse, because you have encoded a workaround for one version of one cost model into production code with no comment explaining it. Change the inputs to the decision (statistics, indexes, predicates) rather than out-arguing the decision.

When this mental model does not help you

Not every slow query is a planning problem, and reaching for plans first wastes time. Do not start here when:

  • The query is fast but you run it a thousand times. The bottleneck is round trips. Batch, or push the loop into SQL.
  • The result set itself is enormous. Returning millions of rows to a client is dominated by serialisation and network. Aggregate server-side.
  • The query is waiting, not working. Blocked on someone else's lock, the plan is irrelevant. A query using no CPU is not an optimiser problem.
  • The system is starved. If the machine is swapping, every plan is slow. Fix the resource first.
Interview angle

“A query that ran fine yesterday is slow today and nothing was deployed.” The interviewer is testing whether you know the plan is data-dependent. Weak answer: “add an index”. Strong answer: separate working from waiting, check whether the plan changed, then ask what caused it — stale or refreshed statistics, a load shifting a column's distribution, an unusually unselective parameter value. Naming statistics as the mechanism is the signal they listen for.

Exercise 12.1

Banking Count how many orders belong to customers acquired through paid search. Write it two ways — once with a join, once with a subquery — and then argue whether you expect the engine to run them differently.

Show solution
sqltwo_ways.sql
-- Version A: semi-join expressed as IN
SELECT COUNT(*) AS n
FROM orders AS o
WHERE o.customer_id IN (
        SELECT c.customer_id
        FROM customers AS c
        WHERE c.channel = 'paid_search');
Result
n
11

The join version (JOIN customers c ON c.customer_id = o.customer_id WHERE c.channel = 'paid_search') returns the same 11 here because customer_id is unique in customers. That uniqueness is the whole argument: because one order can match at most one customer, the two forms are provably equivalent, and mainstream optimisers rewrite IN (subquery) into a semi-join and then into the same physical plan. If customer_id were not unique, the join would multiply rows and the two queries would give different answers — so the rewrite would be illegal and the engine would not perform it. The lesson: the optimiser's freedom is bounded by what the schema lets it prove.

Knowledge check

A query's text has not changed for a year, but this week it became a hundred times slower. Which explanation is most consistent with how execution works?

Key takeaway

Your SQL is a specification, not a program. It is parsed, bound against the catalogue, planned using statistics, and only then executed. Parsing and binding depend on your text; planning depends on your data. That single asymmetry explains why unchanged queries change speed — and it means performance work is mostly about improving the information the optimiser reasons with, not about rewriting the sentence.

Lesson 12.2·15 min read

Reading an Execution Plan

You will be able to open a plan, find the operator that is actually costing you, and tell within seconds whether the optimiser was lied to.

Telecommunications A network operations team has a dashboard query over call-detail records that times out during the evening peak and returns instantly at 3am. Every guess someone offers — “it needs an index”, “the join is wrong” — costs a day and proves nothing.

The plan ends the guessing. It is the engine telling you exactly what it intends to do and — with EXPLAIN ANALYZE — what it actually did.

Two verbs: EXPLAIN and EXPLAIN ANALYZE

EXPLAIN asks for the plan without running the query. It is free and safe. You get the operator tree and the optimiser's estimates.

EXPLAIN ANALYZE runs the query and reports the plan annotated with measurements: how long each operator took, how many rows really came out. It is not free, and on a statement that modifies data it really modifies the data — wrap it in a transaction you roll back.

sqlexplain_basic.sql
-- Ask the engine what it PLANS to do. Nothing is executed.
EXPLAIN
SELECT o.order_id, o.order_date
FROM orders AS o
WHERE o.status = 'completed';

DuckDB renders this as a box diagram; PostgreSQL, SQL Server and Oracle render an indented text tree. The vocabulary differs, the anatomy does not. Every plan is a tree of operators, and you read it the same way everywhere:

  • Leaves are data sources — a scan of a table or an index. All rows originate here.
  • Interior nodes consume and transform — filters, joins, aggregates, sorts.
  • The root is what you receive. Rows flow from the bottom up, so read bottom-up to follow the work, and top-down to follow the meaning.

The four numbers that matter

Here is a PostgreSQL-style plan for a similar query. The numbers are the whole point:

planplan_seqscan.txt
Seq Scan on orders  (cost=0.00..18.50 rows=9 width=12)
                    (actual time=0.011..0.019 rows=21 loops=1)
  Filter: ((status)::text = 'completed'::text)
  Rows Removed by Filter: 4
Planning Time: 0.084 ms
Execution Time: 0.031 ms

cost=0.00..18.50. Two numbers, not one. The first is the start-up cost: work that must finish before the first row can be emitted. The second is the total cost to produce all rows. The unit is arbitrary — an internal currency calibrated so that one sequential page read is 1.0 — so a cost of 18.50 means nothing on its own. Fact Costs are only ever meaningful relative to other costs in the same plan, or in a competing plan for the same query. Anyone who tells you a cost of 5,000 is “bad” is reading a currency with no exchange rate.

The start-up/total split explains why LIMIT 10 transforms one query and does nothing for another. Operators that must see all their input before emitting anything — sorts, hash builds, most aggregates — have a high start-up cost, so the limit only discards rows already paid for. A streaming plan, by contrast, simply stops early.

rows=9. The estimate. The optimiser expects nine rows past that filter.

actual rows=21. The truth. It got twenty-one. That gap is the diagnosis.

loops=1. How many times this operator ran. Critical and routinely missed: in most engines the actual time and rows shown are per loop, so the real total is the reported figure multiplied by loops. An inner operator reporting actual time=0.05 rows=1 loops=200000 is not fast — it is a fifth of a second of pure lookup, repeated two hundred thousand times, and it is very likely your entire problem.

Analogy

A plan is a builder's quote next to the final invoice. The quote (estimated cost and rows) was written before anyone lifted a tool, based on a survey of the property. The invoice (actual time and rows) records what really happened. If the quote said “nine hours” and the invoice says “two hundred hours”, you do not argue about the hourly rate — you go and find out what the surveyor missed. Same with plans: chase the mismatch, not the cost.

Spotting the estimate-versus-actual mismatch

This is the core technique, and it is almost mechanical:

  1. Get an EXPLAIN ANALYZE plan.
  2. At every node, compute estimated rows versus actual rows × loops.
  3. Find the deepest node where the ratio first goes badly wrong — an order of magnitude or more, in either direction.
  4. That node is where the optimiser was misled. Everything above it inherited the bad number and made downstream choices on top of it.

Inference Underestimates hurt far more than overestimates. Expecting a handful of rows, the engine picks strategies that are excellent per-row and catastrophic in bulk — repeated index lookups in a nested loop, or a hash table sized for a handful that then spills to disk. Expecting millions and getting a handful only wastes heavyweight machinery on little data: inefficient, but survivable.

Here is what a mismatch looks like in a two-table plan. Read it bottom-up:

planplan_mismatch.txt
Nested Loop  (cost=0.29..842.11 rows=12 width=44)
             (actual time=0.03..9184.55 rows=418902 loops=1)
  ->  Seq Scan on orders o  (cost=0.00..18.50 rows=12 width=12)
                            (actual time=0.01..2.11 rows=418902 loops=1)
        Filter: (status = 'completed')
  ->  Index Scan using customers_pkey on customers c
                            (cost=0.29..0.68 rows=1 width=32)
                            (actual time=0.02..0.02 rows=1 loops=418902)
        Index Cond: (customer_id = o.customer_id)

The deepest wrong node is the sequential scan: estimated 12 rows, produced 418,902. Believing 12, a nested loop looked sensible — twelve index lookups is nothing. Instead the inner side ran 418,902 times. Each lookup is genuinely fast, and that is the trap: every operator here looks healthy in isolation, and only the loop count reveals the disaster. The fix is never “make the index scan faster”; it is to correct the estimate so the optimiser reconsiders and picks a hash join that reads each side once.

Now the same idea with real measurements you can reproduce:

sqlexplain_analyze.sql
-- Runs the query, then reports the plan with real timings and row counts.
EXPLAIN ANALYZE
SELECT c.country,
       COUNT(*) AS completed_orders
FROM orders    AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.country;

On a dataset this small the timings are noise, and that is a lesson in itself: plans measured on toy data teach you the shape, never the cost. An engine will often scan a tiny table sequentially no matter how many indexes you build, because for a handful of pages a scan genuinely is cheapest. Always confirm performance conclusions against production-scale volumes.

Common pitfall — hunting the highest cost number

The instinct is to find the biggest cost and attack it. Two reasons this misleads. Costs are cumulative — a parent's total includes its children, so the root always has the largest number and tells you nothing. And cost is the estimate, which is exactly what you have just discovered to be untrustworthy. Sort by actual time exclusive of children, and by estimate-versus-actual ratio.

When reading the plan is the wrong move

Plans answer “how is this query executing?” They are silent on everything else, and a surprising share of production slowness lives in that silence.

  • Contention. If the query is blocked on a lock, the plan is optimal and irrelevant. Check wait states first.
  • Aggregate load. A hundred cheap queries can saturate a server while every individual plan is fine. That is a capacity question.
  • The wrong query entirely. Tuning a report that scans two years to display last week optimises the wrong artefact.
  • Non-representative environments. A plan from a thousand-row laptop copy can send you confidently in the wrong direction.
Interview angle

A plan is often put in front of you and you are asked to narrate it. Do it in this order: name the join strategy, read bottom-up naming each operator, then explicitly compare estimated with actual rows at each level and point at the deepest divergence. Say the words “actual rows are per loop, so the real count here is rows times loops” — interviewers use that detail as a filter. Finish with what you would change about the estimate, not about the query text.

Exercise 12.2

Telecommunications You are handed a plan node reading rows=50 ... (actual rows=3 loops=90000). State (a) how many rows the operator really produced in total, (b) whether the optimiser under- or over-estimated, and (c) which decision higher in the plan this most likely broke. Then produce a plan of your own for a three-table join over orders, order_items and customers.

Show solution

(a) 3 × 90,000 = 270,000 rows. The headline “3” is per loop and is nearly always the number people misread.

(b) A severe under-estimate, but the interesting under-estimate is not on this node — it is on whatever feeds the outer side of the loop. Something above or beside this node expected roughly 50 driving rows and got 90,000, which is why this operator ran 90,000 times.

(c) The join strategy. Ninety thousand index probes is the signature of a nested loop chosen on the belief that the outer input was tiny. With honest estimates the optimiser would most likely have built a hash table and read each side once, turning per-row lookup cost into a single pass whose cost grows linearly with the input rather than multiplicatively.

sqlexplain_join3.sql
EXPLAIN
SELECT c.country,
       SUM(oi.quantity * oi.unit_price) AS gross_revenue
FROM orders      AS o
JOIN order_items AS oi ON oi.order_id    = o.order_id
JOIN customers   AS c  ON c.customer_id  = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.country;

Read the output bottom-up. You should see three scans at the leaves, two join operators stacked above them, and an aggregate near the root. Notice where the status filter appears: attached to the orders scan itself, not applied after the joins. That is predicate pushdown, and seeing it in the plan is how you confirm the optimiser found it.

Knowledge check

A plan node shows (cost=0.29..0.68 rows=1) (actual time=0.02..0.02 rows=1 loops=400000). What is the correct reading?

Key takeaway

EXPLAIN shows intentions, EXPLAIN ANALYZE shows facts. Read bottom-up, ignore the absolute cost, always multiply actual rows by loops, and hunt for the deepest node where estimate and reality diverge. That node — not the biggest number — is where the optimiser was misinformed, and correcting the information beats rewriting the query almost every time.

Lesson 12.3·15 min read

B-Tree Indexes From First Principles

You will be able to explain why a lookup in a billion-row table costs only a handful of page reads — and why that same structure makes every write more expensive.

Digital media A streaming platform stores one row per view. Finding every view for one title without help means reading the entire table: at a billion rows, a bulk data transfer to answer a question about a tiny fraction of it. The cost grows in proportion to the table, so every month the same question gets slower.

An index changes the growth curve rather than the constant — and that comes from one structural idea.

First principles: the page is the unit of cost

Databases do not read rows from storage. They read pages — fixed-size blocks, commonly 4KB, 8KB or 16KB. Asking for one byte costs a whole page. Fact This is a property of the storage stack, not the database: block devices transfer in blocks, and the database aligns to that.

Performance analysis therefore becomes counting: how many pages must I touch? A full scan touches every page. An index exists to make that count small and stop it growing in step with the table.

Why not sort the table and binary search it? Sorted arrays are excellent for reading and terrible for writing — inserting in the middle shifts everything after it. A B-tree keeps sorted-order lookup while making insertion local, by storing keys in pages linked by pointers, so an insertion rearranges one page instead of a file.

Descending the tree

A B-tree (specifically the B+tree variant nearly every database actually uses) has three parts: a root page, some levels of internal pages holding only separator keys and pointers, and a bottom level of leaf pages holding every key in sorted order along with a pointer to the row. Leaves are chained left-to-right, which is what makes range scans cheap.

Suppose we want the views for content_id = 4:

                        ROOT  (page 1)   -- always in memory
                 +-----------------------------+
                 |  [ 200 ]      [ 640 ]       |
                 +--|-------------|--------|---+
        key < 200 --+   200..639 -+        +-- key >= 640
             |                |                     |
             v                v                     v
     INTERNAL (page 2)  INTERNAL (page 3)    INTERNAL (page 4)
     +-------------+    +---------------+    +---------------+
     | [ 60 ][130 ]|    | [ 310 ][ 480 ]|    | [ 700 ][ 880 ]|
     +--|----|---|-+    +---|-----|---|-+    +---|-----|---|-+
        |    |   |          |     |   |          |     |   |
        v    v   v          v     v   v          v     v   v
     LEAF LEAF LEAF      LEAF   LEAF LEAF     LEAF  LEAF  LEAF
    +-------------------------------------------------------+
    | ... | [1 ->row][2 ->row][4 ->row][7 ->row] | ...       |
    +-------------------------------------------------------+
                            ^
                            |
              step 3: found key 4, follow the pointer

  SEARCH FOR key = 4
    step 1  read ROOT     -- 4 < 200, take the left pointer     (1 page read)
    step 2  read INTERNAL -- 4 < 60,  take the left pointer     (1 page read)
    step 3  read LEAF     -- binary search inside the page      (1 page read)
    step 4  follow pointer to the table row                     (1 page read)
                                              --------------------------------
                                              total: 4 page reads, ANY table size

Three reads to locate the key, one to fetch the row. Note what is absent: the number of rows in the table plays no part in that count beyond determining how many levels exist. That is the whole trick, and the next section explains why the level count stays tiny.

Why height grows logarithmically — and why that means very few levels

Each internal page is a full disk page of separator keys and pointers. If a key plus pointer occupies roughly 16 bytes and a page is 8KB, one page holds on the order of several hundred entries. That number is the fanout.

Fanout is what makes the tree flat. With fanout f, the root addresses f children, level two addresses f², level three f³. The number of leaf pages reachable grows as f raised to the height, so the height needed for N rows is log fN — a logarithm with a very large base. Taking a fanout in the low hundreds as an illustration:

LevelPages reachable (fanout ≈ 300)Rows addressable (order of magnitude)
Root only1hundreds
+1 level~300tens of thousands
+2 levels~90,000tens of millions
+3 levels~27,000,000billions

Read that table as the punchline of the lesson. Inference Because the base of the logarithm is in the hundreds rather than 2, the height barely moves as data grows: a table a thousand times bigger needs only about one extra level, because a thousand is small compared with the fanout. In practice production B-trees are typically three or four levels deep across an enormous range of table sizes.

The consequence for page reads is stark. A full scan costs pages proportional to N: double the rows, double the reads, forever. An indexed point lookup costs the height plus one, and the height moves by one only when the table grows by roughly a factor of the fanout. Better still, the upper levels are read constantly and stay resident in the buffer cache, so in a warm system the real disk cost of a lookup collapses towards one or two reads.

Analogy

A library. Without an index you walk every shelf in the building. With one, you read a board in the foyer (“Science: east wing”), a sign at the wing entrance (“500–599: aisles 12–18”), then the aisle label — and you are at the book. Three signs. Now double the library's size: you do not read six signs, you read three signs with slightly more text on each, because a sign can list many destinations. Fanout is how many destinations fit on one sign, and it is why the number of signs you read barely changes when the library grows.

Ranges, order, and what an index gives you for free

Because leaves are sorted and chained, a range query descends once and then walks sideways: the cost is the descent plus the leaf pages spanned — proportional to matching rows, not table size. The same property lets the index supply ORDER BY without a sort and answer MIN/MAX by reading one edge.

sqlrange_lookup.sql
-- A range predicate on an indexed column: descend once, then walk the
-- leaf chain. Cost is proportional to rows MATCHED, not rows STORED.
CREATE INDEX idx_orders_order_date ON orders(order_date);

SELECT order_id, order_date, status
FROM orders
WHERE order_date BETWEEN DATE '2024-06-01' AND DATE '2024-06-30'
ORDER BY order_date;
Result
order_idorder_datestatus
10082024-06-01completed
10152024-06-04completed
10182024-06-23completed
Common pitfall — wrapping the indexed column in a function

Writing WHERE YEAR(order_date) = 2024 or WHERE UPPER(email) = 'A@EX.COM' makes the index useless. The tree is sorted by order_date, not by YEAR(order_date), so there is no range of the key that corresponds to your predicate and the engine must compute the function for every row. The same happens with a leading wildcard: LIKE '%shoe' cannot be a prefix range. The fix is to keep the column bare and transform the constant instead — WHERE order_date >= DATE '2024-01-01' AND order_date < DATE '2025-01-01' — or to build an index on the expression itself where the engine supports it.

When a B-tree index hurts

Indexes are not free lookups bolted onto a table; they are a second data structure that must be kept true. Every insert, update of an indexed column, and delete must also modify every index on that table, and each of those modifications is itself a tree descent plus a page write. Avoid or reconsider a B-tree when:

  • The predicate is not selective. If a value matches a large share of rows, the engine fetches a large share of table pages one at a time in index order, scattering reads. A sequential scan reads the same pages in physical order and wins — so the optimiser ignores your index, correctly.
  • The table is small. A few pages are cheaper to scan outright than to descend a tree for. This is why plans over the teaching dataset show sequential scans everywhere.
  • The workload is write-heavy. On ingest-dominated tables — event streams, telemetry, audit logs — every extra index taxes every write.
  • The column is low-cardinality. An index on a boolean or three-value status column has almost no discriminating power.
  • You are on a columnar analytics engine. DuckDB, BigQuery and Snowflake lean on column pruning, compression and zone maps rather than row-level B-trees; the fast path was never a per-row lookup.
Interview angle

“Why is a B-tree lookup fast on a huge table?” The mediocre answer is “because it's a tree, so it's logarithmic”. The strong answer names the base: the log is base-fanout, fanout is set by how many keys fit in a page, pages hold hundreds of keys, therefore height is three or four for almost any real table and a lookup is a handful of page reads. Then volunteer the cost side — writes must maintain the tree — and the case where it loses: a non-selective predicate, where random single-row fetches beat by an ordinary sequential scan.

Exercise 12.3

Digital media A viewing-history table holds one row per view. Two queries run against it: (1) all views for a given content title, (2) all views in a date range for the weekly report. Explain in page-read terms what an index on the content identifier does for each. Then write a query that uses a date range and confirm what an index on the date column would provide beyond the filter itself.

Show solution

Query 1 is a point lookup on a high-cardinality column: descend the tree (roughly three reads, mostly cached), land on the first matching leaf, walk the chain while the key matches, fetch one table page per matching row. Pages touched are proportional to matching rows plus a small constant — independent of table size.

Query 2 gains nothing from that index. A tree sorted by content identifier has no contiguous region corresponding to a date range, so the engine must scan. It needs its own index on the date column — or, better, partitioning by date, since the report always slices that way.

sqlviews_range.sql
CREATE INDEX idx_views_viewed_on ON views(viewed_on);

SELECT viewed_on,
       COUNT(*)      AS view_events,
       SUM(minutes)  AS minutes_watched
FROM views
WHERE viewed_on >= DATE '2024-09-01'
  AND viewed_on <  DATE '2024-12-01'
GROUP BY viewed_on
ORDER BY viewed_on;

Beyond the filter, the index supplies sorted order for free: leaves are already in viewed_on order, so the ORDER BY needs no sort operator and the grouping can stream adjacent equal keys instead of building a hash table. Note the half-open range, which keeps the column bare and the index usable — unlike a YEAR() wrapper.

Knowledge check

A B-tree on a ten-million-row table is three levels deep. The table grows to one billion rows. Roughly what happens to the depth?

Key takeaway

Cost is measured in page reads. A scan reads pages in proportion to table size; a B-tree lookup reads pages in proportion to tree height, and height is log-base-fanout of the row count — so a few hundred keys per page keeps almost any real table three or four levels deep. You buy that flat lookup curve with write amplification and storage, which is why the right question is never “should I index?” but “is this predicate selective enough, and often enough, to pay for the writes?”

Lesson 12.4·14 min read

Hash, Bitmap, GIN & Specialised Indexes

You will be able to pick the right index structure for a predicate shape — and explain why the B-tree is still the correct default nine times out of ten.

Manufacturing A plant's quality system stores an inspection record per unit, and three questions get asked of it constantly: find one record by serial number; find every unit failing on a three-value severity flag; find every record whose free-text inspector note mentions a defect word. Three different shapes of question — and the sorted tree of the previous lesson is a good answer to only the first.

An index is any auxiliary structure that lets the engine avoid reading everything, and different structures make different trade-offs. Knowing the menu is what separates “add an index” from a diagnosis.

Hash indexes: equality only, and proud of it

A hash index applies a hash function to the key and stores the row pointer in the resulting bucket. Lookup is: hash the value, go to the bucket, scan its short list. There is no descent and no comparison chain — the cost is roughly constant per lookup regardless of table size, rather than growing with tree height.

The catch is total: hashing destroys order. Two adjacent keys land in unrelated buckets, so a hash index can answer = value and nothing else. No ranges, no >, no BETWEEN, no sorted output, no prefix matching, no help with ORDER BY.

planpostgres_hash.sql
-- PostgreSQL syntax. Equality lookups only.
CREATE INDEX idx_orders_customer_hash
    ON orders USING hash (customer_id);

-- This can use the hash index:
SELECT * FROM orders WHERE customer_id = 10;

-- These CANNOT -- hashing discards ordering entirely:
SELECT * FROM orders WHERE customer_id > 10;
SELECT * FROM orders ORDER BY customer_id;

Inference This is why hash indexes are rare in practice despite their theoretical edge. A B-tree serves equality perfectly well — three or four page reads — and serves ranges, sorting and prefix lookups. Choosing hash means giving up a great deal of capability for a modest gain on one access pattern, and you rarely know a column will only ever be compared for equality.

Bitmap indexes: the low-cardinality answer

Lesson 12.3 ended with a warning: a B-tree on a three-value column is nearly useless, because each key points at a third of the table. A bitmap index inverts the representation. For each distinct value it stores a bit vector, one bit per row, set where that row holds that value.

  orders.status, 8 illustrative rows

  row #        1   2   3   4   5   6   7   8
  status       C   C   R   C   X   C   C   R      C=completed R=returned X=cancelled

  bitmap 'completed'   1   1   0   1   0   1   1   0
  bitmap 'returned'    0   0   1   0   0   0   0   1
  bitmap 'cancelled'   0   0   0   0   1   0   0   0

  WHERE status = 'returned' AND ship_country = 'US'
       bitmap(returned)   0 0 1 0 0 0 0 1
   AND bitmap(US)         0 0 1 0 1 0 0 0
                          -----------------
       result             0 0 1 0 0 0 0 0   -> fetch row 3 only

Two properties fall out. Combining predicates is a bitwise AND or OR over vectors — extremely cheap — so several mediocre filters compose into one good one before a single table page is fetched. And storage is tiny for low-cardinality columns, because long runs of identical bits compress heavily.

Both reverse on high-cardinality columns: one bit vector per value, with millions of values, is a disaster. Updating a row also means flipping bits in several vectors, which historically required coarse locking — the reason bitmap indexes belong to read-mostly warehouses, not transactional systems.

Note

Distinguish a bitmap index (a stored structure, as in Oracle) from a bitmap scan (a runtime technique, as in PostgreSQL). PostgreSQL has no bitmap index type, but it will read a B-tree, build a bitmap of matching pages in memory, combine bitmaps from several indexes, then fetch pages once in physical order. Same arithmetic, built on demand rather than maintained on disk.

The teaching dataset shows the shape of the low-cardinality problem even at tiny scale — status has three values and a filter on it alone leaves most of the table:

sqllow_cardinality.sql
-- Two weak filters combined. Neither is selective alone;
-- together they identify a single row. This is exactly the
-- case bitmap combination is designed for.
SELECT status, ship_country, COUNT(*) AS orders
FROM orders
WHERE status = 'returned'
GROUP BY status, ship_country
ORDER BY ship_country;
Result
statusship_countryorders
returnedSG1
returnedUS1

GIN and inverted indexes: one row, many keys

Every index so far assumes one key per row. That breaks the moment a column holds a collection: an array of tags, a JSON document, a body of text. The question is not “which row equals this value” but “which rows contain this element”.

An inverted index — PostgreSQL calls its implementation GIN, Generalised Inverted Index — flips the mapping. Instead of row → keys, it stores key → list of rows. The document “bearing wear detected” contributes three entries, one per word, each pointing back at the row. Searching for “bearing” is then a single lookup returning the posting list.

planpostgres_gin.sql
-- PostgreSQL. Containment and full-text search over multi-valued columns.
CREATE INDEX idx_notes_fts
    ON inspections USING gin (to_tsvector('english', inspector_note));

CREATE INDEX idx_attrs_jsonb
    ON inspections USING gin (attributes jsonb_path_ops);

-- Now these become index lookups instead of full scans:
SELECT * FROM inspections
 WHERE to_tsvector('english', inspector_note) @@ to_tsquery('bearing');

SELECT * FROM inspections
 WHERE attributes @> '{"line": "A3"}';

The trade is write cost: inserting one row inserts into as many posting lists as it has distinct elements, so a long document is many index writes. That is why implementations buffer pending insertions and apply them in batches. Fact A GIN index can be substantially larger than the data it indexes when elements are numerous and rarely repeated.

Analogy

Three ways to find things in a building. A B-tree is the numbered corridor: rooms in order, so you can find room 412 and walk from 400 to 420. A hash is a cloakroom ticket: hand over number 47 and the coat appears instantly, but there is no way to ask for “all coats numbered 40 to 60” because the rail is not in order. A bitmap is a checklist by attribute — a column of ticks for “has a hood”, another for “is black” — so you find black hooded coats by reading two columns side by side. A GIN index is the book index at the back: one entry per word, listing every page it appears on, because a page contains many words rather than being “a word”.

The rest of the menu, briefly

  • BRIN (block range index). Stores only the min and max of each block range. Minute in size, effective only when physical row order correlates with the column — typically an append-only timestamp.
  • GiST / SP-GiST. Frameworks for data with no natural total order: geometric shapes, ranges, nearest-neighbour searches. What a “shops within 5km” query uses.
  • Zone maps. The columnar-warehouse equivalent of BRIN, maintained automatically by DuckDB, Snowflake and BigQuery. You do not create them; you enable them by loading data in a sensible order.
Common pitfall — picking the exotic structure first

Reading a list like this creates an urge to match each column to a clever structure. Resist it. The B-tree handles equality, ranges, sorting, prefix matching and uniqueness enforcement with one structure, and it is the one every optimiser understands best. Specialised indexes are answers to specific proven problems — you have measured that a containment query scans, or that a low-cardinality combination is unservable by trees. Reaching for GIN before you have a query plan showing a scan is optimisation by vocabulary, and you inherit the write cost immediately while the benefit remains hypothetical.

When not to use these — and when an index of any kind hurts

  • Hash: not unless the column is equality-only forever and you have measured that the tree descent matters. Any future range or ORDER BY forces a second index anyway.
  • Bitmap: never on a transactional table with concurrent writers, never on a high-cardinality column. The concurrency cost and storage explosion are both severe.
  • GIN: not on a write-heavy table whose searchable text is rarely queried — you pay per row and collect occasionally.
  • BRIN: useless the moment physical order stops correlating with the column. A bulk backfill inserting old dates at the end widens every block range to cover everything.
  • Any index, on a columnar engine: pruning happens through column projection, compression and zone maps, so secondary indexes often add maintenance for no plan change.
Interview angle

“When would you use a hash index instead of a B-tree?” is frequently a trap: the expected strong answer is “almost never, and here is why” — the B-tree's constant factor for equality is already small, and it also handles everything hashing cannot. Follow with the reverse framing that shows real judgement: the structure should be chosen from the predicate shape. Equality and ranges → B-tree. Containment or text → inverted. Several weak low-cardinality filters combined → bitmap. Physically clustered append-only data → BRIN or zone maps.

Exercise 12.4

Retail For each access pattern, name the structure and justify it in one sentence: (a) look up a product by exact SKU string; (b) find all sales in a region where the category is one of three values and the region is one of four; (c) find products whose free-text description contains “waterproof”; (d) find rows in a 5-billion-row append-only event table for last Tuesday.

Show solution

(a) B-tree. Equality on a high-cardinality key. A hash index would also serve it, but the B-tree costs only a few page reads and additionally supports prefix searches and sorted output you will inevitably want later.

(b) Bitmap (or a runtime bitmap scan over two B-trees). Neither predicate is selective alone — a quarter of rows and a third of rows — but the bitwise AND of two vectors identifies the intersection before any table page is read. Suitable because this is a read-mostly analytical table.

(c) Inverted / GIN. The column is multi-valued: one row contains many words. Ordered structures cannot answer containment because there is no single key to sort by.

(d) BRIN or zone maps. Append-only means physical order correlates with time, so per-block min/max ranges let the engine skip nearly every block for a one-day window — at a fraction of the storage and write cost a B-tree over five billion rows would demand.

sqltwo_weak_filters.sql
-- Pattern (b) in shape: two individually weak predicates
-- whose intersection is narrow.
SELECT region, category, SUM(units) AS units, SUM(revenue) AS revenue
FROM sales
WHERE category = 'Biscuits'
  AND region  IN ('North','South')
GROUP BY region, category
ORDER BY region;
Knowledge check

Why can a hash index not satisfy WHERE customer_id BETWEEN 100 AND 200?

Key takeaway

Match the structure to the predicate shape: B-tree for equality plus ranges plus ordering; hash for equality alone and little reason to prefer it; bitmap for combining several low-cardinality filters in read-mostly data; inverted/GIN when one row contains many searchable elements; BRIN or zone maps when physical order already correlates with the column. The B-tree remains the default because it is the only one that is good at nearly everything — and every alternative buys its speciality with capability, write cost, or both.

Lesson 12.5·15 min read

Index Design: Composite Keys, Covering Indexes & Selectivity

You will be able to decide which columns go in an index, in what order, and when to stop — using selectivity rather than intuition.

Supply chain A logistics platform has a shipments table with eleven indexes. Writes are the bottleneck, three indexes have never been used by any plan, and the query everyone complains about is still slow. Nobody added one carelessly — each was added during an incident, by someone reasonable, to fix a real query.

That is the normal end state of index-by-instinct. The discipline that prevents it rests on two ideas: selectivity, which decides whether an index is worth having, and column order, which decides whether one index can replace three.

Selectivity: the number that decides everything

Selectivity is the fraction of rows a predicate keeps. A predicate keeping 0.1% of rows is highly selective; one keeping 40% is not.

Why the fraction is decisive: using an index means fetching matching rows one at a time through pointers into scattered pages, while a sequential scan reads pages in physical order, which storage and read-ahead heavily favour. There is therefore a crossover above which random single-row fetching costs more than reading the lot. Inference The exact crossover depends on hardware and rows per page, but the direction is universal: as the matching fraction rises the index's advantage falls, and past some point inverts. An optimiser ignoring your new index is often correct, not broken.

sqlselectivity.sql
-- Measure selectivity directly: what fraction of the table
-- would each value of status keep?
SELECT status,
       COUNT(*) AS matching_rows,
       ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 3) AS selectivity
FROM orders
GROUP BY status
ORDER BY matching_rows DESC;
Result
statusmatching_rowsselectivity
completed210.84
returned20.08
cancelled20.08

Read that carefully, because it contains the trap. An index on status is worthless for status = 'completed', which keeps 84% of rows — you would fetch most of the table one row at a time. The very same index is useful for status = 'cancelled'. Selectivity is a property of the predicate, not of the column, and skewed distributions are the reason a query can be fast for one parameter value and slow for another with identical text.

Cardinality gives you the average case; the distribution gives you the truth:

sqlcardinality.sql
-- Distinct-value counts: the raw material of a selectivity estimate.
SELECT COUNT(*)                       AS total_rows,
       COUNT(DISTINCT customer_id)    AS distinct_customer,
       COUNT(DISTINCT ship_country)   AS distinct_ship_country,
       COUNT(DISTINCT status)         AS distinct_status
FROM orders;
Result
total_rowsdistinct_customerdistinct_ship_countrydistinct_status
2515143

Fifteen distinct customers over twenty-five rows averages under two rows each — excellent index material. Three statuses over twenty-five rows averages eight — poor. But averages lie under skew: that eight conceals one value holding twenty-one rows and two holding two apiece.

Composite indexes and the leftmost-prefix rule

A composite index sorts by the first column, then breaks ties with the second, then the third — exactly like sorting a spreadsheet by several columns. That single fact generates every rule about them.

  INDEX ON orders(customer_id, order_date)

  leaf order:
    (1, 2024-01-08) (1, 2024-03-12) (1, 2025-01-09)
    (3, 2024-02-09) (3, 2024-05-14)
    (5, 2024-03-09) (5, 2024-06-01) (5, 2024-09-16)
    (10,2024-06-04) (10,2024-07-11) (10,2024-10-22)

  WHERE customer_id = 5                      -> contiguous run. USABLE.
  WHERE customer_id = 5 AND order_date > ...  -> run within a run. USABLE.
  WHERE customer_id BETWEEN 3 AND 5          -> contiguous run. USABLE.
  WHERE order_date = '2024-06-01'            -> scattered across all
                                                customers. NOT USABLE.

The rule: an index can serve a predicate only on a leftmost prefix of its columns. (customer_id, order_date) serves queries on customer_id, and on customer_id plus order_date — but not on order_date alone, because dates for different customers are not adjacent in the leaves.

Two practical consequences. First, a composite index makes a separate index on its leading column redundant — delete it. Second, ordering matters and is not arbitrary:

  • Equality predicates before range predicates. After a range column, later columns are only sorted within each value, so they can no longer narrow the scan — only filter rows already fetched.
  • Then most selective first among the equality columns — a modest gain, since any leading equality column already reduces the scan to a contiguous run.
  • Then the column you want sorted, so an ORDER BY can be satisfied by index order and skip a sort.
planpostgres_index_design.sql
-- PostgreSQL syntax throughout.

-- Equality column first, range column second.
CREATE INDEX idx_orders_cust_date
    ON orders (customer_id, order_date);

-- COVERING: extra payload columns stored in the leaves but not
-- part of the sort key, so the plan never touches the table.
CREATE INDEX idx_orders_cust_date_incl
    ON orders (customer_id, order_date)
    INCLUDE (status, ship_country);

-- PARTIAL: index only the rows anyone actually queries.
CREATE INDEX idx_orders_open
    ON orders (order_date)
 WHERE status <> 'completed';

-- Enforcing a rule, not speeding a query.
CREATE UNIQUE INDEX idx_customers_email
    ON customers (email)
 WHERE email IS NOT NULL;

Covering indexes: skipping the table

A normal index lookup has two phases: find the key, then follow the pointer to the table for the columns you asked for. That second phase is a random page read per row and frequently dominates.

If the index already contains every column the query mentions, the second phase is unnecessary and the plan becomes index-only. Two ways there: put the columns in the key, or — better — attach them as non-key payload with INCLUDE. Payload columns live only in the leaves and do not participate in sorting, so they widen the leaves without reducing fanout in the internal levels or constraining column order.

Analogy

A phone directory sorted by surname then first name. Looking up “Osei, Amara” is instant — that is the leftmost prefix working for you. Looking up everyone called “Amara” regardless of surname means reading the whole book, because first names are scattered. Now: if the directory prints the phone number beside each name, you have your answer without going anywhere — that is a covering index. If it printed only names and you had to visit each house to ask for the number, that is the table fetch you are trying to avoid.

Common pitfall — one index per column

Faced with WHERE customer_id = ? AND order_date > ?, the instinct is to create an index on each column. The engine then either picks one and filters the rest by hand, or combines them via a bitmap intersection — both weaker than a single (customer_id, order_date) index that resolves both predicates in one contiguous run. You have also paid twice on every write. The counter-pitfall is just as costly: bolting every column of the query onto one enormous composite key. Wide keys shrink fanout, deepen the tree, and only ever serve prefixes of themselves. Aim at the query's predicate shape, not at its column list.

When adding an index is the wrong answer

Index design is subtraction as much as addition. Do not add one when:

  • The predicate is not selective for the values people actually pass. Indexing a status column whose common value covers most of the table produces a structure the optimiser rationally ignores — while you pay on every write.
  • An existing index already has it as a leftmost prefix. Adding (customer_id) when (customer_id, order_date) exists buys nothing.
  • The table is write-dominated. Ingest tables pay maintenance per row continuously for occasional query benefit. Sometimes the honest answer is to let the query scan, or serve it from a separate copy.
  • The query is genuinely analytical. A report aggregating a year must read a year. Indexes accelerate finding few rows among many; partitioning, pre-aggregation or a columnar store is the right lever here.
  • The real fix is the query. A function around the column, an implicit cast, or SELECT * will each defeat an index that would otherwise work.
In the field

Review indexes on a schedule, not only during incidents. Most engines expose usage counters showing how often each index has been chosen by a plan; anything near zero after a full business cycle is a pure write tax. Drop them one at a time, keeping the definition so it can be recreated quickly. The most valuable output of such a review is rarely a new index — it is deleting four and consolidating three into one composite.

Interview angle

“You have WHERE tenant_id = ? AND created_at >= ? ORDER BY created_at. Design the index.” The expected answer is (tenant_id, created_at), and you should say all three reasons out loud: tenant_id is an equality predicate so it leads; created_at is a range so it follows; and because the leaves are already sorted by created_at within each tenant, the ORDER BY is satisfied without a sort. If they ask how to improve it further, propose INCLUDE-ing the selected columns to make the plan index-only — and mention that you would check selectivity of tenant_id first, because on a single-tenant-dominated table the leading column stops discriminating.

Exercise 12.5

Advertising A campaign reporting table is queried as WHERE channel = ? AND spend_date BETWEEN ? AND ?, returning campaign, cost, clicks, sorted by spend_date. (a) Give the composite index and justify the order. (b) Say what to add to make the plan index-only. (c) Explain why an index on (spend_date, channel) would be worse. Then measure the selectivity of channel on ad_spend to check the design is worth it.

Show solution

(a) (channel, spend_date). channel is an equality predicate, so leading with it reduces the scan to one contiguous run; spend_date is a range, and within that run the dates are sorted, so the range is a sub-run of it. Reversing them would break this.

(b) INCLUDE (campaign, cost, clicks). With those columns in the leaves, the plan answers entirely from the index and never performs a random table fetch per row.

(c) With (spend_date, channel) the leading column carries a range. Everything after a range column is only sorted within each individual date, so channel can no longer narrow the scan — the engine must walk every entry in the whole date window and discard non-matching channels one by one. The rule “equality columns before range columns” is exactly this observation.

sqlchannel_selectivity.sql
-- Is channel selective enough to lead the index?
SELECT channel,
       COUNT(*) AS rows_matching,
       ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 3) AS selectivity
FROM ad_spend
GROUP BY channel
ORDER BY rows_matching DESC;

Two channels splitting the table evenly means a channel predicate keeps about half the rows — on its own, far too weak to justify an index. It earns its place here only as the leading equality column of a composite, where its job is not to be selective by itself but to bound the region the date range then narrows further. That combination, not either column alone, is what makes the index worth its write cost.

Knowledge check

You have an index on orders(customer_id, order_date). Which query can it not serve efficiently?

Knowledge check

A team adds an index on a status column where 84% of rows are 'completed'. Plans for status = 'completed' still show a sequential scan. What is happening?

Key takeaway

Design from the predicate, not the column list. Check selectivity for the values actually passed, put equality columns before range columns, remember that an index only serves a leftmost prefix of its key, and use INCLUDE to make hot queries index-only without widening the sort key. Then do the harder half of the job: delete the indexes nothing uses, because every one of them is charged to every write forever.

Lesson 12.6·14 min read

Table Statistics & Cardinality Estimation

Learn what the optimiser actually knows about your data, why it is usually a summary rather than the truth, and how one wrong row-count guess turns a two-second query into a two-hour one.

Telecommunications A network operator runs a nightly report joining call detail records to subscriber accounts to handset models. It has run in four minutes every night for a year. One morning it takes six hours. Nobody changed the SQL. Nobody changed the schema. The data grew by the usual half a percent.

What changed is the plan. The optimiser saw the same query, formed a different belief about how many rows would survive the filters, and on that basis chose a different join order. The query describes a result; the plan is a strategy for producing it; and the bridge between them is a guess about row counts. That guess is called cardinality estimation, and it is the largest single source of catastrophic plan regressions in every engine in production use. This lesson is about what it is made of.

Why the engine has to guess at all

To join three tables the planner must pick an order: A to B first, then the result to C — or B to C first, then A. The intermediate result is materialised in memory, or spilled to disk if it does not fit. If A⋈B produces a hundred rows and B⋈C produces a hundred million, the two orders differ enormously in cost while returning exactly the same answer.

To choose, the planner must know how big each intermediate is before running anything. It cannot execute the join to find out — that is the work it is trying to plan. So it estimates, from a compact statistical summary computed at some earlier moment. Fact Every cost-based optimiser in wide use — PostgreSQL, SQL Server, Oracle, Snowflake, BigQuery, Spark, DuckDB — works this way. They differ in what statistics they keep, not in whether they guess.

What is actually in the statistics

Four things carry almost all the weight. Each is a small summary standing in for a large column.

  • Row count (and physical size in pages or blocks). The starting point for every estimate downstream.
  • n_distinct — the number of distinct values. Given col = 'x' and nothing else, the planner assumes every distinct value is equally common and estimates rows / n_distinct. That assumption is called uniformity, and it is wrong for almost every real column.
  • Most common values (MCVs) — a short list of the most frequent values with their exact frequencies, existing precisely to patch uniformity at the top end. If status is 84% 'completed', the MCV list says so.
  • A histogram over the remaining values — buckets each covering a range and each holding roughly the same number of rows, narrow where data is dense and wide where it is sparse. This is what estimates a range predicate such as order_date >= DATE '2024-06-01'.

A fifth matters more than its obscurity suggests: correlation, the degree to which a column's logical order matches its physical order on disk. Sorted on disk, correlation is near 1 and an index range scan reads a few contiguous blocks; stored randomly, correlation is near 0 and the same scan touches nearly every block. Same index, same selectivity, wildly different cost.

Analogy

Statistics are the blurb on the back of a book. Two hundred words standing in for four hundred pages. It is enough to decide whether to read the book, which is exactly the decision the planner is making — and it is nowhere near enough to answer a question about chapter nine. Trouble starts when the blurb is out of date, describing the first edition while you are holding the third.

Computing selectivity by hand

Selectivity is the fraction of rows a predicate keeps, between 0 and 1. Cardinality is that fraction times the row count. Everything the planner does with statistics reduces to these two quantities, and you can compute them exactly — a good way to see what the approximation is approximating.

sqlselectivity_by_hand.sql
-- The raw ingredients of a cardinality estimate, computed exactly.
SELECT COUNT(*)                                            AS row_count,
       COUNT(DISTINCT status)                              AS ndv_status,
       COUNT(DISTINCT ship_country)                        AS ndv_ship_country,
       COUNT(*) FILTER (WHERE status = 'completed')        AS n_completed,
       ROUND(COUNT(*) FILTER (WHERE status = 'completed') * 1.0
             / NULLIF(COUNT(*), 0), 4)                     AS sel_completed
FROM orders;
Result
row_countndv_statusndv_ship_countryn_completedsel_completed
25314210.8400

Now watch uniformity fail. There are three distinct statuses, so a planner with only n_distinct estimates status = 'completed' at 1/3 ≈ 0.333, about 8 rows. The true selectivity is 0.84, about 21 rows — a two-and-a-half-fold underestimate from a reasonable-sounding rule. An MCV list fixes this case because 'completed' is common enough to be in it; long-tail values get no such help.

sqlmcv_list.sql
-- What a most-common-values list stores: value, frequency, fraction.
SELECT status,
       COUNT(*)                                         AS freq,
       ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 4) AS frac
FROM orders
GROUP BY status
ORDER BY freq DESC;
Result
statusfreqfrac
completed210.8400
returned20.0800
cancelled20.0800

Correlated predicates: where estimates really break

Give the planner two predicates on one table and it must combine two selectivities. The default rule is multiplication: sel(A AND B) = sel(A) × sel(B). That is exactly correct when the columns are statistically independent — and it is why estimation collapses in practice, because real columns almost never are. Country and currency. Postcode and city. Handset model and operating system.

sqlindependence_vs_reality.sql
-- Independence assumption vs. the truth, for two predicate pairs.
WITH s AS (SELECT COUNT(*) AS n FROM orders)
SELECT p.pair,
       s.n                               AS row_count,
       ROUND(s.n * p.sel_a * p.sel_b, 3) AS independent_estimate,
       p.actual_rows
FROM s
CROSS JOIN (
  SELECT 'status=cancelled AND ship_country=MX' AS pair,
         COUNT(*) FILTER (WHERE status = 'cancelled')  * 1.0 / COUNT(*) AS sel_a,
         COUNT(*) FILTER (WHERE ship_country = 'MX')   * 1.0 / COUNT(*) AS sel_b,
         COUNT(*) FILTER (WHERE status = 'cancelled'
                            AND ship_country = 'MX')        AS actual_rows
  FROM orders
  UNION ALL
  SELECT 'status=completed AND ship_country=SE',
         COUNT(*) FILTER (WHERE status = 'completed')  * 1.0 / COUNT(*),
         COUNT(*) FILTER (WHERE ship_country = 'SE')   * 1.0 / COUNT(*),
         COUNT(*) FILTER (WHERE status = 'completed'
                            AND ship_country = 'SE')
  FROM orders
) AS p
ORDER BY p.pair;
Result
pairrow_countindependent_estimateactual_rows
status=cancelled AND ship_country=MX250.0801
status=completed AND ship_country=SE252.5203

The second pair is close, because those conditions really are near-independent here. The first is off by more than tenfold in the direction that hurts most: the estimate says “essentially no rows” when there is a row. Inference Under-estimates are far more dangerous than over-estimates. An over-estimate buys a robust strategy for work that turns out small — you waste a little. An under-estimate buys a fragile one — a nested loop over a believed single row, a hash table sized for a handful of entries — and when reality arrives it does not degrade gracefully. It spills, or it loops millions of times.

How one bad estimate cascades

Error does not stay put. One operator's estimated output is the next operator's estimated input, so error compounds multiplicatively up the tree. A three-way join whose first estimate is 10× low and whose second is 10× low reaches the top believing it has a hundredth of the rows it really has.

  believed          actual
  --------          ------
  scan A     100      100     stats fine here
  A join B    50    5,000     correlated predicate: 100x low
  (A B) C     10  500,000     error inherited, then multiplied
     |
     +--> planner sees "10 rows" and picks a nested loop
          with an unindexed inner side. 500,000 probes later,
          the query is still running.

This is why a plan regression feels so abrupt. Nothing degrades by twenty percent. The plan is either on the right side of a decision boundary or on the wrong side of it, and a small drift in statistics can flip it.

planpg_stats.txt
-- PostgreSQL: refresh statistics, then read them back.
ANALYZE orders;

SELECT attname, n_distinct, most_common_vals, most_common_freqs, correlation
FROM pg_stats
WHERE tablename = 'orders';

 attname      | n_distinct | most_common_vals              | most_common_freqs      | correlation
--------------+------------+-------------------------------+------------------------+-------------
 status       |          3 | {completed,returned,cancelled}| {0.84,0.08,0.08}       |       0.61
 ship_country |         14 | {GB,SE,US,TR}                 | {0.12,0.12,0.12,0.12}  |       0.09
 order_date   |         -1 |                               |                        |       0.97

-- n_distinct = -1 means "distinct values scale with row count" (a key-like column).
-- correlation near 1.0 means physical order matches logical order: range scans are cheap.
Common pitfall — stale statistics after a bulk load

The classic production incident: a nightly job truncates and reloads a large table, downstream queries start immediately, and statistics have not been recomputed. The planner still believes the row count from before the truncate — or worse, believes the table is empty, which is what a freshly created one looks like — and plans as though a fifty-million-row table were a scratch table of ten rows. Auto-analyze daemons are threshold-driven and asynchronous; they have not necessarily fired by the time your query arrives. If a pipeline step rewrites a table wholesale, make the explicit ANALYZE the next step in the same job, not a hope.

When statistics are not the answer

There is a reflex among engineers who have just learned this material: every slow query gets an ANALYZE and a request for more histogram buckets. Resist it. Better statistics are the wrong tool when:

  • The plan is already optimal and the query is simply large. Aggregating a billion rows with no filter has one sensible plan and the engine already picked it. The fix is less data — pre-aggregation, partition pruning — not better guesses.
  • The predicate is fundamentally un-estimable. A filter on lower(trim(col)) LIKE '%foo%', or on a user-defined function, gives the planner nothing to work with and most engines fall back to a fixed default. Rewrite the predicate, or store a computed column, so there is something to estimate.
  • Collection cost outweighs the benefit. On very wide, very large tables a full-fidelity collection is real work. Doing it hourly on a table queried twice a day is a poor trade.
  • The engine does not use them the way you think. Some columnar engines lean far more on per-block min/max metadata (Lesson 12.10) than on classical histograms.

Open Whether adaptive estimation — observing actual row counts at runtime and feeding them back — will displace histograms in mainstream engines is unsettled. Several already re-plan mid-flight when reality diverges sharply from the estimate; how far that generalises is not yet clear.

Interview angle

“The query was fast yesterday and slow today, with no code change. What happened?” A weak answer is “the data grew”. A strong one names the mechanism: the plan changed because the estimates changed — stale statistics, or a threshold crossed that flipped a join-order decision. Volunteer the follow-up: compare estimated against actual rows per operator (EXPLAIN ANALYZE, or SET STATISTICS PROFILE in SQL Server), find the lowest operator where the ratio blows up, and fix there — everything above it is inherited error, not the cause.

Exercise 12.6

Telecommunications You suspect the planner is badly under-estimating a filter on customer acquisition channel combined with activity status. Using customers, compute for the pair channel = 'organic' AND is_active = FALSE: the individual selectivities, the cardinality an independence-assuming planner would predict, and the true row count. Report the ratio of actual to estimated.

Show solution
sqlexercise_12_6.sql
SELECT COUNT(*)                                                      AS row_count,
       ROUND(COUNT(*) FILTER (WHERE channel = 'organic') * 1.0
             / NULLIF(COUNT(*), 0), 4)                               AS sel_channel,
       ROUND(COUNT(*) FILTER (WHERE is_active = FALSE) * 1.0
             / NULLIF(COUNT(*), 0), 4)                               AS sel_inactive,
       ROUND(COUNT(*)
             * (COUNT(*) FILTER (WHERE channel = 'organic') * 1.0 / COUNT(*))
             * (COUNT(*) FILTER (WHERE is_active = FALSE)  * 1.0 / COUNT(*)), 3)
                                                                     AS independent_estimate,
       COUNT(*) FILTER (WHERE channel = 'organic'
                          AND is_active = FALSE)                     AS actual_rows
FROM customers;

The pattern generalises: express each selectivity as a FILTERed count over the total, multiply for the independence estimate, and compare against the count with both predicates applied together. A ratio far from 1 in either direction means the columns are correlated and the planner's default arithmetic will mislead it. The remedy is engine-specific — PostgreSQL's CREATE STATISTICS ... (dependencies, mcv) over a column group, SQL Server's multi-column statistics — but the diagnosis is always this comparison.

Knowledge check

A planner estimates 8 rows from a filter that actually returns 21. Which stored statistic would most directly fix this?

Key takeaway

The optimiser does not know your data; it knows a summary of it — row counts, distinct counts, most-common values, histograms, correlation — and it combines predicate selectivities by assuming independence. Correlated columns break that assumption, and the resulting error is inherited and multiplied up the plan tree. When a plan regresses without a code change, compare estimated against actual rows per operator and fix the lowest operator where they diverge.

Lesson 12.7·15 min read

Join Algorithms: Nested Loop, Hash Join, Merge Join

Understand the three ways a database physically matches rows, what each one costs, and why the engine's choice between them explains almost every surprising join performance you will ever see.

JOIN is a logical operation: for each pair of rows satisfying a condition, emit a combined row. It says nothing about how. There are three physical strategies in general use, and every mainstream engine implements all three, because each one wins decisively in a different regime.

Banking A fraud team runs two queries against the same two tables. One asks “show me the account details for these three flagged transactions”. The other asks “summarise every transaction this quarter by account type”. Same join, same columns, same foreign key. If the engine used one strategy for both, one of them would be badly served. Understanding which is which is the whole lesson.

Nested loop join

The simplest and oldest. Take the outer input; for each of its rows, search the inner input for matches; emit the pairs. Written as pseudocode it is two loops, hence the name.

Its cost is the number of outer rows multiplied by the cost of one inner lookup, and that product is the whole story. If the inner lookup is a full scan of a large table, the cost is quadratic-ish and disastrous. If it is an index seek on a unique key, it is a handful of block reads and the total scales linearly with the outer side alone.

So nested loop wins in exactly one regime, but wins emphatically there: a tiny outer input and an indexed inner side. Three flagged transactions, an index on account_id, three seeks, done. No hash table, no sort, and the first result row appears immediately — which matters for interactive queries and anything with a LIMIT.

sqltiny_outer.sql
-- The nested-loop regime: the outer side is filtered down to almost nothing
-- before the join, so only a couple of inner lookups are ever needed.
SELECT o.order_id, o.order_date, c.full_name
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.order_id IN (1001, 1025)
ORDER BY o.order_id;
Result
order_idorder_datefull_name
10012024-01-08Amara Osei
10252025-01-09Amara Osei

Fact Nested loop is also the only one of the three that can evaluate an arbitrary join condition. Hash and merge joins both require an equality predicate — they work by grouping equal keys together. A join on a.x BETWEEN b.lo AND b.hi, or on a geometry intersection, has no equality to hash on, so the engine falls back to a loop unless it has a specialised operator. That is why inequality joins are so often slow: not because the syntax is exotic, but because it forecloses two of the three algorithms.

Hash join: build and probe

The workhorse of analytics. It runs in two phases.

Build phase. Choose the smaller input (the build side). Read it once, entirely, and for each row compute a hash of the join key. Store the row in an in-memory hash table bucketed by that hash. Nothing is emitted during this phase — the operator is blocking until the build completes.

Probe phase. Stream the larger input (the probe side) past the hash table. For each row, hash the join key, jump straight to the corresponding bucket, compare the actual key values there (hashes collide; the comparison is still required), and emit a combined row for each match.

  BUILD SIDE (smaller)              PROBE SIDE (larger, streamed)
  customers                          orders
  +-------------+                    +---------------------+
  | 1 Amara  GB |                    | 1001  cust 1        |
  | 2 Bruno  BR |                    | 1003  cust 2        |
  | 3 Chen   SG |                    | 1004  cust 3        |
  +------+------+                    | 1015  cust 10       |
         |                           +----------+----------+
         | hash(customer_id)                    | hash(customer_id)
         v                                      v
     +---------------- HASH TABLE ----------------+
     | bucket 0 : [2 Bruno BR]                    |
     | bucket 1 : [1 Amara GB] [3 Chen SG]        |  <-- collision:
     | bucket 2 : [10 Jolene US]                  |      keys compared
     +--------------------------------------------+          exactly
                          |
                          v  emit (order row + matched customer row)

  Phase 1 BUILD : read small side fully, nothing emitted yet (blocking)
  Phase 2 PROBE : stream large side, one bucket lookup per row

Two properties follow directly from that structure. First, each input is read exactly once, so the cost is roughly linear in the sum of the two sizes — which is why hash join dominates when both sides are large. Second, the build side must fit in memory. If it does not, the engine falls back to a grace or partitioned hash join: hash both sides into partitions written to disk, then join partition-by-partition. That still works, but it writes and re-reads both inputs, and this is what people mean when they say a join “spilled”.

Notice the connection to Lesson 12.6. The engine picks the build side based on its estimated cardinality. Under-estimate the build side and it sizes a hash table far too small, then spills — an entirely avoidable disk round-trip caused by a statistics problem, not a join problem.

sqlexplain_hash_join.sql
-- Ask the engine which physical operator it chose. DuckDB, being a
-- columnar analytical engine, will pick a hash join here.
EXPLAIN
SELECT c.country, COUNT(*) AS orders
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
GROUP BY c.country;
planplan_excerpt.txt
HASH_GROUP_BY
  Groups: #0
  Aggregates: count_star()
    |
  HASH_JOIN
    Join Type: INNER
    Conditions: customer_id = customer_id
      |                              |
  SEQ_SCAN customers            SEQ_SCAN orders
    Projections: customer_id      Filters: status='completed'
                 country
Analogy

Three ways to match a room of guests to their name badges. Nested loop: for each guest, walk the whole badge table looking for theirs — fine for three guests, agony for three hundred. Hash join: first lay all the badges out on tables sorted into pigeonholes by first letter (the build), then let guests walk past and go straight to their pigeonhole (the probe). You need a big enough table to lay everything out. Merge join: line the guests up alphabetically and the badges alphabetically, then walk both queues in step. No table needed at all — but somebody had to do the alphabetising.

Merge join: the sorted special case

A merge join requires both inputs to be sorted on the join key. Given that, it advances two cursors in lockstep: whichever side holds the smaller key advances; when keys match, emit and handle duplicates. Each input is read once, sequentially, and — this is the important part — no hash table is built and memory use is bounded, independent of input size.

That makes merge join the strategy of choice for enormous joins on sorted or indexed data. It also produces its output already sorted on the join key, which the planner will happily reuse to satisfy a downstream ORDER BY or a merge-based aggregation, eliminating a sort you would otherwise pay for.

The catch is the requirement. If the inputs are not already sorted, the engine must sort them, and a sort is blocking, memory-hungry and superlinear. A planner will generally prefer a hash join to “sort both sides then merge” unless the sort is free — because an index already provides the order, or because a previous operator produced it, or because the data was physically clustered on that key (Lesson 12.10).

 Nested loopHash joinMerge join
Join conditionAny predicateEquality onlyEquality only
Wins whenTiny outer, indexed innerBoth sides large, no useful orderBoth sides already sorted on the key
Extra memoryNegligibleBuild side must fit, else spillsBounded, size-independent
First row emittedImmediatelyAfter the build completesAfter sorting, if a sort is required
Output orderingFollows the outer sideNone guaranteedSorted on the join key
Chief failure modeOuter side larger than estimatedBuild side spills to diskInputs turn out to need a sort
Common pitfall — a nested loop chosen on a bad estimate

The most common catastrophic join plan in the world: the planner estimates the outer input at a handful of rows, chooses a nested loop with an unindexed inner side, and the outer input actually returns hundreds of thousands. The plan is not slightly worse — it performs hundreds of thousands of full inner scans. The symptom is distinctive: a plan that looks clean and cheap on paper, and a query that runs for hours with low disk throughput and one CPU pinned. When you see a nested loop over anything you cannot personally count, check the estimate against the actual immediately.

When not to reach for a specific algorithm

Every engine offers some way to force a choice — hints in Oracle and SQL Server, enable_hashjoin = off in PostgreSQL, hint syntax in Spark. Using them should be rare, and here is when it is the wrong move:

  • When the estimate is the real problem. Forcing a hash join because a nested loop was disastrous treats the symptom. The planner chose the loop because it believed a false row count; fix the statistics or the predicate and the correct choice follows automatically — and keeps following it as the data changes.
  • When the data distribution will shift. A hint is a decision frozen at the moment you wrote it. The plan that suits a table of one million rows may be exactly wrong at fifty million. Hints do not age; your data does.
  • When you have not confirmed the join is the bottleneck. Plenty of “slow joins” are slow scans, slow sorts, or a spilling aggregation above the join. Read the plan's actual timings before optimising the operator you assumed was guilty.
  • On an engine that does not really offer the choice. Several columnar and distributed engines implement essentially only hash joins for equality predicates. Reasoning about merge joins there is wasted effort; the real levers are data layout and join order.

The legitimate uses of a hint are narrow: a known-good plan you need to stabilise for a critical query while you fix the underlying estimate, or a case where you hold information the optimiser structurally cannot — such as a parameter value distribution it never sees.

Interview angle

“When would a nested loop beat a hash join?” is a standard filter question. The answer must contain both conditions — small outer input and an indexed inner side — because either alone is insufficient. Strong candidates add the two structural points: nested loop is the only algorithm that handles non-equality predicates, and it returns the first row without a blocking build, which matters under LIMIT. A common follow-up is “what makes a hash join spill, and how would you tell?” — answer: the build side exceeded the memory budget, usually because it was under-estimated; you tell from temp-file or spill counters in the profiled plan.

Exercise 12.7

Banking For a join between order_items and products, work out which side a hash join would use as the build side, and confirm your reasoning by asking the engine for its plan. State in one sentence why that side was chosen.

Show solution
sqlexercise_12_7.sql
-- First, the sizes the planner is comparing.
SELECT 'products' AS side, COUNT(*) AS row_count FROM products
UNION ALL
SELECT 'order_items', COUNT(*) FROM order_items
ORDER BY row_count;

-- Then the plan itself.
EXPLAIN
SELECT p.product_name, SUM(oi.quantity) AS units
FROM order_items AS oi
JOIN products AS p ON p.product_id = oi.product_id
GROUP BY p.product_name;

products is the smaller relation, so it becomes the build side: the engine reads it fully into a hash table keyed on product_id, then streams order_items past it as the probe side. The rule is always “build the smaller side”, because build-side memory is the scarce resource and the probe side merely streams. Note that “smaller” means estimated rows after filters are applied, not the raw table size — a heavily filtered large table can legitimately become the build side.

Knowledge check

A query joins on a.event_time BETWEEN b.window_start AND b.window_end and is extremely slow. What is the structural reason?

Key takeaway

Three algorithms, three regimes. Nested loop: tiny outer plus indexed inner, the only one that handles non-equality predicates, first row out immediately. Hash join: build the smaller side into memory, probe with the larger, linear in both — the analytics default, and it spills when the build side was under-estimated. Merge join: needs both sides sorted on the key, uses bounded memory regardless of size, and hands you sorted output for free. When a join misbehaves, name the algorithm first; the fix follows from which one is running and why it was chosen.

Lesson 12.8·14 min read

Broadcast vs Shuffle Joins in Distributed Engines

See what changes when a join runs across a cluster rather than inside one process — and why the deciding question stops being “which algorithm” and becomes “which rows have to move”.

On a single machine, a join's cost is dominated by reading data and building structures. On a cluster, a third cost appears and usually swamps both: moving rows between machines over the network. Two rows can only be joined if they are in the same process at the same time, and if they start on different workers, somebody has to travel.

Digital media A streaming platform stores billions of view events spread across a hundred workers, and a few thousand content titles. Joining views to titles to attribute watch-minutes is conceptually trivial and can take either four minutes or four hours depending entirely on which of the two data movement strategies the engine picks.

Data starts partitioned, and rarely partitioned usefully

A distributed table is split into partitions, each held by a worker. The split is usually by file, or by a hash of some column chosen when the data was written — almost never by the column you happen to be joining on today. So at the start of a join, worker 7 holds view events for arbitrary content ids, and the title rows for those ids are somewhere else entirely.

There are exactly two ways out of this, and every distributed join is one of them.

Broadcast join: send the small side everywhere

If one side is small enough, copy the whole of it to every worker. Each worker then holds a complete copy of the small side plus its own slice of the large side, and performs an ordinary local hash join — build the small side, probe with the local slice. The large side never moves at all.

  titles (small)                      views (huge, already spread out)
  +-----------+
  | 6 rows    |----+---------------------------------------+
  +-----------+    |                 |                     |
                   v                 v                     v
              +---------+       +---------+           +---------+
              | worker1 |       | worker2 |    ...    | worker99|
              | titles  |       | titles  |           | titles  |  <- full copy
              | views/99|       | views/99|           | views/99|
              +---------+       +---------+           +---------+
                   |                 |                     |
                   +--- local hash join on each worker -----+

  Network cost: size(titles) x number_of_workers
  The huge side does not move. That is the entire point.

The network cost is the small side's size multiplied by the worker count. That is only a good trade while the small side really is small: broadcasting a two-gigabyte table to two hundred workers means moving four hundred gigabytes and holding two gigabytes of build-side memory in every executor simultaneously. Fact Engines therefore apply a size threshold — Spark's spark.sql.autoBroadcastJoinThreshold is the well-known example — and broadcast only below it. The threshold is compared against an estimate, which brings Lesson 12.6 straight back into play: an under-estimated side gets broadcast when it should not have been, and workers run out of memory.

sqlwhich_side_is_small.sql
-- The question the planner is answering: is either side small enough
-- that copying it everywhere beats moving both sides?
SELECT 'categories'  AS side, COUNT(*) AS row_count FROM categories
UNION ALL SELECT 'customers',   COUNT(*) FROM customers
UNION ALL SELECT 'order_items', COUNT(*) FROM order_items
ORDER BY row_count;
Result
siderow_count
categories6
customers17
order_items30
sparkbroadcast_hint.sql
-- Spark SQL: force the small dimension to be broadcast rather than shuffled.
SELECT /*+ BROADCAST(c) */
       c.department,
       SUM(v.minutes) AS watch_minutes
FROM   fact_views  v
JOIN   dim_content c ON c.content_id = v.content_id
GROUP BY c.department;

-- The DataFrame equivalent:
--   fact_views.join(broadcast(dim_content), "content_id")
--
-- Only hint this when you KNOW the side is small. A BROADCAST hint on a
-- large relation asks every executor to hold a full copy of it.
Analogy

A hundred clerks each hold a pile of receipts, and you need every receipt matched to a price list. Broadcast: photocopy the two-page price list a hundred times and hand one to each clerk. Nobody moves; everyone works in parallel. Shuffle: instead, agree that the clerk by the window takes every receipt for items A–F, the clerk by the door takes G–L, and so on — then everybody carries their receipts across the room to the right desk. Correct for any size of price list, but the room fills with people carrying paper. And if 80% of receipts are for item “C”, one clerk gets buried while the rest sit idle.

Shuffle join: repartition both sides by the join key

When neither side is small, both must move. The engine picks a number of partitions, applies the same hash function to the join key on both sides, and routes every row to the worker responsible for its hash bucket. After the shuffle, all rows sharing a join key — from both tables — sit on the same worker, which then performs a local hash join.

This is correct at any scale, which is why it is the fallback. It is also expensive in a specific way: every row of both inputs is serialised, written to a shuffle buffer, sent over the network, and deserialised. The shuffle is a full barrier — no worker can begin its local join until it has received everything destined for it, so a single slow worker stalls the whole stage.

Skew: how one hot key ruins a shuffle

A shuffle assumes work divides roughly evenly across partitions. That assumption fails whenever the join key has a heavily skewed distribution — and real keys are almost always skewed. A single mega-customer. A default unknown id used for unattributed traffic. And the classic: NULL, which hashes to one value and therefore lands entirely on one worker.

The consequence is precise. All rows with the hot key go to one partition; that worker must process them alone; the stage cannot finish until it does. Ninety-nine workers finish quickly and idle while one grinds through a disproportionate share — and if its share exceeds its memory, it spills, and then it may fail outright. The stage duration is set by the slowest partition, not the average.

sqldetect_skew.sql
-- Skew detection is just a histogram of the join key. Here we simulate
-- four shuffle partitions with a modulo of the key and see how evenly
-- rows would land.
SELECT product_id % 4                                    AS shuffle_partition,
       COUNT(*)                                          AS rows_landing_here,
       ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 4)  AS share
FROM order_items
GROUP BY 1
ORDER BY rows_landing_here DESC;
Result
shuffle_partitionrows_landing_hereshare
1100.3333
070.2333
270.2333
360.2000

An even split would be 25% each; the busiest partition here holds a third. On a toy table that is noise, but the diagnostic is exactly the one you run at scale: group by the join key (or by a simulated partition of it), sort descending, and look at the top. If the top key holds a large share of all rows, you have skew and no amount of adding workers will help — extra workers cannot subdivide a single key's partition.

The standard remedies, in increasing order of effort:

  1. Filter the junk key. If the hot key is NULL or a placeholder that will never match anything, exclude it before the join. Often this alone resolves the problem, because the hot key was never real data.
  2. Let adaptive execution split it. Modern engines can detect an oversized partition at runtime and divide it across several tasks. Spark's adaptive query execution does this; it costs nothing to enable and handles the common cases.
  3. Salting. Append a small random integer to the hot key on the large side, and explode the matching rows on the small side across every salt value. The single hot partition becomes several. It works, it is fiddly, and it changes your SQL — reach for it last.
Common pitfall — adding workers to a skewed job

A job takes two hours; the team doubles the cluster; it still takes two hours, now at twice the cost. The reason is that the duration was set by one partition holding one hot key, and partitions are indivisible units of work under a plain hash shuffle. Doubling the workers halves the average partition and does nothing at all to the largest one. The tell is in the stage's task timings: if the median task finishes in seconds and the maximum runs for an hour, the problem is distribution, not capacity. Read the task-duration spread before you read the invoice.

When not to broadcast

Broadcast is the strategy people over-apply, because it is dramatic when it works. It is the wrong choice when:

  • The “small” side is only small before filtering, or only small today. A dimension that fits comfortably now will cross the threshold eventually. A hard-coded BROADCAST hint has no way to notice, and the failure mode is executor memory exhaustion across the whole cluster at once rather than a gradual slowdown.
  • The small side is a computed subquery. Its size is an estimate, and estimates on derived relations are far less reliable than on base tables. Broadcasting on a shaky estimate is a bet with an asymmetric payoff.
  • Both sides are already co-partitioned on the join key. If the data was written bucketed or partitioned by that key, matching rows are already on the same worker and the engine can join locally with no movement at all. Broadcasting throws that advantage away. This is the case worth engineering for deliberately: choosing a bucketing key that matches your most frequent join eliminates the shuffle permanently rather than working around it query by query.
  • The worker count is very high and the small side is not tiny. Broadcast cost scales with the number of workers. On a very wide cluster, the multiplication can exceed the cost of simply shuffling both sides.

Inference The right instinct on a cluster is not “which join algorithm” — it is nearly always a local hash join in the end — but “how much data crosses the network, and does it land evenly”. Those two questions explain most distributed join performance.

Interview angle

“Your Spark job has one task that runs ten times longer than every other task in the stage. What is happening?” The expected answer is data skew on the shuffle key. What distinguishes a strong answer is the diagnostic sequence: confirm from the stage's task-duration distribution, then group by the join key to find the hot value, then check whether it is a genuine business entity or a junk placeholder such as NULL or 'unknown' — because the remedy differs completely. Mentioning that adding executors will not help demonstrates you understand partitions as indivisible work units.

Exercise 12.8

Digital media Before joining sessions to customers on customer_id in a distributed engine, you want to know whether that key would shuffle evenly. Write a query listing each customer_id in sessions with its row count and share of total rows, worst first, and say what you would do about the top entry.

Show solution
sqlexercise_12_8.sql
SELECT customer_id,
       COUNT(*)                                          AS row_count,
       ROUND(COUNT(*) * 1.0 / SUM(COUNT(*)) OVER (), 4)  AS share_of_rows
FROM sessions
GROUP BY customer_id
ORDER BY row_count DESC, customer_id NULLS FIRST
LIMIT 5;

The entry to look at is customer_id IS NULL — anonymous sessions with no signed-in user. Every NULL key hashes identically, so in a shuffle they all land on one worker, and they will never match a row in customers anyway because NULL = NULL is not true. That makes them pure cost. The fix is to filter them out before the join (or route them through a separate branch if you need them in the output), which removes the hottest partition without changing a single result row. Note the NULLS FIRST in the tie-break: skew hunting is exactly the situation where you must not let NULL sort quietly to the bottom of the list.

Knowledge check

Both sides of a distributed join are already bucketed by the join key. What is the best plan?

Key takeaway

On a cluster the join question is which rows must travel. Broadcast copies one small side to every worker and leaves the large side still — cost scales with worker count, so it depends on that side genuinely being small. Shuffle repartitions both sides by the join key — correct at any scale, and vulnerable to skew, where one hot key lands entirely on one worker and sets the stage duration on its own. Adding workers cannot fix skew; filtering junk keys, adaptive splitting, or salting can. Best of all is co-partitioned data that needs no movement whatsoever.

Lesson 12.9·14 min read

Partitioning & Partition Pruning

Learn how splitting a table by a column lets the engine skip most of it before reading a byte — and the two specific ways teams write predicates that silently switch that saving off.

The cheapest data to process is data you never read. Indexes make individual rows cheap to find; partitioning makes entire swathes of a table cheap to ignore, by arranging the physical storage so the engine can rule out whole regions from the query text alone, without opening them.

Supply chain A logistics company keeps six years of shipment scans in one table. Nearly every query asks about the last thirty days. Without partitioning, every one of those queries reads six years of data to discard 99% of it. That is not an optimisation problem; it is a layout problem, and no index fixes it — an index still has to be traversed, and on a query touching a large date range the planner will often ignore it and scan anyway.

What partitioning physically means

Partitioning splits one logical table into many physical pieces along the values of a chosen column — the partition key. In a warehouse on object storage, each partition is typically its own directory of files:

  shipments/
    scan_date=2024-11-28/  part-0000.parquet  part-0001.parquet
    scan_date=2024-11-29/  part-0000.parquet
    scan_date=2024-11-30/  part-0000.parquet  part-0001.parquet
    ...

  Query: WHERE scan_date = DATE '2024-11-29'
  The engine resolves the predicate against directory NAMES and opens
  exactly one directory. The other 2,190 are never listed, never read,
  never billed.

That last point is the mechanism. The partition value is encoded in the metadata — a directory name, a manifest entry, a catalogue row — so eliminating a partition costs a metadata comparison rather than a data read. This is partition pruning, and it happens before the scan operator starts.

sqlsimulate_pruning.sql
-- Treat each month as a partition, then work out which ones a
-- Q2 predicate would need to open.
WITH parts AS (
  SELECT date_trunc('month', order_date) AS partition_month,
         COUNT(*)        AS rows_in_partition,
         MIN(order_date) AS min_d,
         MAX(order_date) AS max_d
  FROM orders
  GROUP BY 1
)
SELECT partition_month,
       rows_in_partition,
       (min_d <= DATE '2024-06-30' AND max_d >= DATE '2024-04-01')
         AS scanned_with_range_predicate
FROM parts
ORDER BY partition_month;
Result
partition_monthrows_in_partitionscanned_with_range_predicate
2024-01-012false
2024-02-012false
2024-03-013false
2024-04-011true
2024-05-013true
2024-06-013true
2024-07-012false
2024-08-013false
2024-09-012false
2024-10-011false
2024-11-011false
2024-12-011false
2025-01-011false

Three partitions of thirteen are opened. The other ten are eliminated by comparing the predicate's bounds against partition metadata — no rows examined, and on a usage-billed engine, no bytes charged.

bigquerypartitioned_ddl.sql
-- BigQuery: partition by day, and refuse queries that would scan everything.
CREATE TABLE logistics.shipment_scans
PARTITION BY DATE(scan_ts)
CLUSTER BY carrier_id, depot_id
OPTIONS (
  require_partition_filter = TRUE,      -- a query with no partition
                                        -- predicate is rejected, not run
  partition_expiration_days = 1095
)
AS SELECT * FROM logistics.staging_scans;

require_partition_filter deserves attention. It converts the most expensive possible mistake — a full-table scan of six years — from a large invoice into an error message the author sees immediately. Inference Making the bad case fail loudly beats making it merely discouraged, because a discouraged behaviour still happens at 2am inside a scheduled job that nobody reads.

snowflakesnowflake_note.sql
-- Snowflake has no explicit PARTITION BY clause on tables. It divides data
-- into immutable micro-partitions automatically and records per-column
-- min/max for each one, then prunes on those. You influence the layout
-- with a clustering key rather than a partition declaration:
ALTER TABLE shipment_scans CLUSTER BY (scan_date, carrier_id);

-- Inspect how well clustered the table currently is:
SELECT SYSTEM$CLUSTERING_INFORMATION('shipment_scans', '(scan_date)');
Analogy

A warehouse with one aisle per month, and the month painted on the end of each aisle. Asked for something from April, you walk to the April aisle and ignore the rest of the building — you did not search the other aisles quickly, you never entered them. But now imagine somebody labels the aisles by day instead: two thousand aisles, most holding three boxes, and you spend your morning walking between them reading signs. Partitioning too finely replaces one large search with an enormous amount of navigation.

Two ways to switch pruning off by accident

Pruning is not a property of the table. It is a property of the predicate, and it applies only when the planner can evaluate the predicate against partition metadata before scanning. Two extremely common patterns prevent that.

1. Wrapping the partition column in a function. If the table is partitioned on scan_date, then WHERE scan_date >= DATE '2024-11-01' prunes. But WHERE CAST(scan_date AS VARCHAR) LIKE '2024-11%', or WHERE date_trunc('month', scan_date) = DATE '2024-11-01', generally does not. The planner has partition boundaries expressed in terms of scan_date, not in terms of some function of it; unless the engine can prove the function is monotonic and invert it, it must open every partition and evaluate row by row. The fix is always the same: rewrite the predicate as a bare range on the raw column.

2. Joining to derive the filter. WHERE scan_date IN (SELECT max_date FROM control_table) hides the value behind a subquery the planner may not be able to evaluate at planning time. Some engines handle this with runtime or dynamic partition pruning, computing the small side first and then pruning; many do not. If your engine does not, the query silently scans everything. Resolve the value in a prior step and inject it as a literal or a parameter.

Common pitfall — over-partitioning and the small-file problem

Faced with “partitioning makes queries cheaper”, teams reach for the finest grain available: partition by day, then by hour, then add country and device inside that. The result is millions of directories holding a few kilobytes each. Every query now begins with an expensive metadata listing; every file carries fixed open-and-read overhead that dwarfs its contents; columnar compression, which works by finding patterns across many rows in a column, has almost nothing to work with in a file of forty rows. The table becomes slower than it was unpartitioned, and the cause looks nothing like the cure. Choose the grain from the volume per partition, not from the finest column you have.

sqlgrain_tradeoff.sql
-- The trade-off, made numeric: finer grain means more partitions and
-- fewer rows in each. Past some point the per-partition overhead wins.
SELECT 'year'  AS grain,
       COUNT(DISTINCT date_trunc('year', order_date))                 AS partitions,
       ROUND(COUNT(*) * 1.0
             / NULLIF(COUNT(DISTINCT date_trunc('year', order_date)), 0), 2)
                                                                      AS avg_rows_per_partition
FROM orders
UNION ALL
SELECT 'month',
       COUNT(DISTINCT date_trunc('month', order_date)),
       ROUND(COUNT(*) * 1.0
             / NULLIF(COUNT(DISTINCT date_trunc('month', order_date)), 0), 2)
FROM orders
UNION ALL
SELECT 'day',
       COUNT(DISTINCT order_date),
       ROUND(COUNT(*) * 1.0 / NULLIF(COUNT(DISTINCT order_date), 0), 2)
FROM orders
ORDER BY partitions;
Result
grainpartitionsavg_rows_per_partition
year212.50
month131.92
day251.00

The shape of that table is the whole design question in miniature. Coarse grain: few partitions, poor pruning, but healthy file sizes. Fine grain: excellent pruning, but partitions so small that overhead dominates. Open There is no universal target size, and published guidance differs by engine and storage layer; the durable rule is that a partition should be large enough that reading it is dominated by useful data rather than by opening it.

When not to partition

Partitioning is a commitment in the physical layout, and it is not free to change later. Skip it when:

  • The table is small. If the whole table fits in a few files, a full scan is already fast and partitioning only adds metadata to traverse. Dimension tables are the usual case.
  • No column dominates the query filters. Partitioning helps the queries that filter on the partition key and helps nothing else. If your workload filters on five different columns in roughly equal measure, you cannot pick a key that serves it, and clustering or zone maps (Lesson 12.10) are the better tool because they operate on several columns without splitting the physical layout.
  • The candidate key is high-cardinality and evenly used. Partitioning by customer_id across millions of customers produces millions of tiny partitions — over-partitioning by construction. Bucketing by a hash of the key into a fixed number of buckets gives you locality without the explosion.
  • The key's values are not known at write time, or arrive so late that partitions must constantly be rewritten. Backfilling into old partitions repeatedly is a real operational cost, and a layout that fights your arrival pattern will lose.

Note also what partitioning does not do: it does not speed up a query that has no predicate on the partition key. A dashboard aggregating all history reads every partition regardless. Partitioning optimises selective access, not scanning.

Interview angle

“This table is partitioned by date, the query filters on date, and it still scans the whole thing. Why?” The interviewer is testing whether you know pruning depends on predicate form. Name the two classic defeats: a function or cast wrapped around the partition column, and a filter value hidden inside a subquery or join that the planner cannot resolve at plan time. The strongest answers add the verification step — read the plan or dry-run output for partitions-scanned or bytes-processed, and confirm the number drops after rewriting, rather than assuming it did.

Exercise 12.9

Supply chain Treating each ship_country as a partition of orders, produce the row count per partition and flag which partitions a query filtering to 'GB' or 'SE' would open. Then say, from the result, whether ship_country is a sensible partition key for this table.

Show solution
sqlexercise_12_9.sql
SELECT ship_country                             AS partition_value,
       COUNT(*)                                 AS rows_in_partition,
       ship_country IN ('GB', 'SE')             AS opened_by_query
FROM orders
GROUP BY ship_country
ORDER BY rows_in_partition DESC, ship_country;

Two partitions are opened and the rest are pruned, so the mechanism works. But look at the row counts: every partition holds a handful of rows, because the column's cardinality is high relative to the table's size. Partitioning here would create many tiny files and pay far more in metadata and per-file overhead than it recovers in pruning — the textbook over-partitioning mistake. The right reading of this result is that ship_country is a good clustering or secondary key and a poor partition key. The general test is not “do queries filter on it” but “do queries filter on it and does each resulting partition hold a substantial amount of data”.

Knowledge check

A table is partitioned by event_date. Which predicate is most likely to prune?

Key takeaway

Partitioning encodes a column's value in metadata, so the engine can discard whole regions without reading them. Pruning depends on the predicate being a plain comparison on the raw partition column — wrap it in a function or hide it in a subquery and the saving silently disappears. Choose the grain by data volume per partition, never by the finest column available: over-partitioning trades a scan problem for a small-file problem, and the second is harder to see and harder to undo.

Lesson 12.10·15 min read

Clustering, Sort Keys & Data Skipping

Understand why the physical order of rows inside a table decides how much of it a query can skip, and why that order quietly decays as data arrives.

Partitioning (Lesson 12.9) skips data at the directory level, and it works on one column, or a small handful. Inside a partition there is still a great deal of data, and a second, finer skipping mechanism operates there — one that costs nothing to maintain per query, works on many columns at once, and depends entirely on something you rarely think about: the order rows happen to be stored in.

Insurance A claims table is partitioned by month. An analyst filters to one policy type within one month. The month prunes to one partition; inside it, hundreds of millions of rows remain, of which a few thousand match. Whether the engine reads all of those hundreds of millions or a small fraction of them is decided by whether rows with the same policy type were written near each other.

Zone maps: min and max per block

Columnar storage does not keep loose rows. It keeps blocks — row groups, stripes, micro-partitions; the names differ, the idea does not — each holding many rows, each column stored contiguously and compressed. Along with every block, the format records a tiny summary of each column: at minimum the minimum and maximum value present, often a null count and sometimes a bloom filter. This summary is called a zone map.

Given a predicate, the engine reads only the zone maps first — they are tiny — and asks a single question per block: could any row here possibly match? If the predicate is order_date >= DATE '2024-10-01' and a block's maximum date is 2024-03-09, the answer is provably no, and the block is skipped without being decompressed or read. This is data skipping, and unlike an index it requires no separate structure, no maintenance, and no storage worth mentioning.

Everything therefore turns on a single property: are the zone maps narrow or wide? A block whose dates span one week can be excluded by almost any date predicate. A block whose dates span the entire history can be excluded by none. And what determines the width of a block's range is the order in which rows were written.

Sort order decides how much you can skip

Here is the effect, computed rather than asserted. We simulate blocks of five rows and check which blocks a Q4 2024 predicate must open — first with rows laid out in insertion order, then with the same rows laid out sorted by date.

sqlzone_maps_unsorted.sql
-- Layout A: rows stored in insertion order (order_id).
WITH laid_out AS (
  SELECT order_date,
         (ROW_NUMBER() OVER (ORDER BY order_id) - 1) // 5 AS block_no
  FROM orders
)
SELECT block_no,
       COUNT(*)        AS rows_in_block,
       MIN(order_date) AS zone_min,
       MAX(order_date) AS zone_max,
       MIN(order_date) <= DATE '2024-12-31'
         AND MAX(order_date) >= DATE '2024-10-01' AS must_read
FROM laid_out
GROUP BY block_no
ORDER BY block_no;
Result — insertion order
block_norows_in_blockzone_minzone_maxmust_read
052024-01-082024-05-14false
152024-02-202024-09-16false
252024-04-142024-08-19false
352024-06-232024-11-14true
452024-08-012025-01-09true
sqlzone_maps_sorted.sql
-- Layout B: identical rows, but stored sorted by order_date.
WITH laid_out AS (
  SELECT order_date,
         (ROW_NUMBER() OVER (ORDER BY order_date, order_id) - 1) // 5 AS block_no
  FROM orders
)
SELECT block_no,
       COUNT(*)        AS rows_in_block,
       MIN(order_date) AS zone_min,
       MAX(order_date) AS zone_max,
       MIN(order_date) <= DATE '2024-12-31'
         AND MAX(order_date) >= DATE '2024-10-01' AS must_read
FROM laid_out
GROUP BY block_no
ORDER BY block_no;
Result — sorted by order_date
block_norows_in_blockzone_minzone_maxmust_read
052024-01-082024-03-09false
152024-03-122024-05-14false
252024-05-182024-07-08false
352024-07-112024-09-07false
452024-09-162025-01-09true

Same rows, same predicate, same block size. In insertion order two blocks must be opened; sorted, one. Look at why: in layout A the zone ranges overlap heavily — block 1 spans February to September — because orders were inserted grouped by customer, and each customer ordered across the whole year. In layout B the ranges are disjoint and each block covers a tight window. Inference The general principle is that skipping effectiveness is governed by overlap between block ranges, not by sortedness as such. A wide range is a range that cannot be excluded.

Analogy

Fifty archive boxes, each labelled with the earliest and latest date inside it. If the papers were filed chronologically, each label reads something like “March–April” and finding November means opening one box. If the papers were filed as they happened to arrive, every label reads “January–December” — the labels are all still perfectly accurate, and completely useless, because none of them lets you rule a box out. Nothing about the boxes changed; only the order of what went into them.

Clustering keys and multi-column order

Declaring a clustering key (Snowflake, BigQuery) or a sort key (Redshift) tells the engine to physically organise rows by those columns, so zone maps come out narrow for them. The syntax varies; the mechanism is identical.

snowflakeclustering.sql
-- Snowflake: cluster the claims table so that micro-partitions have narrow
-- ranges on the columns queries actually filter by.
ALTER TABLE claims CLUSTER BY (claim_date, policy_type);

-- How well clustered is it right now?
SELECT SYSTEM$CLUSTERING_INFORMATION('claims', '(claim_date, policy_type)');
-- Returns average_overlaps and average_depth. Rising depth over time means
-- newly written micro-partitions overlap the existing ones more and more.
redshiftsortkey.sql
-- Redshift: a compound sort key. Column ORDER is significant.
CREATE TABLE claims (
  claim_date  DATE,
  policy_type VARCHAR(20),
  claim_amt   NUMERIC(12,2)
)
DISTKEY (policy_type)
COMPOUND SORTKEY (claim_date, policy_type);

-- A compound key skips well for predicates on the FIRST column, and for
-- leading prefixes. A filter on policy_type alone gets little benefit.
-- An INTERLEAVED SORTKEY weights the columns equally instead, at the cost
-- of a more expensive re-sort during maintenance.

Column order in a multi-column clustering key matters in the same way it matters in a composite index. With (claim_date, policy_type), rows are ordered by date first and only by policy type within a date. A filter on claim_date alone skips beautifully. A filter on policy_type alone skips barely at all, because every block contains the full spread of policy types for its date window. Put the column your queries filter on most selectively first — and if two columns are both filtered independently and often, that is the situation an interleaved or space-filling-curve ordering exists to address.

  Clustered by (claim_date, policy_type)

  block 0 : claim_date [Jan 01 .. Jan 03]  policy_type [auto .. travel]
  block 1 : claim_date [Jan 03 .. Jan 06]  policy_type [auto .. travel]
  block 2 : claim_date [Jan 06 .. Jan 09]  policy_type [auto .. travel]

  WHERE claim_date = 'Jan 07'      --> blocks 0,1 excluded. Good.
  WHERE policy_type = 'travel'     --> every block's range includes
                                        'travel'. Nothing excluded.

  The second column narrows ranges only WITHIN a value of the first.

Clustering drift: why it degrades on its own

Clustering is not a constraint the engine enforces on every write — that would make ingestion prohibitively expensive. It is a physical arrangement established at some moment and eroded afterwards. Each new batch is written as fresh blocks, and unless it happens to arrive in clustering-key order, those blocks overlap the existing ones. Updates and deletes are worse: in immutable-file formats they rewrite rows into new blocks whose ranges have no relationship to the original layout.

The measure of this erosion is clustering depth: for a given point in the key space, how many blocks contain it. A perfectly clustered table has depth 1 — one block per key value range. A table that has been appended to unsorted for a year has high depth, meaning any given predicate value could be in many blocks, and skipping approaches zero.

The observable symptom is the annoying one: a table that has not changed in schema and whose queries have not changed in text gets steadily slower over months, with no single day where anything broke. The remedy is periodic reorganisation — automatic clustering services, VACUUM/re-sort operations, or compaction jobs that rewrite blocks in key order. Fact All of these consume compute; clustering maintenance is a real, recurring cost, not a one-off setup step.

Common pitfall — clustering on a high-cardinality identifier

A tempting choice is to cluster on the column queries filter on most precisely, which is often something like claim_id or user_id. It backfires. With near-unique values, almost every incoming batch spans nearly the entire key range, so new blocks overlap everything and depth degrades almost immediately — you pay for continual reclustering and get little skipping in return. Clustering rewards columns with moderate cardinality that queries filter on by range or by a common value: dates, regions, categories, tenant ids. If you genuinely need single-row lookups by a unique id, that is what an index or a bloom filter is for, and it is a different mechanism with different economics.

When clustering is not worth it

  • The table is small, or a partition is already small. If a query reads three blocks regardless, there is nothing to skip. Maintenance cost with no benefit.
  • Queries are unselective. Full-table aggregations and dashboards that read all history skip nothing by construction. Clustering optimises selective reads only.
  • The table is written far more often than it is read. Reclustering cost scales with churn. A high-ingest, rarely-queried table can easily spend more on maintaining its layout than the layout ever saves.
  • Filter columns change constantly. Clustering is a bet on a stable access pattern. If the workload's filter columns rotate every quarter, you will be paying to reorganise into a layout that is obsolete before it settles.
  • The natural arrival order already gives you what you need. Event data appended in timestamp order is already clustered on time — the zone maps are narrow with no declaration and no maintenance. Declaring a clustering key on the ingestion timestamp of an append-only log usually buys nothing you did not already have for free.

That last point is the one most worth internalising. Before declaring a clustering key, check whether the data already arrives in an order that serves your queries. Frequently it does, and the correct action is to leave it alone.

Interview angle

“What is the difference between partitioning and clustering?” A precise answer separates them on three axes: granularity (partitions eliminate whole directories or files, clustering narrows ranges within blocks inside them), cardinality tolerance (partitioning needs low cardinality or you get tiny files; clustering handles moderate to high), and enforcement (a partition value is exact and permanent, whereas clustering is a best-effort physical arrangement that decays and needs maintenance). Candidates who add that the two are complementary — partition on the coarse dominant filter, cluster on the secondary ones — are describing what real warehouse tables actually do.

Exercise 12.10

Healthcare Using order_items as a stand-in for a large fact table, simulate blocks of six rows laid out in insertion order and again laid out sorted by product_id. For each layout, report each block's min and max product_id and whether a filter for product_id = 112 would have to open it. Count the blocks read in each case.

Show solution
sqlexercise_12_10.sql
-- Layout A: insertion order.
WITH laid_out AS (
  SELECT product_id,
         (ROW_NUMBER() OVER (ORDER BY order_item_id) - 1) // 6 AS block_no
  FROM order_items
)
SELECT 'insertion order' AS layout,
       block_no,
       MIN(product_id)   AS zone_min,
       MAX(product_id)   AS zone_max,
       MIN(product_id) <= 112 AND MAX(product_id) >= 112 AS must_read
FROM laid_out
GROUP BY block_no

UNION ALL

-- Layout B: clustered on product_id.
SELECT 'clustered on product_id',
       block_no,
       MIN(product_id),
       MAX(product_id),
       MIN(product_id) <= 112 AND MAX(product_id) >= 112
FROM (
  SELECT product_id,
         (ROW_NUMBER() OVER (ORDER BY product_id, order_item_id) - 1) // 6 AS block_no
  FROM order_items
)
GROUP BY block_no
ORDER BY layout, block_no;

In insertion order the product ids are effectively random with respect to block boundaries, so most blocks span a wide id range and cannot be excluded. Clustered on product_id, the ranges are disjoint and only the block containing 112 must be opened. Note the shape of the reasoning, which is the transferable part: a block is skippable exactly when the predicate value falls outside [zone_min, zone_max], so your goal in choosing a layout is to make those intervals as narrow and as non-overlapping as possible for the columns you actually filter on.

Knowledge check

A table is clustered on (region, event_date). Which query benefits least from data skipping?

Key takeaway

Columnar engines store per-block min/max zone maps and skip any block whose range cannot contain a match. Skipping works in proportion to how narrow and non-overlapping those ranges are, which is decided entirely by the physical order rows were written in. A clustering or sort key requests that order; column order within it matters exactly as it does in a composite index. And because clustering is an arrangement rather than an enforced constraint, it drifts as data arrives and needs periodic, genuinely costly reorganisation — so declare it where selective reads justify the maintenance, and check first whether arrival order already gives you the layout for free.

Lesson 12.11·13 min read

Predicate Pushdown & Projection Pushdown

Learn to read a plan and tell whether your filter reached the scan — and to recognise the five things you can write that stop it getting there.

A query has two costs that dwarf everything else: how many rows you read off storage, and how many bytes per row. A join algorithm can only be clever about rows that have already been read; nothing can un-read a byte.

So the most valuable thing an optimiser does is move work downwards. If your query says “read the whole orders table, then keep only 2024”, a good engine rewrites it as “read only 2024 from the orders table”. That is predicate pushdown: the predicate (the filter condition) is pushed down the plan tree until it sits inside the scan itself. Its sibling, projection pushdown, does the same for columns — if you reference two of fifteen columns, the scan is told to fetch two.

Digital media A streaming team queries a viewing-events table holding several years of playback records. The dashboard asks for last week. If the filter is pushed into the scan, the engine touches roughly a week of storage. If it is applied above a step that blocks pushdown, the engine reads every year ever recorded and then discards nearly all of it. Same SQL text, same answer, wildly different bill.

What “pushed down” actually means

Pushdown happens at three depths, and it is worth keeping them separate in your head because different things block each one.

  1. Into the operator tree. The filter moves below joins, below sub-queries, below CTEs — anywhere it is provably safe to evaluate earlier.
  2. Into the file format. Formats like Parquet store per-row-group minimum and maximum values. A pushed predicate lets the reader skip whole row groups without decompressing them. This is often called predicate pruning or min/max skipping.
  3. Into the remote source. When the table is a federated source — another database, an object store, an API-backed extension — a pushed predicate can be translated into that system's own filter and sent over the wire, so the rows never travel.

DuckDB shows you all of this in its plan. Run the two statements below and compare where the word Filters appears.

sqlpushdown_basic.sql
-- The filter is written OUTSIDE a sub-query, but it belongs to the scan.
EXPLAIN
SELECT t.order_id, t.status
FROM (SELECT * FROM orders) AS t
WHERE t.status = 'completed';
planpushdown_basic.plan
┌──────────────────────────┐
│         SEQ_SCAN          │
│    ────────────────────   │
│       Table: orders       │
│   Type: Sequential Scan   │
│                           │
│        Projections:       │
│          order_id         │
│           status          │
│                           │
│          Filters:         │
│     status='completed'    │
│                           │
│          ~9 rows          │
────────────────────────────

Two things happened at once. The sub-query disappeared entirely, and the filter landed inside the scan node under Filters. Just as importantly, SELECT * inside the sub-query did not force five columns to be read: the scan's Projections list contains only order_id and status, because those are the only ones the outer query needs. That is projection pushdown, and it is the reason a nested SELECT * is usually harmless while a top-level one is not.

Analogy

You ask a warehouse for “every box you have”, then stand at the loading bay opening each one and keeping only the blue ones. Predicate pushdown is telling the warehouse staff “blue boxes only” before they load the lorry. Projection pushdown is adding “and I only need the lids”. The work is identical in principle; the difference is entirely in where it happens, and therefore how much gets carried.

What blocks pushdown

The optimiser will only move a filter downwards when it can prove the result is unchanged. Anything that breaks the proof stops the move. There are four families worth memorising.

1. A filter above a window function that references the window output. A window function's value depends on the whole partition. Removing rows first would change the ranking, so the filter must stay above.

sqlblocked_by_window.sql
EXPLAIN
SELECT *
FROM (
  SELECT order_id, customer_id, order_date,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS rn
  FROM orders
) AS t
WHERE t.rn = 1;

The plan shows a standalone FILTER (rn = 1) node sitting above the WINDOW node, which itself sits above the scan. Every row in the table is read and ranked before a single one is discarded. Fact A filter on a column that is part of the PARTITION BY key can often be pushed below the window, because partitions are independent of each other; a filter on the window's output never can.

2. A filter above a LIMIT. This one surprises people. LIMIT chooses an arbitrary set of rows; filtering before it would change which rows survive, so the filter is stuck on top.

sqlblocked_by_limit.sql
EXPLAIN
SELECT *
FROM (SELECT order_id, status FROM orders LIMIT 10) AS t
WHERE t.status = 'completed';

The plan is FILTER above STREAMING_LIMIT above SEQ_SCAN. Semantically this query means “take any ten orders, then keep the completed ones” — which is almost certainly not what the author meant. The performance problem and the correctness problem have the same root.

3. Non-deterministic functions. A predicate containing random(), a session clock, or a sequence generator cannot be evaluated by the storage layer, because the storage layer may evaluate it a different number of times than the plan implies.

sqlnondeterministic.sql
-- Note which half of the AND reaches the scan and which does not.
EXPLAIN
SELECT order_id
FROM orders
WHERE status = 'completed'
  AND random() < 0.5;

This is the most instructive plan of the lesson. DuckDB splits the conjunction: status='completed' goes into the scan's Filters, while random() < 0.5 stays in a FILTER node above it. Inference Practical consequence: joining your sargable conditions with AND costs you nothing even when one of them is opaque, but replacing that AND with OR destroys the split — the whole disjunction becomes unpushable, because either side alone is no longer sufficient to reject a row.

4. Opaque user-defined functions. A UDF is a black box. Unless the engine has been told it is deterministic and side-effect-free, it must be evaluated row by row above the scan. Some systems let you declare this; many do not, and a scalar UDF in a WHERE clause quietly converts an indexed lookup into a full scan.

Common pitfall — the view that swallows your filter

Analysts build a reporting view, someone adds a ROW_NUMBER() or a LIMIT inside it for deduplication, and from that day every query against the view scans the whole history no matter how narrow the WHERE clause looks. Nobody notices because the view still returns the right numbers. Inference The tell is a plan whose scan node has an empty Filters section despite a selective outer predicate. Whenever you wrap logic in a view that others will filter, ask what you have put between their predicate and the storage.

When not to chase pushdown

Pushdown is a means, not a goal. There are situations where rewriting your query to force it is wasted or actively harmful.

  • The table is small. Below some threshold the whole relation lives in cache, and a full scan costs less than the effort spent avoiding it. Optimising a dimension table of a few thousand rows is theatre.
  • The predicate is not selective. Pushing a filter that keeps most rows saves almost nothing. Pushdown pays in proportion to what it eliminates.
  • The blocker is load-bearing. If the window function or the LIMIT is genuinely part of the business logic — “the most recent row per customer” really does need all rows ranked — the filter must stay above it. The fix is not to remove the window but to add a second, independently pushable predicate that shrinks the input before ranking.
  • The data is not laid out to exploit it. Min/max skipping only helps if the filter column correlates with physical ordering. A pushed filter on a randomly scattered column still touches every row group. Clustering is the prerequisite; pushdown cashes it in.
Interview angle

“This query filters to one day but reads the whole table — why?” is a favourite live-debugging prompt. Weak answers reach for indexes immediately. Strong answers ask to see the plan and check whether the predicate reached the scan at all, then name a blocker: a window function, a LIMIT, a DISTINCT ON, an opaque UDF, a non-deterministic expression, or a UNION branch above which the filter cannot be duplicated. The strongest add the layered view — operator tree, file format, remote source — because it shows you know pushdown is not one mechanism but three.

Exercise 12.11

Product analytics A colleague reports that a query over orders is reading far more than it should. They want the first order per customer, but only for customers acquired through paid search. Their draft ranks every order, then filters. Show, using EXPLAIN, that only one of the two predicates reaches the scan, and rewrite so the channel filter is applied before ranking.

Show solution
sqlexercise_12_11.sql
-- Rewritten: the channel restriction is expressed as a join that the
-- optimiser can evaluate BEFORE the window, so fewer rows get ranked.
SELECT t.customer_id, t.order_id, t.order_date
FROM (
  SELECT o.customer_id, o.order_id, o.order_date,
         ROW_NUMBER() OVER (PARTITION BY o.customer_id
                            ORDER BY o.order_date) AS rn
  FROM orders AS o
  JOIN customers AS c ON c.customer_id = o.customer_id
  WHERE c.channel = 'paid_search'
) AS t
WHERE t.rn = 1
ORDER BY t.customer_id;
Result
customer_idorder_idorder_date
110012024-01-08
510072024-03-09
1010152024-06-04
1510232024-09-07

The rn = 1 predicate still sits above the window — it must, because it depends on the ranking. But channel = 'paid_search' is now inside the sub-query, so it can be pushed to the customers scan and propagated through the join before any ranking happens. The lesson generalises: when one predicate is unpushable, your job is to find a second one that is.

Knowledge check

A plan shows a FILTER node above the scan rather than a Filters entry inside it. What does that tell you?

Key takeaway

Read the scan node first. If your predicate is listed inside it, the engine is reading less; if it sits in a FILTER above, you are paying full price for the read and merely tidying up afterwards. Pushdown fails for provable reasons — window outputs, LIMIT, non-determinism, opaque UDFs, and OR across unpushable branches — and the fix is almost never to delete the blocker but to supply an extra predicate that can travel.

Lesson 12.12·13 min read

Vectorised Execution & Columnar Compression

Understand why analytical engines process batches of values instead of rows, and why squeezing data smaller can make reading it faster rather than slower.

Two engines can run identical SQL over identical data and differ enormously in cost, without either having a better plan. The difference is in how the plan is executed: the physical shape of the loop and the physical layout of the bytes it reads.

Classic transactional databases use the Volcano model. Each operator exposes a next() call that returns one row. The aggregate calls next() on the filter, which calls next() on the scan, which produces a row, which travels back up. To sum a hundred million values you make hundreds of millions of function calls, each dispatching through a virtual call, each re-checking types.

The arithmetic itself is trivial; nearly all the time goes on the machinery around it. Fact Modern CPUs pipeline instructions and predict branches ahead of time; an interpreter that dispatches one indirect call per row per operator defeats both mechanisms, so the processor spends most of its cycles waiting rather than computing.

Batches instead of rows

Vectorised execution changes the unit of work. Instead of returning one row, each operator returns a vector — a batch of values from a single column, typically one or two thousand of them, sized to sit comfortably in the CPU's fast cache. The sum operator now receives an array of numbers and runs one tight loop over it.

Three things get better simultaneously. The interpreter overhead is amortised across the whole batch instead of paid per row. The loop over a contiguous array is exactly what a CPU's prefetcher and its wide SIMD instructions are built for, so several values are added per cycle. And because a vector holds one column, every value in it has the same type, so no type check is needed inside the loop.

ROW-AT-A-TIME (Volcano)          VECTORISED
  next() -> [1001|10|2024|OK]      next() -> order_id  [1001,1002,1003, ... x2048]
  next() -> [1002|11|2024|OK]      next() -> status    [OK,  OK,  RET,  ... x2048]
  next() -> [1003|12|2024|RET]
  ... one call per row             ... one call per 2048 values, per column
  overhead paid 100,000,000x       overhead paid ~48,000x

Telecommunications A network operator aggregates call-detail records to find average call duration per cell tower. The query touches two columns out of forty. Row-at-a-time execution reads all forty columns of every record into memory just to reach the two it wants; vectorised columnar execution reads two columns as two dense arrays and never materialises a record at all.

sqlcolumnar_aggregate.sql
-- The profile shows the scan projecting a single column and the
-- aggregate consuming it. No row is ever assembled.
EXPLAIN ANALYZE
SELECT sum(minutes_watched) AS total_minutes
FROM content;

The analysed plan reports TABLE_SCAN with Projections: minutes_watched feeding an UNGROUPED_AGGREGATE. The other six columns of content — title, type, dates — are never touched, because in a columnar store they are physically elsewhere on disk.

Analogy

Counting a warehouse of stock. Row-at-a-time is walking to a shelf, picking up one box, carrying it to the desk, writing down its weight, walking back. Vectorised is loading a pallet of boxes and weighing them together. Columnar layout is the further step of storing all the weights on one shelf and all the colours on another — so when you are asked for total weight you never have to touch a colour at all.

Why columns compress so well

A row mixes an integer id, a date, a short string and a boolean. Consecutive bytes have nothing in common, so a general-purpose compressor finds little to exploit. A column is thousands of values of one type from one domain, and that homogeneity is what encodings feed on. Four families do most of the work.

EncodingExploitsStoresFits
Run-lengthLong repeats of the same value(value, count) pairsSorted or clustered low-cardinality columns: country, status
DictionaryFew distinct values, repeatedA code per row plus one lookup tableCategorical strings: channel, device, plan
DeltaValues close to their neighbourThe first value plus differencesTimestamps, sequential ids, monotonic counters
Bit-packingA narrow value rangeOnly the bits the range needsSmall integers, dictionary codes, booleans

You can see the conditions that trigger each of these directly in the data. The following query profiles the cardinality of the orders columns — exactly the statistic a storage engine consults when choosing an encoding.

sqlencoding_profile.sql
SELECT count(*)                     AS n_rows,
       count(DISTINCT status)       AS distinct_status,
       count(DISTINCT ship_country) AS distinct_country,
       count(DISTINCT order_id)     AS distinct_ids,
       max(order_id) - min(order_id) AS id_span
FROM orders;
Result
n_rowsdistinct_statusdistinct_countrydistinct_idsid_span
253142524

Read that as a storage engine would. status has three distinct values across every row — a dictionary of three entries, and two bits per row is enough to index it. order_id is fully unique and therefore a poor dictionary candidate, but its span is tiny relative to its magnitude, so storing differences from a base value and bit-packing them costs a handful of bits rather than a full integer each. Real tables are millions of rows, but the ratios that drive the decision look exactly like this.

Why smaller can mean faster

The intuition that compression trades CPU for space comes from file archiving, and it is the wrong intuition here. Two effects invert it.

Reading is the bottleneck, not decoding. The gap between how fast a CPU computes and how fast storage delivers bytes is enormous. If halving the bytes costs a small amount of extra CPU on a processor that was idle anyway, the trade is free. Inference This is why lightweight encodings are preferred over heavyweight general-purpose compression in analytical stores: the goal is not maximum shrinkage, it is the best shrinkage that decodes at memory speed.

Some operators never decode at all. This is the deeper reason. If status is dictionary-encoded, a filter for status = 'completed' resolves the string to its dictionary code once, then compares integers — and a GROUP BY status can aggregate directly on codes and translate back only for the final few output rows. If a run-length-encoded block says “the next 50,000 rows are all 'GB'”, a count can add 50,000 in one step. The encoded form is not an obstacle to computation; for these operators it is a better representation than the raw data.

sqlgroup_on_codes.sql
-- A low-cardinality GROUP BY: the engine can hash dictionary codes
-- rather than strings, and only decodes for the three output rows.
SELECT status, count(*) AS n
FROM orders
GROUP BY status
ORDER BY n DESC;
Result
statusn
completed21
cancelled2
returned2
Common pitfall — destroying the encoding with a cast

Wrap a dictionary-encoded column in upper(), trim() or a cast and the engine must decode every value to a real string before it can evaluate anything. You have converted an integer comparison over compressed data into a string operation over decompressed data, for the whole column. The same applies to storing categorical values inconsistently — 'GB', 'gb', ' GB' — which inflates the dictionary and forces the cleanup you then pay for on every read. Normalise on write, not on read.

When columnar and vectorised is the wrong choice

The design has a mirror-image weakness, and it is severe enough that transactional systems have not adopted it.

  • Single-row point lookups. Fetching one complete order means visiting every column's storage separately and stitching a row back together. A row store keeps that record contiguously and returns it in a single read. For “show me order 1017” the row store wins outright, and the gap widens with column count.
  • Updates and deletes. Changing one field means rewriting a compressed block, which may re-encode a run or invalidate a dictionary. Delete vectors and write-ahead areas mitigate this, but the cost stands: columnar storage is optimised for append and scan, not in-place mutation.
  • Small result sets under tight latency. Vectorisation amortises overhead across a batch. If a query touches ten rows there is nothing to amortise, and the batch machinery is pure overhead.
  • Highly selective operational queries. An application fetching one profile by primary key thousands of times a second wants a B-tree over a row store. Sending that workload to an analytical engine is a category error, not a tuning problem.

Open The boundary is moving. Hybrid formats and engines that keep a row-oriented write buffer in front of columnar storage are narrowing the gap, and reasonable engineers disagree about how far one system can serve both workloads well.

Interview angle

“Why is a columnar database faster for analytics?” invites a shallow answer — “it only reads the columns you need”. That is true and incomplete. The full answer has three legs: less I/O from projection, better compression because a column is type- and domain-homogeneous, and vectorised execution that turns per-row interpretation into tight loops over cache-resident arrays. Finish with the cost — point lookups and updates — because the question is really testing whether you understand a trade-off or have memorised a slogan.

Exercise 12.12

Insurance You are advising on storage for a claims table. Using customers as a stand-in, produce one row per column-of-interest showing its distinct-value count against the total row count, so you can argue which columns are dictionary candidates and which are not.

Show solution
sqlexercise_12_12.sql
SELECT 'channel' AS column_name,
       count(DISTINCT channel) AS distinct_values,
       count(*)                AS n_rows
FROM customers
UNION ALL
SELECT 'country', count(DISTINCT country), count(*) FROM customers
UNION ALL
SELECT 'email',   count(DISTINCT email),   count(*) FROM customers
ORDER BY distinct_values;
Result
column_namedistinct_valuesn_rows
channel517
country1417
email1617

channel has five distinct values and is an obvious dictionary candidate — three bits per row replace a string. email is effectively unique, so a dictionary would be as large as the data and buy nothing; it is better left to a general string encoding. Note that count(DISTINCT ...) ignores NULL, which is why country and email fall short of the row count — and storage engines treat nulls the same way, keeping a separate validity bitmap rather than a dictionary entry.

Knowledge check

Why can compressing a column make a scan faster rather than slower?

Key takeaway

Analytical speed comes from three reinforcing choices: store by column so you read only what you reference, encode each column using the homogeneity that a column has and a row does not, and execute in vectors so the per-value interpretation cost is amortised into a tight loop. The bill for all three is paid on single-row lookups and in-place updates — which is exactly why your warehouse and your application database are different systems.

Lesson 12.13·14 min read

Caching, Materialisation & Incremental Models

Choose deliberately between re-reading a cached answer, storing a computed one, and rebuilding only the part that changed — and know what each one costs you in staleness.

The fastest query is the one you do not run. Every technique in this lesson is a variation on that idea: do the work once, keep the answer, hand it out repeatedly. What separates them is where the answer is kept, who decides it is still valid, and what happens when it is not.

Banking A risk team's exposure dashboard aggregates several years of positions. Rebuilt on every page load it is slow and expensive; rebuilt nightly it is fast and, at 11:00, twelve hours out of date. Neither is wrong in the abstract. The correct answer depends on a question that is not technical: how stale can this number be before someone makes a bad decision with it? Get that answer first, then pick the mechanism.

Three mechanisms, three owners of correctness

The result cache stores the output of a query keyed by the query text and the state of the underlying tables. Re-issue the identical query and the engine returns the stored bytes without executing anything. You do not manage it and cannot rely on it: change a whitespace character, include a call to the current timestamp, or write one row to a source table, and the entry is bypassed or invalidated. Correctness is the engine's responsibility, which is what makes it safe and also what makes it unpredictable.

snowflakeresult_cache.sql
-- Warehouse-specific: the second execution of a byte-identical query
-- against unchanged tables is served from the result cache.
ALTER SESSION SET USE_CACHED_RESULT = TRUE;

SELECT status, count(*) FROM orders GROUP BY status;
SELECT status, count(*) FROM orders GROUP BY status;  -- served from cache

-- This one can never be cached across time: the predicate changes.
SELECT count(*) FROM orders WHERE order_date = CURRENT_DATE();

A materialised view stores the computed result of a defined query as real data, and the database knows how to refresh it. Some systems refresh on demand, some on a schedule, some incrementally when the source changes. The definition lives in the database, so it cannot drift from what people think it is.

plpgsqlmatview.sql
-- PostgreSQL: refresh is explicit. CONCURRENTLY keeps the view readable
-- during the rebuild, at the cost of requiring a unique index.
CREATE MATERIALIZED VIEW daily_revenue_mv AS
SELECT o.order_date AS revenue_date,
       sum(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
GROUP BY o.order_date
WITH DATA;

CREATE UNIQUE INDEX ON daily_revenue_mv (revenue_date);
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue_mv;

A table snapshot is the blunt version: a plain table built by CREATE TABLE AS SELECT, refreshed by whatever pipeline you write. The database has no idea it is derived from anything. That is a real loss — nothing will tell you it is stale — and a real gain: you control the refresh completely, you can index and cluster it however you like, and no engine limitation constrains the query that built it. Most warehouse models in production are this, orchestrated by a transformation tool.

Analogy

A result cache is remembering the answer to a question someone just asked you — useful, automatic, and gone the moment anything changes. A materialised view is a printed report with a standing instruction to reprint it whenever the figures change. A snapshot table is a photocopy in a drawer: instant to hand over, and nothing on it tells you the original was revised last Tuesday.

Staleness is the real cost

Beginners compare these on speed. Speed is the easy axis; they are all fast. The axis that matters is what happens between refreshes, and it has three distinct failure modes.

  • Silent staleness. The number is old and looks current. Nothing in the output says when it was computed. The fix is cheap and almost always skipped: store the build timestamp in the table and surface it on the dashboard.
  • Inconsistency between models. Two derived tables refreshed at different times, joined together, produce totals that reconcile to nothing. This is worse than either being stale alone, because the error is invisible in each input.
  • Stale-forever. The refresh job fails, alerting is on the job rather than on the data, and everyone keeps reading a table frozen weeks ago. Inference Alert on data freshness, not job success. A job that succeeds while producing nothing is the most common way this happens.

Incremental models and the watermark

Rebuilding a whole model every night is simple and, for years of history, wasteful. An incremental model processes only new data. The mechanism is a watermark: a value recorded in the target table — usually the maximum timestamp already processed — that becomes a predicate on the source. Because it is a plain range predicate on the source column, it also pushes down into the scan, which is what makes the pattern cheap rather than merely tidy.

FMCG The block below builds a daily revenue model as of the end of June, then extends it incrementally from its own watermark. Run it as one script.

sqlincremental_watermark.sql
-- 1. Initial build: everything up to the end of June 2024.
CREATE OR REPLACE TABLE daily_revenue AS
SELECT o.order_date AS revenue_date,
       sum(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0)) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
  AND o.order_date < DATE '2024-07-01'
GROUP BY o.order_date;

-- 2. Incremental run: read the watermark from the target, then load
--    only source rows beyond it. COALESCE makes the first run work
--    on an empty target without a special case.
INSERT INTO daily_revenue
SELECT o.order_date,
       sum(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100.0))
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'completed'
  AND o.order_date > (SELECT coalesce(max(revenue_date), DATE '1900-01-01')
                      FROM daily_revenue)
GROUP BY o.order_date;

-- 3. Confirm the model now spans the full history.
SELECT count(*)          AS days_loaded,
       min(revenue_date) AS first_day,
       max(revenue_date) AS last_day,
       round(sum(revenue), 2) AS total_revenue
FROM daily_revenue;
Result
days_loadedfirst_daylast_daytotal_revenue
212024-01-082025-01-096360.85

Twelve days were loaded by the initial build and nine by the incremental step, with no overlap and no gap. Note the strict > against the stored maximum: with >= the boundary day would be inserted twice.

Common pitfall — late-arriving data walks straight past the watermark

A row for the 3rd arrives on the 5th, after the watermark has already advanced to the 4th. It fails order_date > watermark and is never loaded — permanently. Nothing errors; the totals for the 3rd are simply low forever. Fact The standard mitigations are a lookback window (reprocess the last N days every run and delete-then-insert that range, so corrections land) and, more robustly, watermarking on an ingestion timestamp rather than a business date, so “when we received it” drives the window while “when it happened” drives the grouping. The trap is that the naive version works perfectly in testing, where no data is late.

When incremental is the wrong answer

Incrementality is not a maturity level you graduate to. It buys compute and pays in correctness risk and complexity, and there are cases where full refresh is simply right.

  • Source rows mutate in place. If an order's status can change from completed to returned months later, a date-based watermark will never revisit it. Your model will insist revenue was earned that the business has since reversed. Either watermark on a last-modified column or refresh fully.
  • The transformation logic changed. New business rules apply to all history, not only to tomorrow's rows. Shipping a logic change without a full rebuild leaves a model whose old rows and new rows mean different things — the single most confusing failure in analytics engineering.
  • The full rebuild is affordable. If it runs in a few minutes there is nothing to buy. You would be trading a simple, self-healing job for a stateful one that can silently drift.
  • Aggregates are not decomposable. Sums and counts combine across batches; exact distinct counts and medians do not. You cannot merge yesterday's distinct-user count with today's. Either keep the underlying keys and recompute, use an approximate sketch designed to merge, or refresh fully.
  • Deletes happen upstream. An append-only watermark has no mechanism to notice a row that no longer exists. Only a full rebuild or an explicit reconciliation pass will remove it.

Inference A useful default: build the model as a full refresh, run it that way until the cost or the runtime actually hurts, then make it incremental — keeping the full-refresh path as a supported, tested command you can run whenever logic changes or the numbers look wrong.

Interview angle

“How would you make this nightly model incremental?” is a design question wearing a SQL costume. Sketching the watermark predicate earns a pass. What distinguishes a strong candidate is raising the failure modes before being asked: late-arriving records, mutable source rows, upstream deletes, non-decomposable aggregates, and what happens the day the business logic changes. Interviewers are listening for whether you know that incremental models trade correctness guarantees for compute — and whether you would keep a full-refresh escape hatch.

Exercise 12.13

Product analytics Build a snapshot table of sessions per day from sessions, but make it resilient to late-arriving events: instead of a strict watermark, reprocess the most recent three days on every run. Show the delete-then-insert pattern and confirm the row count is unchanged after a second run.

Show solution
sqlexercise_12_13.sql
CREATE OR REPLACE TABLE daily_sessions AS
SELECT CAST(started_at AS DATE) AS session_date,
       count(*) AS sessions
FROM sessions
GROUP BY CAST(started_at AS DATE);

-- Incremental run with a 3-day lookback: remove the window, rebuild it.
DELETE FROM daily_sessions
WHERE session_date >= (SELECT max(session_date) - 2 FROM daily_sessions);

INSERT INTO daily_sessions
SELECT CAST(s.started_at AS DATE), count(*)
FROM sessions AS s
WHERE CAST(s.started_at AS DATE) >=
      (SELECT coalesce(max(session_date), DATE '1900-01-01') FROM daily_sessions)
GROUP BY CAST(s.started_at AS DATE);

SELECT count(*) AS day_rows, sum(sessions) AS total_sessions
FROM daily_sessions;

Deleting the window before re-inserting it is what makes the run idempotent: running it twice produces the same table, so a retried job cannot double-count. The trade is explicit — you reprocess three days of source data every run in exchange for corrections up to three days late being picked up automatically. Choosing the window length is a business decision about how late your data realistically arrives, not a technical one. Note that the delete must come first; inserting and then deduplicating is both slower and easy to get wrong.

Knowledge check

An incremental model watermarks on order_date. Upstream, an order placed on the 3rd is cancelled on the 20th and its status column updated in place. What happens?

Key takeaway

Result cache, materialised view and snapshot table differ mainly in who owns correctness: the engine, the database definition, or you. Incremental models buy compute with a watermark predicate that pushes into the scan — and pay for it with late-arriving data, mutable rows, upstream deletes and non-decomposable aggregates. Make the refresh idempotent, alert on data freshness rather than job exit codes, and keep the full-refresh path working, because one day you will need it.

Lesson 12.14·18 min read

SQL Anti-Patterns That Destroy Performance

Five rewrites that recur in almost every slow query you will ever be handed — each shown as a runnable wrong/right pair with the mechanism that makes the difference.

Slow queries are not usually creative. Hand a hundred of them to an experienced engineer and the same handful of shapes account for most of the damage. Every one returns correct results — which is exactly why they survive code review. They are wrong about cost, not about answers.

The organising idea behind all five is sargability: whether a predicate can be used as a Search ARGument by the storage layer. A sargable predicate compares a bare column to something the engine computes once, so it can become an index seek, a range scan, or a min/max block skip. Anything that wraps the column, transforms it, or makes it depend on the row being examined destroys that property.

(a) A function around the column

Banking A transactions report filters to a calendar year. The natural way to write it is also the expensive one.

sqla_wrong.sql
-- WRONG: the column is wrapped in a function.
EXPLAIN
SELECT order_id
FROM orders
WHERE year(order_date) = 2024;
sqla_right.sql
-- RIGHT: a half-open range on the bare column. Same rows, sargable.
EXPLAIN
SELECT order_id
FROM orders
WHERE order_date >= DATE '2024-01-01'
  AND order_date <  DATE '2025-01-01';

Compare the two plans. The first produces a standalone FILTER ("year"(order_date) = 2024) node above the scan; the second puts order_date>='2024-01-01'::DATE AND order_date<'2025-01-01'::DATE inside the scan's own Filters list, with no separate filter node at all.

The mechanism is stored order. An index — or a min/max statistic on a storage block — records facts about order_date, and nothing about year(order_date). To evaluate the first version the engine must read a row, compute the function, and compare; there is no shortcut, so it reads them all. The second compares stored values directly, so an index can seek to the boundary and walk, and a columnar scanner can skip any block whose maximum is below 2024-01-01.

Note the half-open range rather than BETWEEN '2024-01-01' AND '2024-12-31'. If the column ever becomes a timestamp, BETWEEN silently drops everything after midnight on the last day. The half-open form is correct for dates and timestamps alike — worth making a habit rather than a decision.

The same rewrite exists for every wrapper: substr(code,1,3) = 'ABC' becomes code LIKE 'ABC%'; cast(id AS VARCHAR) = '5' becomes id = 5. Move the transformation off the column and onto the constant, which is evaluated once.

Analogy

A library sorts books by author surname. Ask for “surnames beginning with K” and the librarian walks to one shelf. Ask for “authors whose surname has seven letters” and she must take down every book and count — the sorting tells her nothing about letter counts. The catalogue is not broken; your question was simply phrased in terms the catalogue was not built on. Sargability is the discipline of phrasing questions in the terms your data is actually organised by.

(b) SELECT * instead of the columns you need

Digital media A reporting job joins orders to items to products and returns everything.

sqlb_wrong.sql
-- WRONG: 5 + 6 + 6 columns crossing every operator, three of them duplicated.
SELECT *
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products    AS p  ON p.product_id = oi.product_id
WHERE o.status = 'completed'
ORDER BY o.order_id
LIMIT 5;
sqlb_right.sql
-- RIGHT: name the four columns the report actually shows.
SELECT o.order_id,
       o.order_date,
       p.product_name,
       oi.quantity
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products    AS p  ON p.product_id = oi.product_id
WHERE o.status = 'completed'
ORDER BY o.order_id
LIMIT 5;
Result of the second query
order_idorder_dateproduct_namequantity
10012024-01-08Nimbus 0C Bag1
10012024-01-08Velocity Road 71
10022024-03-12Stormshell Jacket1
10032024-01-25Ridgeline Trail 32
10042024-02-09Basecamp 2P Tent1

Four mechanisms are at work. In a columnar store, unreferenced columns are physically separate, so naming four columns reads four columns' worth of storage rather than seventeen. In a row store, naming only indexed columns can make the query covering — answerable from the index alone. Every intermediate result — hash tables, sort buffers, spill files, network packets — is sized by row width, so wide rows spill to disk sooner. And SELECT * is a correctness hazard: add a column upstream and your output silently changes shape.

The nuance from Lesson 12.11 still applies: SELECT * inside a sub-query is generally harmless, because projection pushdown prunes it. It is the outermost one, which defines the real output, that costs you.

(c) Implicit casts on a join or filter column

Insurance Two systems disagree about types: policy ids arrive as text from one source and as integers from another. Someone makes the join work by casting.

sqlc_wrong.sql
-- WRONG: both sides of the join key are transformed to text per row.
SELECT o.order_id, o.order_date
FROM orders AS o
JOIN customers AS c
  ON CAST(c.customer_id AS VARCHAR) = CAST(o.customer_id AS VARCHAR)
WHERE c.channel = 'email'
ORDER BY o.order_id;
sqlc_right.sql
-- RIGHT: compare the native integer keys. Identical rows.
SELECT o.order_id, o.order_date
FROM orders AS o
JOIN customers AS c
  ON c.customer_id = o.customer_id
WHERE c.channel = 'email'
ORDER BY o.order_id;
Result (identical for both)
order_idorder_date
10112024-04-14
10222024-08-15

The plan for the first shows the join condition as CAST(customer_id AS VARCHAR) = CAST(customer_id AS VARCHAR). A cast is a function, so everything from case (a) applies — but a join key makes it worse in three ways. Integer comparison is a single machine instruction; string comparison walks bytes. Hash tables over strings must hash and store variable-length data instead of a fixed-width value. And the optimiser's statistics describe customer_id, not its text rendering, so cardinality estimates degrade and the join order it picks may be wrong.

The hazard is that this often happens without anyone writing CAST. Compare a VARCHAR column to a number, or an integer column to a quoted literal, and most engines insert the conversion silently. Fact Which side gets converted follows the engine's type-precedence rules, not which side you wrote first — so an apparently clean equality can be converting your indexed column on every row. The real fix is upstream: make the key one type in one place.

(d) OR across different columns

Telecommunications A support view wants any order that was returned or shipped to Sweden.

sqld_wrong.sql
-- WRONG: neither branch of the OR can reject a row on its own,
-- so no single index or block-skip is usable.
SELECT order_id, status, ship_country
FROM orders
WHERE status = 'returned'
   OR ship_country = 'SE'
ORDER BY order_id;
sqld_right.sql
-- RIGHT: two independently selective branches, made disjoint so
-- UNION ALL needs no deduplication.
SELECT order_id, status, ship_country
FROM orders
WHERE status = 'returned'
UNION ALL
SELECT order_id, status, ship_country
FROM orders
WHERE ship_country = 'SE'
  AND status IS DISTINCT FROM 'returned'
ORDER BY order_id;
Result (identical for both)
order_idstatusship_country
1005returnedSG
1011completedSE
1016returnedUS
1023completedSE
1024completedSE

The mechanism follows directly from Lesson 12.11. With AND, either condition alone is enough to reject a row, so the engine can push one into the scan and evaluate the other above. With OR, a row rejected by the first condition may still qualify through the second, so nothing can be pushed unless the engine can use both access paths and combine them. Some optimisers do exactly that (an index-union or bitmap-or); many will not, particularly across different tables or when only one column is indexed. The UNION ALL rewrite states the plan explicitly: two selective scans, concatenated.

Two details make the rewrite safe. Use UNION ALL, not UNION — plain UNION deduplicates, imposing a sort or hash over the whole result that can cost more than the OR did. And because it does not deduplicate, you must make the branches disjoint yourself: that is the job of status IS DISTINCT FROM 'returned', which unlike <> also behaves correctly when status is NULL.

Common pitfall — the OR hidden inside a parameter

The most damaging form of this pattern is the “optional filter” idiom: WHERE (:country IS NULL OR ship_country = :country). It looks elegant and it defeats the optimiser completely — the predicate's selectivity depends on a value not known at planning time, so a single cached plan must serve both the highly selective case and the match-everything case, and it will be wrong for one of them. Build the predicate list dynamically, or provide separate statements, and accept the small extra code in exchange for a plan that matches the query.

(e) A correlated scalar subquery in the SELECT list

FMCG A customer list needs each customer's completed-order count and most recent order date. The obvious approach adds one subquery per figure.

sqle_wrong.sql
-- WRONG: two correlated subqueries, each conceptually re-executed
-- per outer row, both scanning the same table.
SELECT c.customer_id,
       c.full_name,
       (SELECT count(*) FROM orders o
         WHERE o.customer_id = c.customer_id
           AND o.status = 'completed') AS completed_orders,
       (SELECT max(o.order_date) FROM orders o
         WHERE o.customer_id = c.customer_id
           AND o.status = 'completed') AS last_order
FROM customers AS c
ORDER BY c.customer_id
LIMIT 6;
sqle_right.sql
-- RIGHT: one pass over orders, both figures from one aggregate.
SELECT c.customer_id,
       c.full_name,
       count(o.order_id) FILTER (WHERE o.status = 'completed') AS completed_orders,
       max(o.order_date)  FILTER (WHERE o.status = 'completed') AS last_order
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.full_name
ORDER BY c.customer_id
LIMIT 6;
Result (identical for both)
customer_idfull_namecompleted_orderslast_order
1Amara Osei32025-01-09
2Bruno Salgado12024-01-25
3Chen Wei12024-02-09
4Dara Nolan12024-02-20
5Elif Demir32024-09-16
6Farid Haddad0NULL

The semantics of the correlated version are a nested loop: for every outer row, run the inner query. Two subqueries over a million customers is two million inner executions in the worst case. The join version is one scan of orders, one hash aggregate, one probe — a set operation instead of a loop.

Modern optimisers often decorrelate the subquery, rewriting it into a join automatically. But decorrelation is not guaranteed: it commonly fails when the subquery contains a LIMIT, a window function, a UDF, or a correlation buried under an OR. And even when it succeeds, two separate subqueries over the same table may be planned as two separate scans, whereas FILTER gets both figures from one. Inference Relying on decorrelation makes your cost depend on an optimiser capability you have not verified — a query that is fast today can regress on an engine upgrade.

Note the correctness detail that makes the rewrite equivalent: the LEFT JOIN keeps customers with no orders, and count(o.order_id) returns 0 for them because it counts non-null values. Farid Haddad's only order was cancelled, so he correctly shows 0 in both versions. An inner join, or count(*), would quietly change the answer — the general risk with this family of rewrites.

When these rewrites are the wrong move

Every item above is a default, not a law. Applying them mechanically produces unreadable SQL and occasionally slower SQL.

  • The table is small. On a dimension table of a few thousand rows, a correlated subquery is instantaneous and reads better than a join plus GROUP BY. Optimise the queries in your slow log, not the ones that offend you.
  • The predicate is not selective anyway. Making a filter sargable when it keeps most of the table converts a full scan into a full scan. The clarity may be worth it; the speed will not materialise.
  • An expression index already exists. If the database has an index on year(order_date), or statistics over a generated column, the function form is sargable. The rule is “match the predicate to what is indexed”, not “never call a function”.
  • SELECT * in exploration. Interrogating a table interactively with a LIMIT is exactly what it is for. The rule applies to queries that run repeatedly, in views, jobs and applications.
  • The UNION ALL branches are not selective or not disjoint. If both match most of the table you have doubled the scanning for nothing; and if you cannot make them disjoint cleanly you need UNION, whose deduplication may cost more than the OR did.
  • Readability has a price too. A correlated subquery that expresses a rule clearly, in a query that runs in under a second, beats a faster version nobody can safely modify.
Interview angle

“Here is a slow query — make it faster” is graded on process. Weak candidates start rewriting immediately. Strong candidates ask what “slow” means and how big the tables are, ask to see the plan, then work the checklist aloud: is any predicate wrapped in a function or cast; is the projection wider than the output needs; is there an OR that could be two selective branches; is anything correlated per row. The most valuable sentence you can offer is “I would verify the rewrite returns identical rows before shipping it” — because most real performance regressions are introduced by performance fixes.

Exercise 12.14

Retail The query below contains three of the five anti-patterns at once. Identify them and rewrite it. It should return, for each product sold in the second half of 2024, the product name and the number of completed order lines.

sqlexercise_before.sql
SELECT p.product_name,
       (SELECT count(*)
          FROM order_items oi2
          JOIN orders o2 ON o2.order_id = oi2.order_id
         WHERE oi2.product_id = p.product_id
           AND o2.status = 'completed'
           AND month(o2.order_date) >= 7
           AND year(o2.order_date) = 2024) AS lines_sold
FROM products AS p
ORDER BY lines_sold DESC, p.product_name
LIMIT 5;
Show solution

The three problems: a correlated scalar subquery in the SELECT list; two functions wrapping order_date, which pins the date filter above the scan; and a redundant re-scan of both fact tables per product row.

sqlexercise_after.sql
SELECT p.product_name,
       count(oi.order_item_id) AS lines_sold
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.product_id
LEFT JOIN orders      AS o  ON o.order_id = oi.order_id
                           AND o.status = 'completed'
                           AND o.order_date >= DATE '2024-07-01'
                           AND o.order_date <  DATE '2025-01-01'
WHERE o.order_id IS NOT NULL
GROUP BY p.product_name
ORDER BY lines_sold DESC, p.product_name
LIMIT 5;
Result
product_namelines_sold
Pacer GPS Watch Pro3
Ridgeline Trail 42
Basecamp 2P Tent1
Featherlite Jacket1
Nimbus 0C Bag1

The date filter is now a half-open range on a bare column, so it can be pushed into the orders scan and used for block skipping, and the per-product loop becomes one join and aggregate. The date conditions sit in ON rather than WHERE deliberately — a LEFT JOIN whose filter is in WHERE collapses back into an inner join — and the explicit WHERE o.order_id IS NOT NULL makes dropping unsold products visible rather than accidental.

Knowledge check

Why is WHERE year(order_date) = 2024 slower than the equivalent half-open range, even though both return the same rows?

Knowledge check

You rewrite WHERE a = 1 OR b = 2 as two branches combined with UNION ALL. What must you check first?

Key takeaway

Five shapes cause most avoidable slowness: a function or cast around a filtered column, SELECT * in production queries, type mismatches on join keys, OR across different columns, and correlated subqueries in the SELECT list. Each has the same cure — keep the column bare so the storage layer can use what it knows about it, and express work as one set operation rather than a per-row loop. Each has the same trap — the wrong version returns correct answers, so only the plan will tell you.

Part 13

SQL Engines & Dialect Differences

Postgres, MySQL, BigQuery, Snowflake, Redshift, Databricks, ClickHouse, SQL Server, Oracle, SQLite and DuckDB — and how to write portable SQL.

Lesson 13.1·14 min read

Why Dialects Exist: The Standard and Its Discontents

You will be able to predict which parts of a query will break when it moves engines, and look the difference up in one table instead of discovering it in production.

A query that has run every morning for two years is copied into a new warehouse and fails on line one. The column list is identical. The tables exist. The error says something about an unrecognised function. Retail The team had written SELECT first_name + ' ' + last_name against SQL Server; the new engine reads + between two strings as arithmetic and refuses.

Nothing was done wrong. SQL is a standard in the same sense that English is a language: there is an authoritative description, almost nobody implements all of it, and everybody has added words. The practical consequence is that “I know SQL” is roughly as precise as “I speak English” — true, and still not enough to order coffee in every city.

This lesson is the map. It explains why the divergence exists (which tells you where to expect more of it), then gives you a single comparison table covering the differences that actually cause outages. The nine lessons that follow take one engine each.

What the standard is, and what it is not

Fact SQL was standardised by ANSI in 1986 and by ISO in 1987, and has been revised repeatedly since — the widely-cited milestones being SQL-92 (the version most people mean by “standard SQL”), SQL:1999 (recursive queries, triggers), SQL:2003 (window functions, MERGE, XML), SQL:2011 (temporal tables), and SQL:2016 (JSON, row pattern matching). Each revision is a large document sold by ISO, not a free reference implementation.

That last point is the whole story. A standard with no reference implementation and no conformance test that anyone must pass is a suggestion with a version number. Vendors implement the parts their customers pay for, in the order their customers ask, and fill the gaps with their own syntax in the meantime. When the standard later specifies something the vendor already shipped differently, the vendor cannot break its users — so both forms survive forever.

Analogy

Think of the metric system versus the way people actually measure things. There is a precise international definition of a metre, and it is genuinely universal — and yet builders still order “two-by-four” timber, pilots still fly at flight levels in feet, and screen sizes are still inches everywhere. The standard exists, is correct, and is widely honoured for the core; the edges are governed by whatever the trade did first and cannot now afford to change. SQL's SELECT ... FROM ... WHERE is the metre. Date arithmetic is the two-by-four.

The four forces that create dialects

1. Age. Oracle shipped commercial SQL before the standard existed. Its DECODE, its (+) outer-join operator, and its treatment of the empty string as NULL all predate the rules that would later contradict them. Backward compatibility froze them in place.

2. Architecture. Syntax follows physics. An engine that stores data in immutable object-storage files cannot offer a cheap single-row UPDATE, so it offers MERGE and clustering instead of indexes. An engine that scans columnar blocks in parallel across a cluster gains nothing from a B-tree, so it never grew the index syntax at all. Differences that look cosmetic are usually the storage layer showing through.

3. Commercial incentive. Portable SQL is portable away. Distinctive, genuinely useful extensions — QUALIFY, semi-structured : access, UNNEST over nested arrays — raise the cost of leaving. Inference No vendor needs to be cynical about this for the effect to hold; simply prioritising the features that delight existing customers produces lock-in as a by-product.

4. Genuine progress. Some extensions are just better than the standard and get adopted later. Window functions were vendor extensions before SQL:2003. QUALIFY is a vendor extension now, present in several engines, absent from the standard, and obviously the right idea.

flowchart TD
  A["ISO/ANSI SQL standard
(paper, no implementation)"] --> B["Vendor reads it"] B --> C["Implements the profitable 60%"] B --> D["Invents syntax for the gaps"] D --> E["Customers write millions of lines against it"] E --> F["Standard later specifies it differently"] F --> G["Vendor supports BOTH forever"] G --> H["Dialect"]
Divergence is not a failure of discipline; it is the predictable output of a standards process with no enforcement and a customer base that cannot be broken. Expect it to widen, not narrow.

The portability table

This is the reference you will come back to. Every row is a difference that has caused a real production incident somewhere. Read it once now; the engine lessons expand each column.

EngineString concatCurrent timestampTruncate to month5 / 2 (both integers)
PostgreSQLa || b (NULL if either is NULL); concat() skips NULLsnow(), current_timestampdate_trunc('month', d)2 — truncates
MySQL / MariaDBCONCAT(a,b); || means OR by defaultNOW(), CURRENT_TIMESTAMPDATE_FORMAT(d,'%Y-%m-01')2.5 — returns decimal
SQLitea || bdatetime('now')date(d,'start of month')2 — truncates
DuckDBa || b; concat() skips NULLsnow(), current_timestampdate_trunc('month', d)2.5// for integer division
BigQueryCONCAT(a,b) or a || bCURRENT_TIMESTAMP() — parentheses requiredDATE_TRUNC(d, MONTH) — unit second, unquoted2; SAFE_DIVIDE for float
Snowflakea || b or CONCATCURRENT_TIMESTAMP()DATE_TRUNC('month', d)2.5 — returns numeric
Redshifta || bGETDATE(), SYSDATE, current_timestampDATE_TRUNC('month', d)2 — truncates
Databricks / SparkCONCAT(a,b) or a || bcurrent_timestamp()DATE_TRUNC('MONTH', d) or TRUNC(d,'MM')2.5 — ANSI mode affects this
ClickHouseconcat(a,b); || also worksnow()toStartOfMonth(d)2; intDiv() is explicit
SQL Server (T-SQL)a + b; CONCAT() skips NULLsGETDATE(), SYSDATETIME()DATETRUNC(month, d) (recent versions)2 — truncates
Oraclea || b; NULL treated as empty stringSYSDATE, SYSTIMESTAMPTRUNC(d,'MM')2.5 — all numbers are NUMBER
EngineRow limitingIdentifier quotingUnquoted identifier caseUPSERTThe gotcha that bites first
PostgreSQLLIMIT n OFFSET m; FETCH FIRST n ROWS"col"Folded to lowercaseINSERT ... ON CONFLICT (k) DO UPDATEGROUP BY is strict: every non-aggregated select item must be grouped
MySQL / MariaDBLIMIT n OFFSET m or LIMIT m, nBackticks `col`Columns insensitive; table case depends on the host filesystemINSERT ... ON DUPLICATE KEY UPDATEDefault collations are case-insensitive, so 'A' = 'a' is true
SQLiteLIMIT n OFFSET m"col", [col], backticks — all acceptedInsensitiveINSERT ... ON CONFLICT DO UPDATEDynamic typing: a declared INTEGER column will happily store 'banana'
DuckDBLIMIT n OFFSET m; QUALIFY supported"col"Insensitive, but original case preservedINSERT ... ON CONFLICT DO UPDATE/ is float division; use // when you want integer semantics
BigQueryLIMIT n OFFSET mBackticks `project.dataset.table`Column names insensitive; dataset and table names are case-sensitiveMERGESELECT * on a wide partitioned table can scan — and bill for — far more than intended
SnowflakeLIMIT n or FETCH FIRST n ROWS; QUALIFY"col"Folded to UPPERcaseMERGEQuoting a lowercase name creates a column you must quote forever after
RedshiftLIMIT n OFFSET m; TOP n also accepted"col"Folded to lowercaseMERGE; historically delete-then-insertA wrong DISTKEY causes cluster-wide data redistribution on every join
Databricks / SparkLIMIT n; QUALIFY supportedBackticks `col`InsensitiveMERGE INTONon-ANSI mode silently returns NULL where ANSI mode raises an error
ClickHouseLIMIT n OFFSET m; LIMIT n BY colBackticks or "col"Case-sensitive, and function names are camelCaseINSERT + ReplacingMergeTree, or ALTER TABLE ... UPDATEDeduplication by merge is eventual; a plain SELECT may still see duplicates
SQL ServerSELECT TOP n; OFFSET ... FETCH NEXT (needs ORDER BY)[col] or "col"Depends on database collation; commonly insensitiveMERGE or UPDATE then INSERTDefault collation makes WHERE name = 'ACME' match 'Acme'
OracleFETCH FIRST n ROWS ONLY; older code uses ROWNUM"col"Folded to UPPERcaseMERGE'' is stored as NULL, so WHERE x = '' never matches anything
💡Intuition — the three tiers of portability

Sort every construct you write into three buckets. Tier 1 — universal: SELECT, WHERE, JOIN, GROUP BY, HAVING, ORDER BY, CASE, COALESCE, CAST, common table expressions, and the core window functions. These move anywhere. Tier 2 — same idea, different spelling: date truncation, string concatenation, row limiting, string aggregation, UPSERT. These always exist and always need translation. Tier 3 — genuinely engine-specific: array and JSON manipulation, sampling, geospatial, table hints, distribution keys. These may have no equivalent at all. Inference If you keep 90% of your code in tier 1, migration is a week; if tier 3 is scattered through it, migration is a quarter.

Writing in the portable core

E-commerce Here is a genuinely useful query written entirely in tier 1. It runs on DuckDB, and would run unchanged on PostgreSQL, Snowflake, BigQuery, Databricks and SQL Server:

sqlportable_core.sql
-- Revenue and order count per department, completed orders only.
-- Every construct here is in the portable tier-1 core.
SELECT c.department,
       COUNT(DISTINCT o.order_id)                                   AS orders,
       SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100)) AS revenue
FROM orders      AS o
JOIN order_items AS oi ON oi.order_id   = o.order_id
JOIN products    AS p  ON p.product_id  = oi.product_id
JOIN categories  AS c  ON c.category_id = p.category_id
WHERE o.status = 'completed'
GROUP BY c.department
ORDER BY revenue DESC;
Result
departmentordersrevenue
Footwear112192.45
Accessories51710.25
Outdoor51275.10
Apparel51183.05

Note what is absent: no date function, no string concatenation, no row limiting with ties, no engine-specific division helper. The one arithmetic risk — discount_pct/100 — is dodged because discount_pct is NUMERIC, not an integer. Had it been an integer column, discount_pct/100 would evaluate to 0 on PostgreSQL, Redshift and SQL Server and to a fraction on MySQL, Snowflake and Oracle. Same query, silently different money.

Common pitfall — the difference that does not raise an error

The dangerous dialect differences are the ones that succeed. A missing function throws immediately and you fix it in a minute. Integer division silently returning zero, a case-insensitive collation quietly merging 'ACME' and 'Acme' into one group, NULL sorting first instead of last, an Oracle empty string that is really a NULL — these produce a number, the number is wrong, and the report is signed off. Inference When porting, spend your review time on arithmetic, sorting, grouping keys and NULL handling — not on the functions that already refused to compile.

When you should not write portable SQL

Portability is a cost, and most teams over-pay it. Reasons to abandon it deliberately:

  • You are not going to migrate. The expected cost of a rewrite you will never do is zero. Writing everything in a lowest-common-denominator dialect to protect against a hypothetical is a real tax paid for an imaginary benefit.
  • The extension is why you bought the engine. If you chose an engine for its semi-structured handling or its QUALIFY or its clustering, refusing to use them means paying for a product you have disabled.
  • The portable version is worse. A self-join emulating a window function is slower and harder to read than the window function. Portability that degrades correctness or clarity is a bad trade.
  • A transpiler is cheaper. Tools that parse SQL into an abstract syntax tree and re-emit it in another dialect handle mechanical tier-2 translation well, which moves the cost from “every query, forever” to “once, at migration time”.

Open Whether convergence is happening is genuinely contested. Optimists point to widespread adoption of window functions, CTEs and MERGE; pessimists point out that the newest and fastest-growing areas — semi-structured data, vector search, streaming, machine-learning functions — are exactly where dialects diverge most, because that is where the competition is. Both observations are true.

In the field

The realistic pattern on a data team is not one engine but three: a transactional database behind the application, an analytical warehouse, and something embedded for local development and tests. Analysts therefore write in two or three dialects a week without noticing they have switched. The teams that suffer least keep a short internal page — often literally a table like the one above — naming the handful of constructs that differ between their engines. Not the whole standard. The six things their own people get wrong.

Interview angle

Interviewers rarely test dialect trivia, because it is lookup-able. What they do test is whether you notice when it matters. A good move mid-problem is to ask “which engine should I target?” and, on being told it does not matter, say “then I'll write ANSI and flag the two places that would need changing.” If you are then asked to translate one construct — usually row limiting or date truncation — the point is not memory, it is that you know a difference exists and can describe its shape. “Every engine has a way to truncate a date to a month; the argument order and quoting differ” scores better than a half-remembered function name.

Exercise 13.1

Marketing You are handed this query, written against SQL Server, and asked to move it to a warehouse. Identify every construct that will not survive the move, and rewrite it in portable SQL against the ad_spend table.

tsqloriginal_tsql.sql
SELECT TOP 5
       channel + ' / ' + campaign AS label,
       DATETRUNC(month, spend_date) AS mth,
       SUM(clicks) / SUM(impressions) AS ctr
FROM ad_spend
GROUP BY channel + ' / ' + campaign, DATETRUNC(month, spend_date)
ORDER BY ctr DESC;
Show solution

Four problems, in increasing order of danger:

  1. TOP 5 — T-SQL only. Portable form is LIMIT 5 (or FETCH FIRST 5 ROWS ONLY).
  2. + for string concatenation — means addition nearly everywhere else. Use ||, or CONCAT() if you also want NULLs skipped rather than propagated.
  3. DATETRUNC(month, ...) — the unit is an unquoted keyword and comes first. Most engines want date_trunc('month', d); BigQuery wants the date first and the unit second.
  4. SUM(clicks) / SUM(impressions) — the silent one. Both operands are integers, so on SQL Server, PostgreSQL and Redshift this returns 0 for every row. The dashboard shows a flat zero click-through rate and nobody suspects the SQL. It must be cast, and divided defensively.
sqlported_portable.sql
SELECT channel || ' / ' || campaign            AS label,
       date_trunc('month', spend_date)        AS mth,
       CAST(SUM(clicks) AS DECIMAL(18,6))
         / NULLIF(SUM(impressions), 0)        AS ctr
FROM ad_spend
GROUP BY channel || ' / ' || campaign, date_trunc('month', spend_date)
ORDER BY ctr DESC
LIMIT 5;

The CAST forces decimal arithmetic on every engine regardless of its integer-division rule, and NULLIF turns a divide-by-zero into a NULL rather than an error or an infinity. Both are worth writing even on engines that would have been fine, because they make the query's intent explicit rather than dependent on the engine's defaults.

Knowledge check

A query moved from MySQL to PostgreSQL now returns 0 for a conversion-rate column that previously showed sensible decimals. Both columns are INTEGER. What happened?

Knowledge check

Why do engines that store data in immutable files on object storage tend to offer MERGE rather than ON CONFLICT DO UPDATE?

Key takeaway

Dialects exist because a standard without enforcement, met by vendors who cannot break their customers, produces permanent divergence. Sort every construct into three tiers: the universal core that moves anywhere, the same-idea-different-spelling group (dates, concatenation, row limiting, UPSERT) that always needs translating, and the genuinely engine-specific features that may have no equivalent. Fear the silent differences — integer division, collation, NULL ordering — far more than the loud ones, because only the loud ones fail fast.

Lesson 13.2·14 min read

PostgreSQL

You will be able to explain what MVCC costs you, why VACUUM exists, and when a row-store transactional database is the wrong home for your analytics.

An engineering team runs its product on PostgreSQL. It works beautifully. Then the analytics team starts querying the same database directly, and one Monday a dashboard query scanning two years of orders runs for eleven minutes while checkout latency doubles. Nothing is broken. The database is doing exactly what it was designed to do — which is not this.

PostgreSQL is the default correct answer to “which database should we start with?” more often than any other engine, and understanding why also tells you precisely where it stops being the answer.

Origin and design goal

Fact PostgreSQL descends from the POSTGRES project at the University of California, Berkeley, begun in the 1980s under Michael Stonebraker as a successor to Ingres. It gained a SQL interface in the mid-1990s and has been developed since by an independent global community under a permissive open-source licence, with no single owning company.

That governance shapes the product. Because no vendor's roadmap dominates, PostgreSQL optimises for correctness and extensibility rather than for a segment. Its catalogue is itself relational, which is why the extension system is unusually deep: types, operators, index methods, procedural languages and foreign data wrappers can all be added by third parties without forking the engine. Full-text search, geospatial, time-series and vector search all reached PostgreSQL as extensions rather than core rewrites.

Analogy

PostgreSQL is a workshop with a standard rail system. The bench is solid and the basic tools are excellent, but the reason professionals choose it is the rail: any attachment that fits the standard mount works, so the shop grows with the trade rather than being replaced. A specialised machine will always beat it at the one job it was built for. Nothing beats it at the number of jobs it can do adequately without buying a second workshop.

Storage and execution: heap pages, MVCC and the price of it

PostgreSQL is a row store: a table is a sequence of 8 kB pages, each holding whole rows. Reading one column of one row costs the same page read as reading all of them — excellent when you want the whole row (a customer record for a profile page), wasteful when you want one column of ten million rows (a revenue sum).

Concurrency uses multi-version concurrency control. An UPDATE does not overwrite a row. It writes a new version of the row and marks the old one as dead beyond a certain transaction id. Readers see whichever version was current when their statement began. The consequence is the property people love: Fact readers never block writers and writers never block readers.

  UPDATE orders SET status='returned' WHERE order_id=1005;

  page 42 before          page 42 after
  +------------------+    +------------------------------+
  | 1005 completed   |    | 1005 completed  [dead @ tx99]|
  |                  |    | 1005 returned   [live @ tx99]|
  +------------------+    +------------------------------+
                            ^ old version still occupies space
                              until VACUUM reclaims it

The price is bloat. Dead row versions occupy pages until the VACUUM process reclaims them. A table churned by frequent updates can grow far larger than its live data, and every sequential scan then reads the dead space too. Autovacuum handles this in normal operation; it falls behind under sustained heavy write load, and a table that is 90% dead tuples is one of the classic causes of “the database got slow and nothing changed”.

💡Intuition — why indexes matter here and not everywhere

A B-tree index turns “find the 3 rows where customer_id = 5” from reading every page into reading about four. That is a thousand-fold saving on a selective lookup, which is why transactional databases live and die by their indexes. On an analytical engine that scans a compressed column at gigabytes per second and needs 60% of the rows anyway, the same index would be pure overhead — which is why several engines in this Part have no index syntax at all. Indexes are not universally good; they are good for selective access.

The syntax that actually bites

PostgreSQL is close to the standard, so the surprises come from it being stricter than what people are used to:

  • Identifier case folding. Unquoted identifiers are folded to lowercase — the standard says upper, so even here it diverges. SELECT MyCol looks for mycol. If a tool created the column as "MyCol" with quotes, you must quote it forever.
  • GROUP BY is strict. Every non-aggregated select item must appear in the GROUP BY. MySQL historically allowed otherwise; queries ported from it fail loudly here, which is the good kind of failure.
  • Integer division truncates. 5/2 is 2. Cast before dividing.
  • || propagates NULL. 'a' || NULL is NULL, whereas concat('a', NULL) is 'a'. Choose deliberately.
  • NULLs sort last in ascending order by default; NULLS FIRST/NULLS LAST override it. Other engines default the other way.
  • Type names are its own: TEXT (unbounded, no penalty versus VARCHAR(n)), TIMESTAMPTZ, NUMERIC, BOOLEAN, SERIAL/IDENTITY, and native JSONB, arrays and ranges.
  • CAST shorthand is value::type, which is compact, ubiquitous in real code, and portable nowhere.

Arrays and JSON are first-class rather than bolted on. JSONB stores parsed, binary JSON that can be indexed with GIN, so a containment predicate on a JSON document can use an index instead of scanning. Window function coverage is complete, including GROUPS framing and EXCLUDE clauses; QUALIFY is not supported, so you wrap in a subquery.

SaaS The UPSERT is the syntax people come to PostgreSQL for. It is genuinely atomic, unlike an application-level check-then-insert, and it runs on DuckDB too:

sqlupsert_on_conflict.sql
-- Nightly plan sync: insert new subscriptions, update MRR on existing ones.
-- One statement, no race between the check and the write.
INSERT INTO subscriptions (subscription_id, customer_id, plan, mrr, started_on, cancelled_on)
VALUES (9, 12, 'enterprise', 199.00, DATE '2024-07-08', NULL)
ON CONFLICT (subscription_id) DO UPDATE
  SET plan = EXCLUDED.plan,
      mrr  = EXCLUDED.mrr;

SELECT subscription_id, customer_id, plan, mrr
FROM subscriptions
WHERE subscription_id = 9;
Result
subscription_idcustomer_idplanmrr
912enterprise199.00

EXCLUDED is the pseudo-table holding the row you tried to insert. Without ON CONFLICT you would SELECT, branch in application code, then INSERT or UPDATE — and two workers doing that concurrently produce either a duplicate-key error or a lost update.

Where PostgreSQL is unusually strong is expressing a whole analytical result in one statement. This runs on DuckDB unchanged, and would run on PostgreSQL unchanged too:

sqlfiltered_aggregate.sql
-- FILTER is standard SQL and reads far better than SUM(CASE WHEN ...).
SELECT c.channel,
       COUNT(*)                                        AS customers,
       COUNT(*) FILTER (WHERE o.order_id IS NOT NULL)  AS with_orders,
       ROUND(100.0 * COUNT(*) FILTER (WHERE o.order_id IS NOT NULL)
             / NULLIF(COUNT(*), 0), 1)                 AS pct_ordering
FROM customers AS c
LEFT JOIN (SELECT DISTINCT customer_id, order_id FROM orders) AS o
       ON o.customer_id = c.customer_id
GROUP BY c.channel
ORDER BY customers DESC, c.channel;

Scaling model

PostgreSQL scales vertically first: bigger machine, more memory, faster disk. Reads scale out through streaming replicas. Writes do not scale out natively — there is one primary accepting writes — and going beyond it means sharding at the application layer or adopting a distributed fork or extension.

It is free software, so the cost model is people and infrastructure rather than licences. Managed offerings from every major cloud change that to a per-hour instance charge, which usually beats running it yourself once you count the cost of someone being on call for it.

Common pitfall — running analytics on the production primary

The single most common PostgreSQL mistake is not a syntax error, it is an architecture decision made by default. Analytics queries are long, scan-heavy and memory-hungry; transactional queries are short, selective and latency-sensitive. Put both on one instance and the analytics workload evicts the transactional working set from the buffer cache, and checkout gets slow every time someone opens a dashboard. Long-running read transactions also hold back the vacuum horizon, so bloat accumulates while the report runs. Inference The cheap fix is a read replica; the durable fix is a separate analytical engine. Either is cheaper than debugging intermittent latency for a quarter.

When to choose it — and when not to

Choose PostgreSQL when you need transactional correctness with real constraints and foreign keys; when your workload is many small selective reads and writes; when you want one system to handle relational, JSON, full-text and geospatial data rather than four; when you want no licence cost and no vendor dependency; or when you simply do not yet know what you are building — it is the safest default precisely because it is adequate at almost everything.

Do not choose it when the workload is scanning hundreds of millions of rows to aggregate two columns — a columnar engine will be faster by orders of magnitude for a fraction of the hardware; when write throughput exceeds what one primary can absorb and you are unwilling to shard; when you need elastic separation of storage and compute so a heavy query does not compete with anything else; or when the data is genuinely schemaless and churning weekly, where the schema is friction rather than protection.

In the field

A very common and healthy trajectory: everything starts in PostgreSQL, including the reporting. At some scale the reporting queries begin to hurt the application, so read replicas are added. Later the replicas are not enough and a columnar warehouse appears, fed by change-data-capture from PostgreSQL. PostgreSQL never goes away — it remains the system of record. Teams that skip straight to a warehouse on day one often end up running two systems to serve ten thousand rows, which is a real cost paid for a scale they have not reached.

Interview angle

PostgreSQL is the most common engine for take-home tests and live coding, so its strictness is what you are implicitly assessed against: exhaustive GROUP BY, integer division truncating, NULL-safe comparison with IS DISTINCT FROM. In system-design rounds, “why not just use PostgreSQL?” is a strong probe — interviewers want to hear a threshold, not a preference. Answers like “it's not web-scale” are weak. “It handles this fine until either single-primary write throughput or full-scan analytics becomes the bottleneck; here is which one I'd expect to hit first and how I'd measure it” is the answer.

Exercise 13.2

Banking A colleague reports that this query “loses customers”: it returns fewer rows than there are customers, and the ones missing are those with no recorded country. Explain why, then rewrite it so that every customer appears exactly once with a readable label.

sqlbroken_label.sql
SELECT full_name || ' (' || country || ')' AS label
FROM customers
WHERE full_name || ' (' || country || ')' IS NOT NULL;
Show solution

|| propagates NULL: if any operand is NULL the whole expression is NULL. Customers 5 and 9 have no country, so their label is NULL and the WHERE clause then removes them entirely. The bug is not the filter — it is that concatenation destroyed the row's identity before the filter ever saw it.

Two fixes, with different meanings. Use COALESCE when the absence should be visible; use concat() when it should simply be skipped.

sqlfixed_label.sql
SELECT customer_id,
       full_name || ' (' || COALESCE(country, 'unknown') || ')' AS label_explicit,
       concat(full_name, ' (', country, ')')                    AS label_skipping
FROM customers
ORDER BY customer_id
LIMIT 6;

Run it and compare rows 5 and 9. label_explicit reads Elif Demir (unknown) — the reader knows the value is missing. label_skipping reads Elif Demir () — tidier, and it hides the fact. For a customer-facing display the second may be right; for a data-quality review the first is, because it makes the gap countable. The lesson beneath both: never let a presentation expression sit in a WHERE clause, because filtering on a formatted string couples row survival to formatting.

Knowledge check

A PostgreSQL table receiving heavy updates has grown to several times the size of its live data and scans have slowed. What is the most likely cause?

Key takeaway

PostgreSQL is a row-store, MVCC, single-primary transactional database that is standards-strict and extensible to an unusual degree — which makes it the correct default when you do not yet know what you need. Its architecture explains both its strengths (non-blocking concurrency, indexed selective access, real constraints) and its costs (bloat requiring vacuum, no native write scale-out, poor fit for full-table analytical scans). Leave it when your queries stop being selective, not when someone tells you it will not scale.

Lesson 13.3·13 min read

MySQL & MariaDB

You will be able to explain why the world's most widely deployed open-source database has the least standard semantics, and which of its defaults you must change before trusting a number.

An analyst reports that a customer count is wrong. The query groups by company name and returns 4,180 rows; a manual count of the distinct names in a spreadsheet gives 4,265. SaaS Nothing is duplicated and nothing is missing. The database has decided that Acme Ltd and ACME LTD are the same string, because the default collation says so, and has merged them into one group.

This is MySQL's characteristic failure mode and its characteristic virtue in one: it makes helpful assumptions. For a web application storing usernames, case-insensitive matching is what you wanted and saves you writing LOWER() a thousand times. For an analytical group-by it is a wrong answer with no error message.

Origin and design goal

Fact MySQL was created in the mid-1990s and became the database half of the LAMP stack that carried the first web boom. It was acquired by Sun Microsystems and subsequently by Oracle Corporation. MariaDB is a community fork begun by MySQL's original developers after that acquisition; the two share ancestry and much syntax but have diverged in features, storage engines and optimiser over time.

Its founding goal was speed and simplicity for read-heavy web workloads, at a time when the alternatives were expensive, complex, or both. Early MySQL deliberately shipped without transactions, without foreign keys and without subqueries, because the target user was a website reading a lot and writing a little, and omitting those things made it fast and easy to install. Each was added later. Inference Almost every unusual MySQL behaviour is a fossil of that trade: features arrived after the ecosystem had already formed habits around their absence, so the defaults stayed permissive.

Analogy

MySQL is the autocorrect of databases. Type an approximate thing and it produces a plausible thing without complaining. That is genuinely wonderful when you are typing quickly and the cost of a small error is nil. It is dangerous when you are typing a legal document, because the correction is silent and looks exactly like what you meant. The engineering response is the same in both cases: keep it on for casual use, turn it off where the output is going to be trusted.

Storage and execution: pluggable engines and the clustered index

MySQL's distinguishing architectural idea is pluggable storage engines: the SQL layer is separate from the component that actually stores rows, and you can choose per table. In practice one engine dominates modern use — InnoDB — which provides transactions, row-level locking, MVCC and crash recovery. The older MyISAM engine has none of those; it is table-locking and non-transactional, and encountering it in a live system is a strong signal of neglected legacy.

InnoDB stores tables as a clustered index: the table is a B-tree ordered by primary key, with the full row stored in the leaf. Two consequences follow directly, and both are frequently-tested knowledge:

  • Primary key lookups are one traversal — the row is right there in the leaf, with no second fetch.
  • Secondary indexes store the primary key, not a row pointer. Looking up by a secondary index therefore costs two traversals: one to find the primary key, one to fetch the row. This is why an unnecessarily wide primary key (a long UUID string rather than a compact integer) inflates every secondary index on the table.
  InnoDB clustered index on orders(order_id)

            [ 1010 | 1018 ]
           /       |        \
   [1001..1009]  [1010..1017]  [1018..1025]
    full rows     full rows     full rows

  Secondary index on (customer_id) stores customer_id -> order_id,
  then a second descent into the tree above to reach the row.

The syntax that actually bites

MySQL diverges more than any other mainstream engine, and most of it is quiet:

  • || means OR, not concatenation, unless the PIPES_AS_CONCAT SQL mode is enabled. Use CONCAT(a, b). Note that CONCAT here returns NULL if any argument is NULL, unlike PostgreSQL's concat(); CONCAT_WS skips them.
  • Backticks quote identifiers, not double quotes — unless ANSI_QUOTES mode is on, in which case double quotes stop being string delimiters.
  • Table name case sensitivity depends on the host filesystem. The same schema is case-insensitive on a typical Windows or macOS development laptop and case-sensitive on a Linux server. This produces the genuinely maddening bug class of code that works locally and fails only in production.
  • Default collations are case-insensitive and accent-insensitive. WHERE name = 'acme' matches 'ACME'; GROUP BY name merges them. This is a data-correctness issue, not a cosmetic one.
  • Integer division returns a decimal. 5/2 is 2.5. DIV is the integer operator. Convenient, non-standard, and it means a query gaining correctness when ported to MySQL and losing it when ported away.
  • Dates. DATE_ADD(d, INTERVAL 1 MONTH) and DATE_FORMAT(d, '%Y-%m') rather than date_trunc. There is no date_trunc; truncation is done by formatting.
  • Zero dates. Historically '0000-00-00' was an accepted DATE value, which is not a date and breaks every client library. Strict mode rejects it; legacy data still contains it.
  • ONLY_FULL_GROUP_BY is now on by default in modern versions, but older systems allow selecting an ungrouped, unaggregated column and return an arbitrary row's value for it. Silently.

Type names are mostly familiar with additions: TINYINT(1) stands in for boolean (BOOLEAN is an alias for it, so it is genuinely an integer), DATETIME versus TIMESTAMP differ in range and time-zone conversion, and ENUM exists and is best avoided because changing its allowed values rewrites the table definition.

Arrays do not exist. JSON has been a native type since MySQL 5.7 with a reasonable function set and generated-column indexing. Fact Window functions and common table expressions arrived in MySQL 8.0 — meaning a great deal of MySQL code in the wild is written in a style that predates them, using self-joins and user variables to emulate running totals. Recognising that style is a useful skill when reading old code.

mysqlmysql_idioms.sql
-- Distinctly MySQL. None of this runs elsewhere unchanged.
SELECT CONCAT(c.full_name, ' <', COALESCE(c.email,'none'), '>') AS label,
       DATE_FORMAT(c.signup_date, '%Y-%m')                      AS signup_month,
       DATEDIFF(CURDATE(), c.signup_date)                       AS days_since,
       COUNT(o.order_id) DIV 1                                  AS orders
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, label, signup_month, days_since
ORDER BY orders DESC
LIMIT 10;

-- UPSERT: the pre-standard form, still the idiomatic one.
INSERT INTO subscriptions (subscription_id, customer_id, plan, mrr, started_on)
VALUES (11, 14, 'pro', 49.00, '2024-08-15')
ON DUPLICATE KEY UPDATE plan = VALUES(plan), mrr = VALUES(mrr);

The equivalent portable version, which does run on DuckDB, shows how little of that was essential:

sqlportable_equivalent.sql
SELECT c.full_name || ' <' || COALESCE(c.email, 'none') || '>' AS label,
       date_trunc('month', c.signup_date)                      AS signup_month,
       COUNT(o.order_id)                                       AS orders
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.full_name, c.email, c.signup_date
ORDER BY orders DESC, c.customer_id
LIMIT 5;
Result
labelsignup_monthorders
Amara Osei <amara@example.com>2024-01-013
Elif Demir <elif@example.com>2024-03-013
Jolene Pierce <jolene@example.com>2024-05-013
Chen Wei <chen@example.com>2024-02-012
Hana Kobayashi <hana@example.com>2024-04-012

Scaling model

MySQL scales the same shape as PostgreSQL: one primary for writes, replicas for reads, vertical growth first. Its replication is asynchronous by default, which means a read replica can serve stale data — the classic bug being a user who updates their profile, is redirected to a page served by a replica, and sees the old value. Semi-synchronous and group replication options tighten this at a latency cost.

Beyond one primary, the ecosystem's answer has historically been application-level sharding and proxy layers, plus a family of distributed systems that speak the MySQL wire protocol while storing data quite differently underneath. Being free software, the cost model is again infrastructure and people; managed cloud offerings are ubiquitous and cheap at small sizes.

Common pitfall — trusting utf8

MySQL's character set historically named utf8 is a three-byte encoding that cannot represent characters outside the Basic Multilingual Plane — which in practice means emoji and some CJK characters. Inserting one either raises an error or truncates the string at the offending character, depending on strict mode. The four-byte, actually-complete encoding is named utf8mb4. Inference Any older schema that has not been explicitly converted is likely still on the three-byte version, and the symptom — user reviews mysteriously cut short mid-sentence — looks like an application bug rather than a database setting.

When to choose it — and when not to

Choose MySQL or MariaDB when you are running a read-heavy transactional web application; when your team, hosting, or framework ecosystem already assumes it; when you want the widest possible pool of operational experience and hosting options; or when you are inheriting one and the migration cost exceeds the divergence cost, which it usually does.

Do not choose it when you want standards-strict semantics you can reason about without consulting a mode table — PostgreSQL is the better default for new projects on that basis alone; when you need rich types (arrays, ranges, real booleans) or a deep extension ecosystem; when the workload is analytical scanning, where a columnar engine wins as decisively as it does over PostgreSQL; or when correctness of string comparison and grouping matters more than convenience, unless you are prepared to set collations explicitly on every text column.

In the field

Analysts given read access to a production MySQL database should establish three things before publishing any number from it. First, the collation of any column used in a GROUP BY or an equality join — a case-insensitive collation changes group counts. Second, whether ONLY_FULL_GROUP_BY is enabled, because if it is not, an accidentally ungrouped column returns an arbitrary value with no warning. Third, whether they are reading a replica and how far it lags, because “yesterday's total changed overnight” is usually replication, not a data bug.

Interview angle

Two MySQL questions recur. The first is the clustered-index one: “why does a UUID primary key hurt more in InnoDB than elsewhere?” — because the table is physically ordered by it, so random UUIDs cause page splits and poor locality on insert, and because every secondary index carries a copy of it. The second is the group-by one: “what does this query return if ONLY_FULL_GROUP_BY is off?” The expected answer is not a value, it is the phrase “an arbitrary row's value, which is why the mode exists”.

Exercise 13.3

Digital media A report groups viewers by country. On MySQL with default collation the country column contains a mixture of 'gb', 'GB' and 'Gb'. Explain what the MySQL result differs from, then write a query against customers that produces the case-insensitive grouping explicitly, so the same SQL gives the same answer on any engine.

Show solution

On MySQL with a case-insensitive collation, all three spellings collapse into a single group — and the label shown for that group is whichever spelling the engine happened to pick. On PostgreSQL, DuckDB or Snowflake the same query produces three separate groups. Neither is wrong; they are answering different questions, and the query does not say which one it means.

The fix is to stop relying on the collation and normalise in the query, which also makes the intent readable:

sqlexplicit_ci_group.sql
-- UPPER() makes the case-folding a stated decision rather than a setting.
-- COALESCE keeps customers with no country visible instead of silently dropped.
SELECT COALESCE(UPPER(country), 'UNKNOWN') AS country_key,
       COUNT(*)                            AS customers
FROM customers
GROUP BY COALESCE(UPPER(country), 'UNKNOWN')
ORDER BY customers DESC, country_key
LIMIT 5;

Two properties make this the right answer beyond mere portability. It is explicit: a reviewer can see that case was folded deliberately. And it is total: the COALESCE means a missing country becomes a visible bucket rather than a NULL group that some downstream chart quietly omits. The cost is that a function on the column prevents a plain index on country from being used — on a large table you would add a functional index or a normalised column instead.

Knowledge check

A query using a || b to build a label works on PostgreSQL but returns 0 and 1 values on MySQL. Why?

Key takeaway

MySQL optimised for permissiveness and speed on read-heavy web workloads, and its unusual semantics are fossils of features that arrived after the habits formed. The three that will cost you real money are case-insensitive default collations silently merging groups, || meaning OR, and table-name case sensitivity varying with the host filesystem. InnoDB's clustered index makes primary key choice unusually consequential. It is an excellent application database and a poor analytical one — which describes most of this Part's first half.

Lesson 13.4·14 min read

SQLite & DuckDB: The Embedded Engines

You will be able to argue when a database with no server at all is the correct production choice, and why two engines with the same deployment model sit at opposite ends of the workload spectrum.

Every other engine in this Part is a server. You start a process, it listens on a port, you connect over a network, and something has to operate it. The two engines here are libraries. They run inside your process, there is no port and no daemon, and the “connection” is a function call. That single architectural fact changes what they are good at more than any syntax difference in this whole Part.

They are the pair worth learning together because they answer opposite questions with the same deployment model. SQLite is the embedded transactional engine: many tiny reads and writes of whole rows. DuckDB is the embedded analytical engine: few enormous scans of a handful of columns.

Origin and design goal

Fact SQLite was created by D. Richard Hipp in 2000, originally for a system that needed a database with no administrator available. It is in the public domain, has an exceptionally thorough test suite, and is embedded in essentially every major operating system, browser and mobile platform — which makes it, by deployed instance count, the most widely used database engine in existence.

Fact DuckDB began at CWI in Amsterdam — the same research group whose earlier work on columnar and vectorised execution influenced the modern analytical database field — and is MIT-licensed. Its explicit design brief was “SQLite for analytics”: the same in-process, zero-configuration deployment, but a columnar engine underneath.

Analogy

A server database is a restaurant: you go there, someone takes the order, a kitchen you do not control prepares it, and it must be staffed whether or not you turn up. An embedded database is a kitchen in your own flat. No booking, no travel, no waiting staff, and nobody else can eat there. For one household that is obviously better. The moment two hundred people need dinner simultaneously it is obviously worse — and no amount of tuning the kitchen changes that, because the limitation is the model, not the appliances.

Two architectures, one deployment model

SQLite stores the whole database — tables, indexes, schema — in a single file, organised as B-trees in fixed-size pages. It is a row store, like PostgreSQL and InnoDB. Writes are serialised: in the default journalling mode, one writer at a time locks the database; in write-ahead-log mode, one writer can proceed concurrently with many readers. It is fully ACID, and its durability behaviour under power loss is more carefully tested than most server databases.

DuckDB stores data in a columnar layout with compression per column, and executes queries in a vectorised engine: rather than processing one row at a time through a chain of operators, it pushes batches of roughly a thousand values through each operator, which keeps the CPU's caches and vector units busy. It parallelises across cores automatically and can query Parquet and CSV files directly without loading them first.

flowchart LR
  subgraph Row["Row store (SQLite, InnoDB, Postgres heap)"]
    R1["order 1001: id,cust,date,status,country"] --> R2["order 1002: id,cust,date,status,country"]
  end
  subgraph Col["Column store (DuckDB)"]
    C1["all order_id values"] --- C2["all order_date values"] --- C3["all status values"]
  end
  Q["SUM over one column
of 50M rows"] --> Col P["Fetch one whole row
by primary key"] --> Row
Same SQL, opposite physics. The row store reads every column to get one; the column store reads one column and skips the rest entirely, then compresses it because neighbouring values are similar.

The syntax that actually bites

SQLite's defining oddity is dynamic typing. Column types are advisory “type affinities”, not constraints: a column declared INTEGER will accept and return the string 'banana'. Strict tables were added later to opt out of this. Other points:

  • No dedicated date type. Dates are stored as text, integers or reals, and manipulated with date(), datetime(), strftime() and modifier strings such as 'start of month', '+1 month'.
  • Limited ALTER TABLE: dropping or retyping a column historically required recreating the table.
  • Foreign keys are off by default and must be enabled per connection — another backward-compatibility fossil.
  • Identifier quoting is permissive: double quotes, square brackets and backticks are all accepted, so code copied from any dialect tends to parse, which is convenient and hides mistakes.
  • Window functions and CTEs are supported. ON CONFLICT DO UPDATE is supported, having been adopted from PostgreSQL.

DuckDB's divergences are mostly in the direction of ergonomics. It aims at PostgreSQL compatibility and then adds:

  • / is float division and // is integer division — the opposite default from PostgreSQL, and worth remembering when porting either way.
  • QUALIFY, so you can filter on a window function without a wrapping subquery.
  • SELECT * EXCLUDE (col) and * REPLACE (expr AS col), plus GROUP BY ALL and ORDER BY ALL.
  • Native LIST, STRUCT and MAP types with UNNEST, and JSON handling via an extension that ships by default.
  • Reading files as tables: SELECT * FROM 'data/*.parquet' works with no load step.

Product analytics Several of these compose into queries that are markedly shorter than their portable equivalents:

sqlduckdb_ergonomics.sql
-- QUALIFY filters on a window function directly, with no wrapping subquery.
-- Everywhere else this needs an extra level of nesting.
SELECT p.category_id,
       p.product_name,
       SUM(oi.quantity)                                                       AS units,
       RANK() OVER (PARTITION BY p.category_id ORDER BY SUM(oi.quantity) DESC) AS rnk
FROM order_items AS oi
JOIN products    AS p ON p.product_id = oi.product_id
GROUP BY p.category_id, p.product_name
QUALIFY rnk = 1
ORDER BY p.category_id;
Result
category_idproduct_nameunitsrnk
1Velocity Road 761
2Ridgeline Trail 431
2Ridgeline Trail 331
3Stormshell Jacket31
3Featherlite Jacket31
4Summit 3P Tent21
4Basecamp 2P Tent21
5Nimbus 0C Bag31
6Pacer GPS Watch Pro31

Note that several categories return two rows: RANK gives tied values the same rank, so a genuine tie on units produces two winners. That is RANK behaving correctly, not QUALIFY misfiring — swapping to ROW_NUMBER would pick one arbitrarily and hide the tie, which is usually the wrong answer for a “best seller” question.

The nested types are the other genuinely distinctive feature. Aggregating rows into a list, keeping it as one value, and expanding it again is a normal thing to do here rather than a JSON workaround:

sqlnested_types.sql
-- Collect each order's products into a LIST, then measure basket size.
SELECT o.order_id,
       list(p.product_name ORDER BY p.product_name) AS basket,
       len(list(p.product_name))                    AS lines
FROM orders      AS o
JOIN order_items AS oi ON oi.order_id  = o.order_id
JOIN products    AS p  ON p.product_id = oi.product_id
WHERE o.status = 'completed'
GROUP BY o.order_id
HAVING len(list(p.product_name)) > 1
ORDER BY o.order_id;

Scaling model

Neither engine scales out, and that is not a defect — it is the trade that buys the zero-operations property. SQLite scales to whatever one writer at a time can sustain, which for a read-dominated workload on modern hardware is a great deal more than most people assume. DuckDB scales to the cores and memory of one machine, and it spills to disk rather than failing when a query exceeds memory, so “bigger than RAM” is a performance question rather than an error.

Inference Because a single modern server can hold a terabyte of memory and dozens of cores, the range of datasets that genuinely require a distributed engine is smaller than the industry's tooling choices suggest. A large fraction of clusters run workloads that would fit on one node.

Common pitfall — treating an embedded database as a shared one

The most damaging mistake with both engines is putting the file on a network share and pointing several machines at it. SQLite's locking relies on filesystem primitives that network filesystems implement inconsistently, and corruption — not an error, corruption — is a documented risk. DuckDB permits one process to write and, in general use, expects a single process to own the file. Inference If more than one machine must write, you have outgrown the embedded model and no configuration flag fixes that. Multiple readers of a read-only file are fine and are how DuckDB is often deployed for serving.

When to choose them — and when not to

Choose SQLite when the database belongs to one application instance: mobile apps, desktop software, browser storage, embedded devices, on-disk caches, test fixtures. Also for modest server-side workloads that are read-dominated, where its simplicity beats operating a server. And for any file format problem where you want queryability — it is a defensible application file format, not only a database.

Choose DuckDB when you are doing analysis on a laptop or in a single job: exploring Parquet or CSV files, running transformations in a pipeline step, powering local development against a warehouse-shaped schema, or unit-testing SQL without standing up infrastructure. It is also increasingly used as the query layer over files in object storage, where the “database” is just the data and the engine is ephemeral.

Do not choose either when many concurrent clients must write; when you need role-based access control, auditing and network-level security, which are server concerns neither provides; when the data must outlive and be shared across many independent services; or, for DuckDB specifically, when the workload is high-frequency single-row lookups and updates — a columnar engine is the wrong shape for that, exactly as a row store is the wrong shape for a scan.

In the field

The highest-leverage use of DuckDB on a data team is testing. Warehouse SQL is usually tested by running it against the warehouse, which is slow, costs money, and needs credentials in the test environment. Because DuckDB accepts a large subset of ordinary analytical SQL and starts in milliseconds, a transformation can be run against a handful of fixture rows in a unit test on every commit. It will not catch dialect-specific behaviour — that still needs an integration test — but it catches the majority of logic errors before they reach a bill.

Interview angle

“Would SQLite work for this?” is a useful thing to ask yourself in a system-design round even when the answer is no, because saying it out loud demonstrates you size before you architect. The strong answer names the specific limit that rules it out — concurrent writers, multi-service access, network security — rather than a vague appeal to scale. Similarly, being able to say “this aggregation is a single-node problem; a cluster would add coordination cost for no benefit” distinguishes candidates who have measured from those who have only read.

Exercise 13.4

Gaming A mobile game stores each player's local progress in SQLite on the device and syncs to a server nightly. The team now wants a daily report of minutes played per country. Explain why the report should not be built by querying the devices, then write the DuckDB query that produces it from players and daily_logins, treating a missing country as its own bucket.

Show solution

The devices are the wrong place because each SQLite file holds one player's rows, they are not reachable on demand, they are not consistent with each other at any single instant, and the aggregate needs a cross-player scan — which is precisely the workload a row-store-per-device cannot serve. The correct architecture is the one already in place: sync into a central store and run the analytical query there. SQLite is doing its job (local, single-writer, durable); the aggregate is DuckDB's job.

sqlminutes_by_country.sql
SELECT COALESCE(p.country, 'unknown')          AS country,
       COUNT(DISTINCT dl.player_id)            AS active_players,
       SUM(dl.minutes_played)                  AS minutes,
       ROUND(SUM(dl.minutes_played) * 1.0
             / NULLIF(COUNT(DISTINCT dl.player_id), 0), 1) AS minutes_per_player
FROM daily_logins AS dl
JOIN players      AS p ON p.player_id = dl.player_id
GROUP BY COALESCE(p.country, 'unknown')
ORDER BY minutes DESC;

Three deliberate choices. COALESCE keeps players whose country is unrecorded visible as a bucket rather than a NULL group that a chart library may drop. COUNT(DISTINCT ...) rather than COUNT(*) because a player logs in on several days and we want players, not login rows. * 1.0 before dividing forces decimal arithmetic on every engine, which matters because SUM of an integer column divided by a count is exactly the integer-division trap from Lesson 13.1.

Knowledge check

Both SQLite and DuckDB run in-process with no server. Why is DuckDB dramatically faster at “sum one column across fifty million rows”?

Key takeaway

Embedded engines trade multi-client access for the elimination of operations, and that trade is correct far more often than the industry assumes. SQLite is the embedded row store: one writer, whole-row access, everywhere, and dynamically typed unless you opt out. DuckDB is the embedded column store: vectorised, parallel, file-native, and the fastest path from “I have some Parquet” to “I have an answer”. Choose either when the database belongs to one process; leave both when it must be shared, secured or written to concurrently.

Lesson 13.5·14 min read

BigQuery

You will be able to reason about a warehouse where the unit of cost is bytes scanned rather than time elapsed — and write SQL that respects that.

An analyst joins a company, opens the warehouse, and runs SELECT * FROM events LIMIT 10 to see what the table looks like. It returns in three seconds. It also scans the entire table — every column, every partition — because the LIMIT is applied after the read, and on this engine the read is what you pay for. Advertising Nobody notices until the month's bill arrives with a line item for a query that looked like a peek.

Every engine has a resource you should be careful with. On PostgreSQL it is the buffer cache and the single primary. On BigQuery it is bytes read from storage, and it is unusually visible because you are billed for it directly. Understanding that one fact reorganises how you write SQL here.

Origin and design goal

Fact BigQuery is Google Cloud's analytical data warehouse, built on internal Google technology for large-scale columnar storage and distributed query execution. It was offered publicly from around 2010 and is fully managed: there are no instances to size, no indexes to create, and no vacuum to schedule.

The design goal was to make “query an enormous table” an operation with no setup. Not faster than a cluster — requiring no cluster. You upload data, you write SQL, resources appear for the duration of the query and vanish afterwards. Inference That serverless promise is why its cost model is per-query rather than per-hour: with no persistent machine to bill for, the only meaningful unit left is work done.

Analogy

Most databases are like owning a van: you pay whether or not you drive, so once you own it every extra trip feels free and you stop thinking about journeys. BigQuery is like a metered taxi that arrives instantly. Nothing to maintain, no idle cost, available at three in the morning — but the meter runs on distance, so “let me just check something” is not free, and a habit of driving to the shop for one item shows up at the end of the month. The engineering discipline it rewards is planning the route before setting off.

Storage and execution

Storage and compute are fully separated. Data lives in a distributed columnar format on Google's storage layer; queries run on a shared pool of compute, decomposed into stages that shuffle intermediate results between workers over the network. There is no server that “is” your warehouse.

Because storage is columnar and remote, the optimiser's most important decision is how little to read. Two mechanisms control that, and both are chosen at table-design time rather than query time:

  • Partitioning splits a table by date (or an integer range) into separately addressable chunks. A filter on the partitioning column lets the engine skip whole partitions unread. A filter on any other column does not.
  • Clustering sorts data within each partition by up to a few columns, so filters on those columns can skip blocks. It is a softer, best-effort version of the same idea.

There are no indexes in the B-tree sense, and no VACUUM. The tuning surface is: choose the partition column correctly, cluster on the columns you filter by, and select fewer columns.

  events partitioned by DATE(event_at), clustered by (event_name)

  [2024-06-08] [2024-06-09] [2024-06-10] [2024-06-11] ...
                                  ^
  WHERE DATE(event_at) = '2024-06-10'   -> reads ONE partition
  WHERE event_name = 'purchase'         -> reads EVERY partition,
                                           skipping blocks within each

The syntax that actually bites

BigQuery's dialect (“GoogleSQL”) is standards-oriented but has distinctive corners:

  • Backtick-quoted, three-part names: `project.dataset.table`. Dataset and table names are case-sensitive; column names are not.
  • Functions need parentheses: CURRENT_TIMESTAMP(), not the bare keyword.
  • DATE_TRUNC(date, MONTH) — date first, unit second, unit unquoted. This is reversed relative to almost every other engine and is the single most common porting error into and out of BigQuery.
  • Type names: STRING (not VARCHAR), INT64, FLOAT64, NUMERIC/BIGNUMERIC, BYTES, plus ARRAY, STRUCT, JSON and GEOGRAPHY.
  • Arrays and structs are central, not exotic. Nested and repeated fields are the idiomatic way to model one-to-many relationships without a join, and UNNEST in the FROM clause flattens them.
  • SAFE_ prefixes: SAFE_DIVIDE, SAFE_CAST and the general SAFE. prefix return NULL instead of raising. Integer division truncates, so SAFE_DIVIDE is also the idiomatic way to get a float.
  • Scripting and parameters: DECLARE, SET and multi-statement scripts run in a single job.
  • UPSERT is MERGE. There is no ON CONFLICT, because there are no enforced unique constraints to conflict with — primary keys can be declared but are not enforced.
  • Window function coverage is complete; QUALIFY is supported.
bigquerybq_idioms.sql
-- Distinctly BigQuery: partition pruning, SAFE_DIVIDE, reversed DATE_TRUNC,
-- and an ARRAY_AGG that builds a nested column instead of a second table.
SELECT DATE_TRUNC(DATE(s.started_at), MONTH)                AS mth,
       s.channel,
       COUNT(DISTINCT s.session_id)                         AS sessions,
       SAFE_DIVIDE(COUNTIF(e.event_name = 'purchase'),
                   COUNT(DISTINCT s.session_id))            AS purchase_rate,
       ARRAY_AGG(DISTINCT e.event_name IGNORE NULLS)        AS events_seen
FROM `shopline.core.sessions` AS s
LEFT JOIN `shopline.core.events` AS e USING (session_id)
WHERE DATE(s.started_at) BETWEEN '2024-01-01' AND '2024-06-30'
GROUP BY mth, s.channel
QUALIFY sessions >= 1
ORDER BY mth, sessions DESC;

-- UPSERT is MERGE. There is no ON CONFLICT because keys are not enforced.
MERGE `shopline.core.subscriptions` AS t
USING (SELECT 9 AS subscription_id, 199.00 AS mrr) AS s
   ON t.subscription_id = s.subscription_id
WHEN MATCHED THEN UPDATE SET mrr = s.mrr
WHEN NOT MATCHED THEN INSERT (subscription_id, mrr) VALUES (s.subscription_id, s.mrr);

The portable core of that same analysis runs on DuckDB unchanged, which is worth seeing side by side — almost none of the BigQuery-specific syntax was load-bearing:

sqlportable_funnel.sql
-- Same question, tier-1 syntax only.
SELECT date_trunc('month', CAST(s.started_at AS DATE)) AS mth,
       s.channel,
       COUNT(DISTINCT s.session_id)                    AS sessions,
       COUNT(DISTINCT CASE WHEN e.event_name = 'purchase'
                           THEN s.session_id END)      AS purchasing_sessions,
       ROUND(100.0 * COUNT(DISTINCT CASE WHEN e.event_name = 'purchase'
                                         THEN s.session_id END)
             / NULLIF(COUNT(DISTINCT s.session_id), 0), 1) AS purchase_rate_pct
FROM sessions AS s
LEFT JOIN events AS e ON e.session_id = s.session_id
GROUP BY date_trunc('month', CAST(s.started_at AS DATE)), s.channel
ORDER BY mth, sessions DESC, s.channel;

The pricing model, and how it changes your SQL

Fact BigQuery offers two compute pricing modes: on-demand, charged by the volume of data a query scans, and capacity, where you reserve a quantity of compute for a period and queries draw on it. Storage is billed separately, with a lower rate for data that has not been modified for a while. Rates change and vary by region, so consult current documentation rather than any figure in a course.

The behavioural consequence of the on-demand mode is specific and worth stating plainly, because it inverts habits learned on other engines:

  • SELECT * is expensive; LIMIT does not save you. Columns you name are the columns you pay for, and the row limit applies after the scan. To sample a table cheaply, select a few columns or use TABLESAMPLE.
  • A filter on the partition column is the biggest lever you have. Wrapping that column in a function can defeat pruning; filter on the raw partitioning expression.
  • Repeated exploratory queries are the real cost. Materialising an intermediate result once and querying the small table twenty times is dramatically cheaper than scanning the large one twenty times — the reverse of the instinct on an engine where storage is the scarce thing.
  • Cached results are free. Re-running a byte-identical query over unchanged data usually returns from cache at no scan cost, which quietly rewards deterministic SQL over queries containing CURRENT_TIMESTAMP().
💡Intuition — optimise for bytes, not for time

On a conventional database, a fast query is a cheap query, so you have one target. Here they can diverge: a query that reads a huge table and finishes in four seconds because a thousand workers were thrown at it is fast and expensive. The habit to build is reading the “bytes processed” estimate the console shows before you run anything — it is a dry run that costs nothing and tells you the price in advance. Inference Teams that make that estimate visible in their query tooling report far fewer surprises than teams that review costs monthly, because the feedback arrives while the decision is still being made.

Common pitfall — the unpartitioned filter

A table partitioned by day, queried with WHERE customer_id = 42 and no date filter, reads every partition ever written. The query is correct, returns quickly enough, and scans years of data to find three rows. This is the single most common cost incident on BigQuery, and it is invisible in the result — the numbers are right. Defences: require a partition filter on large tables (the table option exists precisely for this), and treat “does this query filter the partition column?” as a review checklist item rather than an optimisation.

When to choose it — and when not to

Choose BigQuery when your workload is spiky and you do not want to pay for idle compute; when you want zero operational surface — no sizing, no patching, no vacuum; when you are already in Google Cloud and want tight integration with its analytics and advertising data sources; when your data is naturally nested and you would rather model it with arrays and structs than with join tables; or when you want streaming ingestion and querying without operating a pipeline.

Do not choose it when your workload is many small, highly selective lookups — you will pay scan costs for point queries a transactional database answers with an index; when you need row-level updates and deletes at high frequency, which the storage model handles but does not love; when unpredictable per-query billing is organisationally unacceptable and you would rather have a fixed, boring monthly number (though capacity pricing addresses this); or when you need multi-cloud portability, since the dialect and the nested-data modelling style are both distinctive enough to make leaving real work.

In the field

The most effective governance measure teams adopt is not a query review process, it is a cap. Setting a maximum bytes-billed limit on a query or a project turns a runaway cost into a failed query, which someone notices immediately and fixes. Beyond that, the highest-value structural change is usually a set of curated intermediate tables: instead of every analyst scanning the raw event table, they scan a daily-aggregated table a fraction of its size. The saving comes from the modelling, not from anyone writing cleverer SQL.

Interview angle

For roles on this engine, expect a question of the form “this query costs too much — what do you do?”. A weak answer reaches for indexes, which do not exist here. A strong one goes in order: confirm the table is partitioned and that the query filters the partition column; replace SELECT * with named columns; check whether the expensive scan is repeated and should be materialised; only then consider clustering or restructuring. Naming that priority order — prune, project, materialise, cluster — is what is being assessed, not any function name.

Exercise 13.5

Advertising A daily dashboard runs this against a large partitioned ad_spend-shaped table and is the largest single line on the bill. Identify the three cost problems, then write a portable rewrite of the underlying analysis against our ad_spend table.

bigqueryexpensive_dashboard.sql
SELECT *
FROM `shopline.core.ad_spend`
WHERE FORMAT_DATE('%Y-%m', spend_date) = '2024-05'
ORDER BY cost DESC
LIMIT 20;
Show solution
  1. SELECT * reads every column although the dashboard shows a handful. On a columnar engine billed by bytes, this is the largest and easiest win.
  2. The partition column is wrapped in a function. FORMAT_DATE('%Y-%m', spend_date) = '2024-05' is logically a May filter, but the engine must compute the function per row to evaluate it and cannot use it to eliminate partitions. Expressing the same restriction as a range on the raw column — spend_date >= '2024-05-01' AND spend_date < '2024-06-01' — prunes to one month's partitions. This is the same principle as sargability on an indexed database: keep the indexed or partitioned column bare on one side of the comparison.
  3. LIMIT 20 saves nothing. It reduces what is returned, not what is read. Analysts routinely believe otherwise, which is why the exploratory SELECT * ... LIMIT 10 is such a reliable cost sink.

The corrected analysis, in portable SQL against our schema:

sqlcheap_dashboard.sql
-- Named columns only, and a bare range predicate on the partition column.
SELECT spend_date,
       channel,
       campaign,
       cost,
       CAST(clicks AS DECIMAL(18,6)) / NULLIF(impressions, 0) AS ctr
FROM ad_spend
WHERE spend_date >= DATE '2024-05-01'
  AND spend_date <  DATE '2024-06-01'
ORDER BY cost DESC
LIMIT 20;

Note the half-open range >= start AND < next_start rather than BETWEEN. It is correct whether the column is a date or a timestamp, needs no thought about whether the endpoint is inclusive, and does not silently drop events recorded during the last day when someone later changes the column's type.

Knowledge check

On BigQuery's on-demand pricing, which change most reduces the cost of a query over a large date-partitioned table?

Key takeaway

BigQuery is a serverless, columnar warehouse where storage and compute are fully separated and the on-demand unit of cost is bytes scanned. That single fact drives everything: no indexes, partition and cluster instead; SELECT * is expensive; LIMIT does not reduce the scan; materialising an intermediate result beats re-scanning. Syntactically, remember the reversed DATE_TRUNC(date, UNIT), backticked three-part names, SAFE_DIVIDE, and MERGE as the only UPSERT because keys are declared but never enforced.

Lesson 13.6·14 min read

Snowflake

You will be able to explain what “separated storage and compute” buys organisationally, why identifier case will eventually bite you, and where the money actually goes.

Two teams share a warehouse. The finance team's month-end close runs for forty minutes; the marketing team's dashboards refresh every fifteen. On a conventional cluster these compete for the same CPUs, so month-end is when dashboards are slowest, and the fix is an argument about scheduling. Finance On Snowflake it is not an argument: the two teams point separate compute clusters at the same data, and neither notices the other.

That is the product in one sentence. The technical idea — multiple independent compute clusters over one shared copy of the data — is what most of the pricing, the tuning advice, and the organisational politics around this engine reduce to.

Origin and design goal

Fact Snowflake was founded in 2012 by engineers with backgrounds in data warehousing, and launched a cloud data warehouse that runs on top of the major public clouds rather than being tied to one. Its distinguishing architectural claim from the outset was the separation of storage, compute and cloud services into three independently scalable layers.

The problem it set out to solve was not query speed as such. It was the fact that on a fixed cluster, every workload competes with every other, and capacity must be provisioned for the worst hour of the month and paid for during the other seven hundred. Separating compute from storage means idle capacity can be switched off and concurrent workloads can be isolated instead of scheduled.

Analogy

A traditional cluster is an office with one photocopier. Everybody's job goes into the same queue, so the person printing a five-hundred-page report at month-end is genuinely everybody's problem, and the solution is a rota. Snowflake is an office where each team can conjure its own photocopier for the minutes it needs one, all drawing from the same paper store. The queue problem disappears. What replaces it is a subtler discipline: the machines bill while they are switched on, so the new failure mode is a copier nobody turned off.

Storage and execution: three layers

Storage layer. Table data is held in cloud object storage as immutable, compressed columnar micro-partitions — small units, each carrying metadata about the range of values it contains for every column. Because they are immutable, an update writes new micro-partitions and retires old ones, which is also what makes time travel possible: the previous versions still exist for a retention window, so you can query a table as it was, or restore a dropped one.

Compute layer. A virtual warehouse is a cluster of compute you name, size (in T-shirt sizes, each step roughly doubling both the resources and the rate at which it bills), start and stop. Several can run at once over the same tables. Each caches data locally while running, so a warehouse that has just been resumed is slower on its first query.

Cloud services layer. Metadata, authentication, transaction coordination, query planning and result caching. This is where the automatic pruning happens: because every micro-partition carries min/max metadata per column, a filter can eliminate huge numbers of them without reading any data, which is the mechanism that substitutes for indexes.

flowchart TD
  S[("Shared storage
immutable micro-partitions")] W1["Warehouse: BI dashboards
small, always on"] --> S W2["Warehouse: month-end close
large, 40 min/month"] --> S W3["Warehouse: data science
x-large, ad hoc"] --> S CS["Cloud services
metadata, pruning, result cache"] --- W1 CS --- W2 CS --- W3
One copy of the data, three independent compute clusters. Isolation is bought with configuration rather than negotiated with colleagues — which is the actual product.

The syntax that actually bites

Snowflake's SQL is close to the standard, with a small number of genuinely surprising behaviours:

  • Unquoted identifiers fold to UPPERCASE. This follows the standard and contradicts PostgreSQL, which folds to lower. It matters because if any tool creates a column as "revenue" with quotes, it is stored lowercase and SELECT revenue — which resolves to REVENUE — will not find it. You must then quote it forever. This is the number one confusion for people arriving from PostgreSQL.
  • VARIANT is a semi-structured type holding JSON, Avro or XML-derived data. Access is by col:field.subfield path syntax, with ::type casts and LATERAL FLATTEN to expand arrays into rows. This is Snowflake's most-used non-standard feature by a wide margin.
  • Integer division returns a numeric result, not a truncated integer — the MySQL behaviour rather than the PostgreSQL one.
  • QUALIFY is supported and idiomatic.
  • Type system is simplified: all integer types are aliases for NUMBER(38,0), and all string types are aliases for VARCHAR with a maximum length. Declaring VARCHAR(50) is documentation, not a storage saving.
  • Time travel and cloning: SELECT ... AT (OFFSET => -3600), UNDROP TABLE, and CREATE TABLE ... CLONE which copies metadata rather than data and is therefore near-instant regardless of table size.
  • UPSERT is MERGE; constraints other than NOT NULL are declarative and unenforced.
snowflakesnowflake_idioms.sql
-- VARIANT path access, FLATTEN, QUALIFY, and a zero-copy clone.
CREATE OR REPLACE TABLE analytics.dev_orders CLONE analytics.orders;

SELECT o.order_id,
       o.payload:customer.country::VARCHAR        AS country,
       f.value:sku::VARCHAR                       AS sku,
       f.value:qty::NUMBER                        AS qty,
       ROW_NUMBER() OVER (PARTITION BY o.order_id
                          ORDER BY f.value:qty::NUMBER DESC) AS line_rank
FROM analytics.raw_orders AS o,
     LATERAL FLATTEN(input => o.payload:items) AS f
QUALIFY line_rank = 1;

-- Query the table as it was an hour ago; then undo a bad DROP.
SELECT COUNT(*) FROM analytics.orders AT (OFFSET => -3600);
UNDROP TABLE analytics.orders;

Where Snowflake's SQL is portable, it is very portable. This runs unchanged on DuckDB and would run unchanged on Snowflake:

sqlqualify_portable_shape.sql
-- Each customer's most recent completed order. QUALIFY is supported on both
-- DuckDB and Snowflake, so this exact text runs on either.
SELECT o.customer_id,
       c.full_name,
       o.order_id,
       o.order_date,
       ROW_NUMBER() OVER (PARTITION BY o.customer_id
                          ORDER BY o.order_date DESC, o.order_id DESC) AS rn
FROM orders    AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'completed'
QUALIFY rn = 1
ORDER BY o.customer_id
LIMIT 6;
Result
customer_idfull_nameorder_idorder_datern
1Amara Osei10252025-01-091
2Bruno Salgado10032024-01-251
3Chen Wei10042024-02-091
4Dara Nolan10062024-02-201
5Elif Demir10092024-09-161
7Greta Lindqvist10112024-04-141

Customer 6 is absent: their only order was cancelled, so the WHERE removed it before the window function ran. That ordering — filter, then partition, then qualify — is the thing to internalise about window functions on any engine.

Pricing and scaling model

Fact Snowflake bills compute by the second that a virtual warehouse is running, at a rate determined by its size, with storage billed separately by volume. Warehouses can be configured to suspend automatically after a period of inactivity and to resume on demand. Actual rates depend on edition, cloud and region.

Two scaling axes exist and are frequently confused, which matters because they solve different problems:

  • Scaling up — a larger warehouse size — makes an individual query faster by giving it more compute. Use it when one query is slow.
  • Scaling out — a multi-cluster warehouse — adds more clusters of the same size when queries start queueing. Use it when many users are waiting, not when one query is slow.

Inference Because a larger warehouse both runs faster and bills faster, sizing up a query that scales well can be roughly cost-neutral while being much quicker — and sizing up a query that does not parallelise is simply more expensive. That makes measurement, rather than a default size policy, the correct approach.

Common pitfall — the quoted identifier

An ingestion tool creates a table with column "orderDate", quotes included. Snowflake stores the name exactly as given. An analyst writes SELECT orderDate, which folds to ORDERDATE, and gets “invalid identifier” — while looking at a table that visibly contains the column. The fix in the query is to quote it, "orderDate"; the fix in the system is to stop the tool quoting names on creation. Inference This is the single most common Snowflake support question from teams migrating off PostgreSQL, because PostgreSQL folds the other way and the same tool configuration produced no visible problem there.

When to choose it — and when not to

Choose Snowflake when workload isolation between teams is a real organisational problem and you would rather solve it with configuration than scheduling; when your usage is bursty and per-second billing with auto-suspend genuinely beats paying for idle capacity; when you want to run the same warehouse across more than one cloud provider; when semi-structured data arrives constantly and you want to load it without a schema and query it immediately; or when features like zero-copy cloning and time travel remove real operational work — a full-size development copy of production that costs nothing until you modify it is a meaningful change to how teams work.

Do not choose it when your workload is transactional — it is a warehouse and single-row operations are expensive; when usage is steady and heavy enough that owned or reserved capacity is cheaper than metered compute; when governance cannot tolerate a cost model where anyone with query access can start a large warehouse; or when the dataset is small enough that a single-node engine answers everything in seconds, in which case you are paying a platform premium for a problem you do not have.

In the field

Cost surprises here almost never come from expensive queries. They come from warehouses that stay running — an auto-suspend set to a long interval, or a scheduled job that keeps a large warehouse alive with a trickle of small queries, so it never idles long enough to suspend. The diagnostic habit is to look at warehouse uptime before looking at query cost, because a small warehouse running continuously for a month can easily exceed the extra-large one that ran for twenty minutes. Separate warehouses per team also make the bill attributable, which turns cost control into something each team can act on rather than a central mystery.

Interview angle

“Explain separated storage and compute, and what it buys” is a standard warehouse-role question, and the weak answer is “it scales better”. The strong answer is concrete: one copy of the data means no duplication and no synchronisation between teams; independent compute means workload isolation without scheduling; elastic compute means idle time costs nothing; and the price is that compute is metered, so the discipline shifts from capacity planning to usage governance. Follow-ups often probe scale-up versus scale-out — being able to say “up for a slow query, out for queueing users” in one line lands well.

Exercise 13.6

Insurance A team's dashboard warehouse is small and always on; their nightly model-training job runs on the same warehouse. Analysts complain of queueing every night, and the finance team asks why compute cost is flat but high. Diagnose both problems and state the configuration change. Then, separately, write the portable SQL for the metric the dashboard shows: monthly active subscriptions and monthly recurring revenue.

Show solution

Diagnosis. Two distinct faults share one cause — a single warehouse serving two workloads with opposite shapes. The queueing is a concurrency problem: the training job saturates the warehouse and dashboard queries wait behind it, so the fix is isolation (a separate warehouse for the job), not a bigger one. The flat, high cost is an uptime problem: an always-on warehouse bills continuously whether or not anyone is querying, so the fix is a short auto-suspend with auto-resume. Scaling out with a multi-cluster warehouse would relieve the queueing and make the cost worse, which is why naming the shape of the contention matters before choosing a lever.

sqlmrr_by_month.sql
-- Active subscriptions and MRR for each month of 2024.
-- A subscription is active in a month if it started on or before the month
-- end and had not been cancelled before the month start.
WITH months AS (
  SELECT DATE '2024-01-01' + INTERVAL (n) MONTH AS month_start
  FROM range(0, 12) AS t(n)
)
SELECT CAST(m.month_start AS DATE)              AS month_start,
       COUNT(s.subscription_id)                 AS active_subs,
       COALESCE(SUM(s.mrr), 0)                  AS mrr
FROM months AS m
LEFT JOIN subscriptions AS s
       ON s.started_on <= m.month_start
      AND (s.cancelled_on IS NULL OR s.cancelled_on > m.month_start)
GROUP BY m.month_start
ORDER BY m.month_start;

The join condition is the whole exercise. cancelled_on IS NULL OR cancelled_on > month_start is required because a NULL cancellation date means “still active”, and any comparison against NULL is unknown rather than true — so writing only cancelled_on > month_start would silently drop every currently-active subscription, which is exactly the customers you most wanted to count.

Knowledge check

Users report queries queueing at peak hours, though each individual query runs at an acceptable speed. What is the appropriate response?

Key takeaway

Snowflake's core idea is three independent layers: shared immutable columnar storage, many separately sized compute warehouses, and a services layer that prunes micro-partitions using their metadata instead of using indexes. That buys workload isolation, elasticity, zero-copy clones and time travel; it costs you a metered compute bill whose largest driver is usually uptime, not query complexity. Syntactically: unquoted identifiers fold to UPPERcase, VARIANT with : paths and FLATTEN handles semi-structured data, QUALIFY is idiomatic, and MERGE is the only UPSERT.

Lesson 13.7·13 min read

Amazon Redshift

You will be able to explain why a physical design decision made once, at table-creation time, can dominate every query's performance for years — and how to make it correctly.

A join between two large tables takes four minutes. The SQL is simple, both tables are columnar and compressed, and the cluster is not short of CPU. The bottleneck is the network: for every row, the engine is moving data between nodes so that matching rows end up on the same machine. Retail Nothing in the query says to do that. It is a consequence of a choice someone made when the table was created, possibly years earlier, and possibly by accepting a default.

Redshift is the engine in this Part where physical layout is your job. That makes it less forgiving than a fully managed warehouse and more instructive, because the concepts it forces you to confront — distribution, sorting, co-location — are exactly what the more automated engines are doing invisibly on your behalf.

Origin and design goal

Fact Amazon Redshift launched in 2012 as AWS's managed data warehouse. Its early codebase derived from ParAccel, itself built on PostgreSQL — which is why Redshift's client protocol, catalogue and much of its syntax remain PostgreSQL-shaped, while its storage and execution are entirely different. Later generations introduced managed storage that separates compute from a durable storage layer, plus a serverless option.

The design goal was a warehouse that a team already using AWS could stand up in an afternoon, at a fraction of the cost of the appliance-based warehouses that then dominated. It was, for several years, the default answer to “we need a warehouse” for a large part of the industry — which is why so much existing warehouse SQL and so many existing pipelines are shaped by its assumptions.

Analogy

Imagine a warehouse where stock is split across six buildings. If shoes and their matching boxes are stored in the same building, packing an order is a walk across a room. If shoes are in building one and boxes are in building four, every single order requires a van. The stock is identical either way; the throughput differs by an order of magnitude. A distribution key is the rule that decides which building each item goes to — and like a real warehouse, re-shelving everything later is possible but is a weekend job, not a decision you revisit casually.

Storage and execution: leader, slices and the network

A Redshift cluster has a leader node that parses and plans, and several compute nodes that hold data and execute. Each compute node is divided into slices, each responsible for a portion of the rows. Data is columnar and compressed per column.

Two physical properties, both declared on the table, determine most of your performance:

  • Distribution style decides which slice each row lives on. KEY distributes by hashing a chosen column, so rows sharing that value are co-located. ALL copies the entire table to every node — correct for small dimensions, ruinous for large facts. EVEN spreads rows round-robin. AUTO lets the engine choose and change as the table grows.
  • Sort key decides the order of rows within a slice. Because each block carries min/max metadata, a filter on the sort key lets the engine skip blocks entirely — the same zone-map idea as Snowflake's micro-partitions, but under your control.
  orders DISTKEY(customer_id), customers DISTKEY(customer_id)

  slice 0: customers 1,4,7   orders for 1,4,7    -> join is local
  slice 1: customers 2,5,8   orders for 2,5,8    -> join is local

  orders DISTKEY(order_date), customers DISTKEY(customer_id)

  slice 0: customers 1,4,7   orders from January -> rows must be
  slice 1: customers 2,5,8   orders from March      shipped between
                                                    slices first

Inference The rule that follows is simple and is most of Redshift tuning: distribute the two tables you join most often on the same key, keep small dimensions on ALL, and sort large facts on the column you filter by — usually a date.

Because storage is columnar and updates rewrite blocks, deleted rows are marked rather than removed, and sort order degrades as data is appended. VACUUM reclaims and re-sorts; ANALYZE refreshes the statistics the planner uses. Modern versions automate much of this, but a cluster where automatic maintenance cannot keep up shows the same symptom as neglected PostgreSQL: gradual, unexplained slowdown.

The syntax that actually bites

Because of its PostgreSQL ancestry, Redshift's syntax is the most familiar of the cloud warehouses — and the gaps are where people get caught:

  • PostgreSQL-like basics: || concatenation, date_trunc('month', d), LIMIT n OFFSET m, double-quoted identifiers folded to lowercase, truncating integer division.
  • But not PostgreSQL. There are no arrays or ranges in the PostgreSQL sense; JSON support is a set of functions plus the SUPER type rather than JSONB; there are no user-defined types, no table inheritance, and a restricted procedural language.
  • GETDATE() is idiomatic alongside current_timestamp — a T-SQL borrowing that surprises people.
  • No enforced constraints. Primary and foreign keys can be declared and are used by the planner as hints, but nothing stops you inserting a duplicate. Declaring a key that is not actually unique gives the planner false information and can produce wrong results, so declare them only when you enforce them upstream.
  • DISTKEY, SORTKEY, ENCODE in CREATE TABLE — the physical clauses that exist nowhere else.
  • Row limiting accepts both LIMIT and TOP n.
  • UPSERT: MERGE is available in current versions; a great deal of existing pipeline code uses the older idiom of a staging table, a DELETE matching on key, then an INSERT, all inside one transaction.
  • Window functions are well covered; QUALIFY is not available, so wrap in a subquery.
redshiftredshift_physical.sql
-- The clauses that make this Redshift rather than PostgreSQL.
CREATE TABLE orders_fact (
  order_id     INTEGER,
  customer_id  INTEGER  DISTKEY,          -- co-locate with customers
  order_date   DATE      SORTKEY,         -- enable block skipping on date
  status       VARCHAR(20) ENCODE ZSTD,
  ship_country CHAR(2)     ENCODE BYTEDICT
)
DISTSTYLE KEY;

CREATE TABLE customers_dim (
  customer_id INTEGER,
  full_name   VARCHAR(120),
  country     CHAR(2)
)
DISTSTYLE ALL;                            -- small dimension: copy everywhere

-- The classic pre-MERGE upsert idiom, still common in existing pipelines.
BEGIN;
DELETE FROM orders_fact
 USING stage_orders s
 WHERE orders_fact.order_id = s.order_id;
INSERT INTO orders_fact SELECT * FROM stage_orders;
END;

The analytical SQL itself is ordinary. This runs on DuckDB and would run on Redshift unchanged — note the subquery wrapper standing in for the QUALIFY that Redshift lacks:

sqlno_qualify.sql
-- Top two products by revenue within each department.
-- Redshift has no QUALIFY, so the window filter needs a wrapping subquery.
SELECT department, product_name, revenue, rnk
FROM (
  SELECT cat.department,
         p.product_name,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100)) AS revenue,
         RANK() OVER (PARTITION BY cat.department
                      ORDER BY SUM(oi.quantity * oi.unit_price
                                   * (1 - oi.discount_pct/100)) DESC) AS rnk
  FROM order_items AS oi
  JOIN orders      AS o   ON o.order_id    = oi.order_id
  JOIN products    AS p   ON p.product_id  = oi.product_id
  JOIN categories  AS cat ON cat.category_id = p.category_id
  WHERE o.status = 'completed'
  GROUP BY cat.department, p.product_name
) AS ranked
WHERE rnk <= 2
ORDER BY department, rnk;

Pricing and scaling model

Fact Provisioned Redshift is billed by node-hour for the cluster you run, with reserved-capacity discounts for committing in advance; a serverless option bills by compute capacity consumed instead. Managed storage is billed separately from compute in current node types. Concurrency scaling can add transient capacity when queries queue, and Spectrum allows querying data left in object storage.

The behavioural consequence is the mirror image of BigQuery's. A provisioned cluster costs the same whether it is busy or idle, so the incentive is to use it — an extra exploratory query is genuinely free at the margin, and the cost discipline is about right-sizing the cluster rather than about individual queries. Inference This is why the two engines produce such different team cultures: metered billing makes people cautious about queries, node-hour billing makes people cautious about capacity.

💡Intuition — the three things a distributed join can do

When joining two distributed tables an engine has exactly three options, and every “why is this slow?” conversation is about which one it picked. Local join: both tables distributed on the join key, so matching rows are already on the same slice — no network traffic, fastest. Broadcast: one table is small enough to copy to every node, then join locally — cheap for a small dimension, catastrophic if the “small” table turns out to have a hundred million rows. Shuffle: redistribute one or both tables across the network by the join key, then join — always works, always costs. Redshift's DISTKEY exists to make the first case happen; DISTSTYLE ALL pre-pays for the second. Every distributed engine in this Part makes the same three choices; Redshift is unusual only in asking you to decide in advance.

Common pitfall — the skewed distribution key

Distributing on a column with a badly uneven distribution puts most of the table on one slice. A common example is a nullable foreign key or a status column: if 80% of rows share one value, 80% of the data lands on one machine, which then does 80% of the work while the rest of the cluster idles. The symptom is a query whose runtime does not improve when you add nodes — the classic signature of skew. Distribution keys should be high-cardinality and evenly spread, and frequently joined on. A key that is one of those things but not the others is worse than no key at all.

When to choose it — and when not to

Choose Redshift when you are committed to AWS and want tight integration with its storage, streaming and identity services; when your workload is steady and predictable, so reserved node-hours beat metered compute; when you want the option to query data left in object storage without loading it; or when your team's SQL and tooling already assume a PostgreSQL-shaped interface.

Do not choose it when nobody on the team will own physical design — the automated modes are good, but a cluster with no thought given to distribution and sort keys will underperform a fully managed competitor; when the workload is bursty and would spend most of its life idle; when you need multi-cloud portability; or when you need transactional single-row operations, which remains true of every warehouse in this Part.

In the field

When inheriting a Redshift cluster, the first diagnostic is not the slow query — it is the table design. Look for large fact tables with DISTSTYLE EVEN that are joined constantly to a dimension on a different key, and for large tables with DISTSTYLE ALL that were small when someone chose it and are not any more. Both are silent, both were reasonable decisions at the time, and both are fixed by recreating the table with different clauses rather than by rewriting any SQL. It is one of the few situations in data work where the largest performance win requires changing no queries at all.

Interview angle

Redshift questions are really distributed-systems questions in disguise, which is why they appear even for roles on other engines. “Two large tables, join is slow, how do you diagnose it?” wants you to reach for data movement rather than for indexes: is the join key the distribution key on both sides; is one side being broadcast; is there skew. If you can also say that the same three options exist on Spark and every other distributed engine and are simply chosen automatically there, you have shown that you understand the mechanism rather than the vendor.

Exercise 13.7

Supply chain You must design the physical layout for a warehouse holding a very large order_items fact, a large orders fact, and small products and categories dimensions. The dominant query pattern is “revenue by category for a date range”. State the distribution and sort choices with reasons, then write that query portably.

Show solution

Physical design. order_items and orders are joined on order_id in nearly every query and are both large, so distribute both on order_id — that makes the biggest join in the workload local and eliminates the largest source of network traffic. products and categories are small and joined constantly, so DISTSTYLE ALL puts a copy on every node and removes them from the network entirely; the storage cost of duplicating a few thousand rows across nodes is negligible against the shuffle it avoids. The sort key on orders should be order_date, because the query pattern always restricts by date and a sorted date column lets whole blocks be skipped using their min/max metadata.

Note the ordering of reasoning: distribution is chosen by what you join, sort is chosen by what you filter. Confusing the two produces designs that look considered and perform like defaults.

sqlrevenue_by_category.sql
SELECT cat.category_name,
       COUNT(DISTINCT o.order_id)                                   AS orders,
       SUM(oi.quantity)                                             AS units,
       SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct/100)) AS revenue
FROM orders      AS o
JOIN order_items AS oi  ON oi.order_id    = o.order_id
JOIN products    AS p   ON p.product_id   = oi.product_id
JOIN categories  AS cat ON cat.category_id = p.category_id
WHERE o.status = 'completed'
  AND o.order_date >= DATE '2024-01-01'
  AND o.order_date <  DATE '2025-01-01'
GROUP BY cat.category_name
ORDER BY revenue DESC;

The date predicate is deliberately a bare half-open range on order_date. Had it been written as WHERE EXTRACT(year FROM o.order_date) = 2024 it would return the same rows and defeat the sort key entirely, because the engine cannot compare a function's output against block metadata. Sargability is the same idea on a sorted columnar warehouse as on an indexed transactional database — only the structure being skipped differs.

Knowledge check

A join between two large Redshift tables does not get faster when nodes are added to the cluster. What does this most likely indicate?

Key takeaway

Redshift is a PostgreSQL-flavoured columnar MPP warehouse whose performance is dominated by physical design you declare once: distribution style decides which node each row lives on and therefore whether joins are local, broadcast or shuffled; sort key decides which blocks can be skipped. Distribute by what you join, sort by what you filter, put small dimensions on ALL, and avoid skewed keys. Provisioned node-hour billing makes idle capacity the cost risk, the opposite of a metered engine. Constraints are declared but never enforced, so declaring a false key can produce wrong answers.

Lesson 13.8·14 min read

Databricks SQL & Spark SQL

You will be able to explain how a table format turns a folder of files into something with transactions, and why the ANSI-mode setting changes what your query returns.

A pipeline writes ten thousand rows to a table. Halfway through, the job fails. On a warehouse this is a rolled-back transaction and nobody notices. On a plain folder of files in object storage, the table now contains a partial write, downstream jobs read it, and the numbers are wrong until someone finds it. Telecommunications Worse, a reader that started before the failure may see a different set of files than one that starts after — two people run the same query and get two answers.

The engine in this lesson is the industry's answer to that problem: keep the data as open files in object storage, but put a transaction log beside it so the collection of files behaves like a table. Understanding that is more useful than any syntax difference, because it is the idea underneath the whole “lakehouse” category.

Origin and design goal

Fact Apache Spark originated at UC Berkeley's AMPLab and became a widely adopted distributed processing framework; Spark SQL added a relational interface and query optimiser over it. Databricks was founded by people from that project and provides a commercial platform built on Spark, together with Delta Lake — an open table format that adds a transaction log over Parquet files.

Spark's original problem was general distributed computation over large datasets, expressed in code — not SQL. SQL arrived as an interface on top, which explains a persistent characteristic of the dialect: it was designed to be compatible with existing tools rather than to be minimal or standards-strict, and it inherits behaviours from the data-processing world (permissive type coercion, nulls on failure) that a database person finds surprising.

Analogy

A folder of data files is a shelf of loose papers. Anyone can add a page; nobody can tell whether the stack they picked up is complete, because a page might be mid-flight from someone's hand to the shelf. A transaction log is a ledger at the front of the shelf that says “as of version 47, the document consists of these pages”. The papers do not move and anyone can still read them directly, but now there is a single authoritative statement of what counts — so two readers agree, and a half-finished addition is simply not in version 47 yet.

Storage and execution

Storage is Parquet files in object storage, plus a transaction log recording atomic commits: which files were added and removed at each version. That log delivers the properties a bare file layout cannot — atomic writes, snapshot isolation for readers, time travel to a previous version, and schema enforcement and evolution. Because the data files remain open-format Parquet, other engines can read them, which is the strategic argument for the approach.

Execution is distributed: a query is compiled into stages, each stage into tasks over partitions of the data, with shuffles between stages. The optimiser is cost-based and includes adaptive features that re-plan mid-query using statistics observed at runtime — useful precisely because file-based data often has poor or absent statistics up front.

flowchart TD
  L["_delta_log
v45 v46 v47 ..."] --> T["Table = log version + file list"] F1["part-0001.parquet"] --> T F2["part-0002.parquet"] --> T F3["part-0003.parquet
(written but not committed)"] -.->|not in v47| T T --> R1["Reader A sees v47"] T --> R2["Reader B sees v47"]
The uncommitted file physically exists in the same folder and is simply absent from the log version readers resolve. That is the entire mechanism — atomicity achieved by making the file list, rather than the files, the thing that changes.

The syntax that actually bites

Spark SQL is broadly familiar, with one behavioural setting that matters more than all the syntax combined:

  • ANSI mode. With it disabled, arithmetic overflow, invalid casts and division by zero return NULL instead of raising an error. With it enabled, they raise. The same query can therefore return a number on one cluster and fail on another, or — far worse — return NULLs that flow into an average and quietly change it. Inference Checking which mode a cluster runs in should be among the first things you do on an unfamiliar Databricks workspace, because it determines whether your data-quality problems announce themselves or hide.
  • Backticks quote identifiers. Names are case-insensitive for resolution.
  • Type names come from the Spark type system: STRING, INT/BIGINT, DOUBLE, DECIMAL(p,s), TIMESTAMP, ARRAY<T>, MAP<K,V>, STRUCT<...>.
  • Complex types are first-class: explode() turns an array into rows, collect_list/collect_set aggregate into one, and struct fields are addressed with dots.
  • Date functions: DATE_TRUNC('MONTH', d), TRUNC(d, 'MM'), DATE_ADD, DATEDIFF, date_format.
  • UPSERT is MERGE INTO, which the table format makes atomic. This is a genuine capability difference from plain Parquet, where updating a row means rewriting files by hand.
  • Table maintenance is SQL: OPTIMIZE compacts small files, VACUUM removes files no longer referenced by any retained version, DESCRIBE HISTORY shows the log.
  • QUALIFY is supported in current versions; window function coverage is complete.
sparkdelta_idioms.sql
-- MERGE is atomic because the log commits a new version, not the files.
MERGE INTO gold.subscriptions AS t
USING staging.subscription_changes AS s
   ON t.subscription_id = s.subscription_id
WHEN MATCHED AND s.cancelled_on IS NOT NULL THEN DELETE
WHEN MATCHED THEN UPDATE SET t.mrr = s.mrr, t.plan = s.plan
WHEN NOT MATCHED THEN INSERT *;

-- Time travel and history: the log is queryable.
SELECT COUNT(*) FROM gold.subscriptions VERSION AS OF 46;
SELECT COUNT(*) FROM gold.subscriptions TIMESTAMP AS OF '2024-06-01';
DESCRIBE HISTORY gold.subscriptions;

-- Maintenance is SQL, not an admin console.
OPTIMIZE gold.subscriptions ZORDER BY (customer_id);
VACUUM gold.subscriptions RETAIN 168 HOURS;

-- Complex types are ordinary here.
SELECT customer_id, explode(order_ids) AS order_id
FROM gold.customer_orders;

The analytical SQL itself is portable. This runs on DuckDB and would run on Databricks SQL unchanged:

sqldefensive_arithmetic.sql
-- Written so that it behaves identically whether or not ANSI mode is on:
-- no cast that can fail, no division that can hit zero.
SELECT a.channel,
       SUM(a.cost)                                          AS spend,
       SUM(a.clicks)                                        AS clicks,
       CAST(SUM(a.cost) AS DECIMAL(18,6))
         / NULLIF(SUM(a.clicks), 0)                         AS cost_per_click,
       CAST(SUM(a.clicks) AS DECIMAL(18,6))
         / NULLIF(SUM(a.impressions), 0)                    AS ctr
FROM ad_spend AS a
GROUP BY a.channel
ORDER BY spend DESC;
Result
channelspendclickscost_per_clickctr
paid_social18040.00195500.9227620.013196
paid_search19730.00350600.5627500.038663

Pricing and scaling model

Fact Databricks bills a platform unit for compute consumed, on top of the underlying cloud provider's charge for the machines themselves — so a cluster costs you twice, once to your cloud and once to the platform. Rates vary by workload type, cloud and region. Object storage is billed by your cloud provider. Serverless options remove the cluster-management step.

Scaling is by cluster size and autoscaling, and the same discipline applies as with any cluster-based system: idle clusters cost money, so auto-termination matters. The distinctive cost trap is not the cluster, though — it is the small files problem. Streaming or frequent micro-batch writes produce many tiny Parquet files; every query then pays per-file overhead to list and open them, and performance degrades steadily until someone runs a compaction. It is the lakehouse equivalent of vacuum debt.

Common pitfall — VACUUM destroys time travel

Two engines in this Part have a command called VACUUM and they do different things. On PostgreSQL it reclaims dead row versions and is routine. Here it deletes the underlying data files that are no longer referenced by the current version — which is exactly what makes time travel possible. Running it with a short retention to save storage silently destroys the ability to query or restore earlier versions, and there is no undo. Inference Treat the retention period as a recovery-window decision made with whoever owns data recovery, not as a storage-tidying setting an engineer picks alone.

When to choose it — and when not to

Choose Databricks or Spark SQL when the same data must serve SQL analytics, machine learning and streaming without being copied into three systems — this is the strongest argument for the platform and the one that does not reduce to price; when you want data stored in open formats you could read with another engine, so leaving is a real option; when your workloads mix SQL with Python or Scala in the same pipeline; or when you process genuinely large volumes where distributed execution earns its coordination overhead.

Do not choose it when your workload is pure SQL analytics at a moderate scale — a dedicated warehouse is simpler and usually cheaper for that; when nobody will own cluster configuration, file compaction and retention policy, because unmaintained lakehouses degrade in ways unmaintained warehouses do not; when you need low-latency single-row reads; or when the data fits comfortably on one machine, in which case a single-node engine is faster and enormously simpler.

In the field

Two recurring failure modes on real lakehouses, both operational rather than analytical. The first is the small files problem, which arrives with streaming ingestion and is fixed by scheduled compaction — not by tuning queries. The second is a table with no owner for its maintenance: nobody schedules compaction, nobody sets retention, and the table works fine for six months and then does not. Warehouses hide this work from you; lakehouses hand it to you along with the openness and the lower storage cost. That trade is reasonable, but it must be staffed rather than assumed.

Interview angle

“What does a table format give you that a folder of Parquet does not?” is the question, and the complete answer is a list: atomic multi-file commits, snapshot isolation so concurrent readers see one consistent version, time travel, schema enforcement and evolution, and efficient row-level updates and deletes via MERGE. Candidates who answer only “it's faster” have missed the point entirely — the format's primary contribution is correctness under concurrency, and performance follows from the metadata it happens to maintain.

Exercise 13.8

Healthcare A nightly job computes a per-patient risk score and overwrites a table. Analysts sometimes query it while the job is running and occasionally see half the expected rows; once, a bad input produced a table of nonsense that was noticed three days later. Explain how a transaction log addresses both incidents, then write a portable query that would have detected the second one.

Show solution

Incident one — partial reads. Overwriting a folder of files is not atomic: readers arriving mid-write see whatever files currently exist. With a transaction log, the new files are written first and then a single commit publishes them as a new version; readers resolve a version at query start and see either the complete old table or the complete new one, never a mixture. Snapshot isolation is the property, and it is bought without locking anyone out.

Incident two — bad data undiscovered. Time travel makes recovery a query rather than a restore from backup: read the previous version, compare, and either roll back or reprocess. Note that this only works while the old files still exist, which is precisely what VACUUM retention governs — the two features are the same mechanism seen from two sides.

A volume and distribution check that would have caught it on the day, written portably:

sqlfreshness_check.sql
-- Compare the most recent day's volume against the prior seven days.
-- Flag if today is less than half or more than double the recent average.
WITH daily AS (
  SELECT CAST(started_at AS DATE) AS d, COUNT(*) AS rows_loaded
  FROM sessions
  GROUP BY CAST(started_at AS DATE)
),
scored AS (
  SELECT d,
         rows_loaded,
         AVG(rows_loaded) OVER (ORDER BY d
                                ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING) AS prior_avg
  FROM daily
)
SELECT d,
       rows_loaded,
       ROUND(prior_avg, 2) AS prior_avg,
       CASE WHEN prior_avg IS NULL                    THEN 'no baseline'
            WHEN rows_loaded < 0.5 * prior_avg        THEN 'ALERT: volume drop'
            WHEN rows_loaded > 2.0 * prior_avg        THEN 'ALERT: volume spike'
            ELSE 'ok' END                             AS verdict
FROM scored
ORDER BY d;

Two details make this a usable check rather than a toy. The window frame ends at 1 PRECEDING so today's value is excluded from its own baseline — including it would drag the average toward the anomaly and blunt the very signal you want. And the prior_avg IS NULL branch handles the first few days explicitly, because a check that errors or alerts on cold start gets switched off in its first week and never switched back on.

Knowledge check

The same query returns numeric results on one Databricks cluster and raises a divide-by-zero error on another. What is the most likely explanation?

Key takeaway

The lakehouse idea is open Parquet files plus a transaction log, and the log is what supplies atomic commits, snapshot isolation, time travel, schema enforcement and MERGE. Choose it when one copy of the data must serve SQL, machine learning and streaming; avoid it when a plain warehouse would do, because you inherit maintenance — compaction of small files, and retention policy that trades storage against your ability to recover. Watch ANSI mode, which changes whether failed arithmetic raises or silently returns NULL, and remember that VACUUM here deletes files rather than reclaiming space.

The Lab

Interactive SQL Lab

A real SQL engine running in your browser, a set of visual simulators for the ideas that are hardest to picture, and a spaced-repetition deck. Nothing here needs a server or an internet connection.

Lab 1·use throughout the course

SQL Playground: Run Queries Against Live Data

Type SQL, press Ctrl+Enter, see real results. The engine below is a genuine SQL implementation — parser, planner and executor — written to run offline in this page.

Every table below is the shopline dataset used throughout Parts 6–15: a small outdoor-goods retailer with customers, orders, products, ad spend, web events, subscriptions and an A/B test. It is deliberately small enough to reason about by hand and rich enough to demonstrate every pattern in the course — including NULLs, customers with no orders, returned and cancelled orders, and repeat buyers.

Schema explorer
Example queries
What this engine does and does not do

It executes SELECT queries: WITH CTEs, all join types, WHERE, GROUP BY/HAVING, DISTINCT, ORDER BY, LIMIT/OFFSET, subqueries, CASE, CAST, IN/BETWEEN/LIKE, aggregates, and window functions with PARTITION BY/ORDER BY. Fact Its results were verified against DuckDB across a suite of queries covering joins, grouping, window functions and NULL handling.

It deliberately refuses INSERT, UPDATE, DELETE and CREATE, so the sample data is always in a known state when you follow a lesson. Those statements are taught in Part 4 and Part 7 — run them against a real database when you get there. It also does not implement recursive CTEs, PIVOT, or explicit window frame clauses (ROWS BETWEEN …); those appear in lessons with worked results instead.

How to use this well

Do not read SQL — run it. When a lesson shows a query, paste it here and change one thing: swap INNER JOIN for LEFT JOIN and watch rows appear; delete a GROUP BY column and watch the error; add WHERE country IS NULL and watch the count drop. Inference The fastest way to build SQL intuition is a tight loop of predict → run → explain the difference, because a surprising result is the moment your mental model gets corrected.

Lab 2·interactive

Join Visualiser: See Every Join Type

Joins are the single biggest source of wrong numbers in analytics. This shows you exactly which rows survive, which are invented, and where NULLs come from.

Two tiny tables. L has four customers, R has orders for only some of them — plus one order whose customer no longer exists. Pick a join type and watch which rows pair up.

Common pitfall — the join that multiplies your revenue

Switch the visualiser to a key that appears twice on both sides and count the output rows. A join is not a lookup: if a key appears m times on the left and n times on the right, you get m × n rows. Join orders to order_items and then SUM(order_total) and you will overstate revenue by exactly the number of items per order. Inference This is why experienced analysts check row counts before and after every join — the bug is silent, and the number still looks plausible.

Lab 3·interactive

Window Function Animator

Step through a window function row by row and watch the frame slide, the partition reset, and the value get computed.

The hardest thing about window functions is that nothing is removed. GROUP BY collapses ten rows into one; OVER keeps all ten and attaches a computed value to each. Step through below and watch the highlighted frame — the set of rows feeding the current answer.

Lab 4·interactive

Index Simulator: Why B-Trees Win

Compare a full table scan against a B-tree lookup and watch the number of rows the engine must touch.

An index is not magic and it is not free. It is a sorted structure that lets the engine discard most of the table without looking at it. Drag the table size and watch how differently the two strategies scale.

💡Intuition — why height grows so slowly

Each node in a B-tree holds many keys, so every level multiplies the rows you can address. If a node holds around 100 keys, one level covers ~100 rows, two levels ~10,000, three ~1,000,000, four ~100,000,000. Fact That is what “logarithmic” means here: to make the lookup one step longer you must multiply the table size, not add to it. It is the same reason a dictionary of a million words takes only a few more page-flips than one of a thousand.

Lab 5·daily, 10 min

Flashcards with Spaced Repetition

The retention layer of the course. Cards you find hard come back sooner; cards you know drift further apart.

Fact Reviewing material at increasing intervals produces better long-term retention than the same total time spent re-reading — the spacing effect is one of the most reliably replicated findings in learning research. This deck implements a simplified version of that idea: rate a card Again and it returns in this session; rate it Easy and its interval multiplies.

Question
click to reveal
Answer
Lab 6·30–60 min per exam

Practice Exams & Interview Simulator

Timed, scored, and diagnostic — it tells you which part of the course to revisit.

In the field

Real SQL interviews are rarely won on syntax. They are won on narration: stating your assumptions about the grain of each table, asking whether duplicates are possible, saying “I'll use a LEFT JOIN here because I want customers with zero orders to appear”. Inference The interviewer cannot see your reasoning unless you speak it, and a correct query produced in silence scores worse than a nearly-correct one produced with clear reasoning — because only one of those tells them what you'll be like to work with.

Part 14

Industry Projects

Twenty-one end-to-end projects: business problem, data model, questions, SQL, optimisation, dashboard and executive read-out.

Lesson 14.1·15 min read

Project 1 — Streaming Video: Content Performance & Watch-Time Analytics (Netflix-style)

Decide which titles earned their licensing cost, using completion, abandonment and retention lift rather than raw view counts.

Everything in this part uses invented companies and invented data. The point is the analytical pattern an industry uses — its grain, its joins, its traps — not any claim about how a named business runs internally.

The business problem

Digital media A subscription streaming service, Northwind Screen, pays a licence fee or a production budget for every title in its catalogue. Roughly once a quarter, the Head of Content sits with the CFO and decides three things: which licences to renew, which originals to commission a second season of, and which titles to quietly let expire.

The naive metric is views. It is close to useless. A title can be opened by a hundred thousand people and abandoned by ninety-five thousand of them in the first eight minutes — that title generated a great deal of disappointment and almost no value. Conversely a slow, 400-minute documentary series with a tenth of the openings can absorb more subscriber hours and, more importantly, keep people paying next month.

So the decision-maker is the Head of Content, the decision is a renewal budget, and the currency the analysis must be denominated in is subscriber hours and retained subscriptions per pound of content cost. Everything below builds toward that.

The data model

erDiagram
  ACCOUNTS ||--o{ PROFILES : "contains"
  PROFILES ||--o{ PLAYS : "generates"
  TITLES ||--o{ PLAYS : "is watched in"
  TITLES ||--o{ COSTS : "is priced by"
Four tables. Note that the billing relationship lives on accounts but the behaviour lives on profiles — a household can hold five viewers and one subscription, which is the single most common source of double counting in streaming analytics.
TableGrain (one row = )Key columns
sv_accountsone paying subscriptionaccount_id, plan, signup_date, cancelled_on
sv_profilesone viewer inside a householdprofile_id, account_id, country
sv_titlesone licensable titletitle_id, title_name, genre, runtime_min, released_on
sv_playsone playback sessionplay_id, profile_id, title_id, started_at, minutes_watched

The grain of sv_plays is the thing to hold on to. It is not one row per viewer per title — a viewer who watches in three sittings produces three rows. Any query that treats a play row as a person will overstate reach and understate completion.

sqlsv_schema.sql
CREATE TABLE sv_accounts (
  account_id   INTEGER PRIMARY KEY,
  plan         VARCHAR,
  signup_date  DATE,
  cancelled_on DATE          -- NULL = still subscribed
);
INSERT INTO sv_accounts VALUES
 (1,'standard',DATE '2024-11-02',NULL),
 (2,'premium', DATE '2024-11-20',NULL),
 (3,'basic',   DATE '2024-12-05',DATE '2025-03-05'),
 (4,'standard',DATE '2025-01-11',NULL),
 (5,'premium', DATE '2025-01-28',NULL),
 (6,'basic',   DATE '2025-02-14',DATE '2025-04-14');

CREATE TABLE sv_profiles (
  profile_id INTEGER PRIMARY KEY,
  account_id INTEGER,
  country    VARCHAR
);
INSERT INTO sv_profiles VALUES
 (11,1,'GB'),(12,1,'GB'),(13,2,'IE'),(14,3,'GB'),
 (15,4,'SE'),(16,4,'SE'),(17,5,'IT'),(18,6,'GB');

CREATE TABLE sv_titles (
  title_id    INTEGER PRIMARY KEY,
  title_name  VARCHAR,
  genre       VARCHAR,
  runtime_min INTEGER,
  released_on DATE
);
INSERT INTO sv_titles VALUES
 (101,'Harbour Lights',   'drama',       104, DATE '2025-01-06'),
 (102,'The Long Ledger',  'documentary', 312, DATE '2025-01-06'),
 (103,'Nine Below',       'thriller',     97, DATE '2025-01-20'),
 (104,'Kitchen Rules',    'reality',     220, DATE '2025-02-03'),
 (105,'Salt and Stone',   'drama',       118, DATE '2025-02-17');

CREATE TABLE sv_plays (
  play_id         INTEGER PRIMARY KEY,
  profile_id      INTEGER,
  title_id        INTEGER,
  started_at      TIMESTAMP,
  minutes_watched INTEGER
);
INSERT INTO sv_plays VALUES
 (1,11,101,TIMESTAMP '2025-01-07 20:10:00', 104),
 (2,12,101,TIMESTAMP '2025-01-08 21:00:00',   9),
 (3,13,101,TIMESTAMP '2025-01-09 19:30:00',  95),
 (4,14,101,TIMESTAMP '2025-01-11 22:05:00',  12),
 (5,11,102,TIMESTAMP '2025-01-12 19:00:00', 160),
 (6,11,102,TIMESTAMP '2025-01-19 19:00:00', 145),
 (7,13,102,TIMESTAMP '2025-01-14 20:00:00', 300),
 (8,15,103,TIMESTAMP '2025-01-22 21:15:00',  97),
 (9,16,103,TIMESTAMP '2025-01-23 18:40:00',   6),
 (10,17,103,TIMESTAMP '2025-01-25 20:00:00',  90),
 (11,18,104,TIMESTAMP '2025-02-05 20:00:00',  40),
 (12,15,104,TIMESTAMP '2025-02-06 20:00:00',  35),
 (13,17,105,TIMESTAMP '2025-02-18 21:00:00', 118),
 (14,13,105,TIMESTAMP '2025-02-20 20:30:00', 110),
 (15,11,105,TIMESTAMP '2025-02-25 19:45:00',  14);

SELECT count(*) AS plays_loaded FROM sv_plays;

The questions

  1. How many distinct viewers and how many distinct households started each title? (Reach, at two grains.)
  2. What share of viewers who started a title finished it, where “finished” means at least 90% of runtime consumed across all their sittings?
  3. What is the abandonment rate — viewers who stopped inside the first 10% — and which titles are worst?
  4. How many subscriber hours did each title absorb, and what share of total platform hours is that?
  5. Do households that watched a given title in their first 30 days survive longer than households that did not?
  6. Which genres over- or under-deliver hours relative to the number of titles carried?
  7. How quickly does a title's daily watch time decay after release?

The SQL

Question 2 and 3 together. The trap is that completion is per viewer, not per play. You must roll plays up to profile×title first, and only then compare against runtime. Doing the division at play grain silently punishes anyone who watched in more than one sitting.

sqlsv_completion.sql
CREATE TABLE sv_titles AS SELECT * FROM (VALUES
 (101,'Harbour Lights','drama',104),(102,'The Long Ledger','documentary',312),
 (103,'Nine Below','thriller',97),(104,'Kitchen Rules','reality',220),
 (105,'Salt and Stone','drama',118)
) AS t(title_id,title_name,genre,runtime_min);

CREATE TABLE sv_plays AS SELECT * FROM (VALUES
 (11,101,104),(12,101,9),(13,101,95),(14,101,12),
 (11,102,160),(11,102,145),(13,102,300),
 (15,103,97),(16,103,6),(17,103,90),
 (18,104,40),(15,104,35),
 (17,105,118),(13,105,110),(11,105,14)
) AS t(profile_id,title_id,minutes_watched);

-- Step 1: collapse many sittings into ONE row per viewer per title.
WITH viewer_title AS (
  SELECT profile_id,
         title_id,
         SUM(minutes_watched) AS mins
  FROM sv_plays
  GROUP BY profile_id, title_id
),
scored AS (
  SELECT vt.title_id,
         t.title_name,
         vt.profile_id,
         vt.mins,
         -- CAST first: integer division would truncate every ratio to 0 or 1.
         CAST(vt.mins AS DOUBLE) / NULLIF(t.runtime_min,0) AS pct_watched
  FROM viewer_title vt
  JOIN sv_titles t ON t.title_id = vt.title_id
)
SELECT title_name,
       COUNT(*)                                              AS starters,
       ROUND(AVG(pct_watched),3)                             AS avg_pct_watched,
       ROUND(AVG(CASE WHEN pct_watched >= 0.90 THEN 1 ELSE 0 END),3) AS completion_rate,
       ROUND(AVG(CASE WHEN pct_watched <  0.10 THEN 1 ELSE 0 END),3) AS abandon_rate
FROM scored
GROUP BY title_name
ORDER BY completion_rate DESC, starters DESC, title_name;
Result
title_namestartersavg_pct_watchedcompletion_rateabandon_rate
The Long Ledger20.9701.0000.000
Nine Below30.6630.6670.333
Salt and Stone30.6840.6670.000
Harbour Lights40.5290.5000.250
Kitchen Rules20.1700.0000.000

Kitchen Rules shows the value of separating the two rates: nobody abandoned it inside the first 10%, and nobody got near the end either. That is the signature of an episodic title being measured against a whole-series runtime — a modelling error, not a content failure. Real catalogues need the series/episode grain resolved before completion means anything.

Question 4 — hours and share. A window function over the aggregate gives share without a second pass.

sqlsv_hours_share.sql
CREATE TABLE sv_titles AS SELECT * FROM (VALUES
 (101,'Harbour Lights','drama'),(102,'The Long Ledger','documentary'),
 (103,'Nine Below','thriller'),(104,'Kitchen Rules','reality'),
 (105,'Salt and Stone','drama')
) AS t(title_id,title_name,genre);

CREATE TABLE sv_plays AS SELECT * FROM (VALUES
 (101,104),(101,9),(101,95),(101,12),(102,160),(102,145),(102,300),
 (103,97),(103,6),(103,90),(104,40),(104,35),(105,118),(105,110),(105,14)
) AS t(title_id,minutes_watched);

SELECT t.genre,
       t.title_name,
       ROUND(SUM(p.minutes_watched)/60.0, 1) AS watch_hours,
       ROUND(100.0 * SUM(p.minutes_watched)
             / SUM(SUM(p.minutes_watched)) OVER (), 1) AS pct_of_platform,
       ROUND(100.0 * SUM(p.minutes_watched)
             / SUM(SUM(p.minutes_watched)) OVER (PARTITION BY t.genre), 1) AS pct_of_genre
FROM sv_plays p
JOIN sv_titles t ON t.title_id = p.title_id
GROUP BY t.genre, t.title_name
ORDER BY watch_hours DESC;

The nested aggregate SUM(SUM(x)) OVER () reads oddly the first time. The inner SUM runs as part of the GROUP BY; the window then runs over the grouped rows. It is the standard way to put a total and a part on the same row without a self-join or a subquery.

Question 5 — retention lift. This is the question that actually settles a renewal argument, and it is the one most often done wrong. You cannot simply compare survival of watchers versus non-watchers: people who watch more of anything are already more engaged. The honest version restricts to a comparable window (first 30 days of subscription) and reports the gap as an association, not a causal effect.

sqlsv_retention_lift.sql
CREATE TABLE sv_accounts AS SELECT * FROM (VALUES
 (1,DATE '2024-11-02',CAST(NULL AS DATE)),(2,DATE '2024-11-20',NULL),
 (3,DATE '2024-12-05',DATE '2025-03-05'),(4,DATE '2025-01-11',NULL),
 (5,DATE '2025-01-28',NULL),(6,DATE '2025-02-14',DATE '2025-04-14')
) AS t(account_id,signup_date,cancelled_on);

CREATE TABLE sv_profiles AS SELECT * FROM (VALUES
 (11,1),(12,1),(13,2),(14,3),(15,4),(16,4),(17,5),(18,6)
) AS t(profile_id,account_id);

CREATE TABLE sv_plays AS SELECT * FROM (VALUES
 (11,101,DATE '2025-01-07'),(12,101,DATE '2025-01-08'),(13,101,DATE '2025-01-09'),
 (14,101,DATE '2025-01-11'),(11,102,DATE '2025-01-12'),(13,102,DATE '2025-01-14'),
 (15,103,DATE '2025-01-22'),(16,103,DATE '2025-01-23'),(17,103,DATE '2025-01-25'),
 (18,104,DATE '2025-02-05'),(15,104,DATE '2025-02-06'),(17,105,DATE '2025-02-18')
) AS t(profile_id,title_id,play_date);

WITH exposure AS (          -- did the HOUSEHOLD watch title 103 in its first 30 days?
  SELECT a.account_id,
         a.cancelled_on,
         MAX(CASE WHEN p.title_id = 103
                   AND p.play_date <= a.signup_date + INTERVAL 30 DAY
                  THEN 1 ELSE 0 END) AS saw_title
  FROM sv_accounts a
  LEFT JOIN sv_profiles pr ON pr.account_id = a.account_id
  LEFT JOIN sv_plays    p  ON p.profile_id  = pr.profile_id
  GROUP BY a.account_id, a.cancelled_on
)
SELECT CASE WHEN saw_title = 1 THEN 'watched Nine Below'
            ELSE 'did not' END              AS segment,
       COUNT(*)                             AS households,
       SUM(CASE WHEN cancelled_on IS NULL THEN 1 ELSE 0 END) AS still_active,
       ROUND(AVG(CASE WHEN cancelled_on IS NULL THEN 1.0 ELSE 0.0 END),3) AS survival_rate
FROM exposure
GROUP BY 1
ORDER BY 1;
Common pitfall — profile-level survival

Cancellation happens to an account, not a profile. Grouping the survival calculation by profile_id weights a five-person household five times as heavily as a single viewer, and inflates the apparent effect of any title that skews family-friendly. Roll behaviour up to the billing entity before you measure anything financial. Inference Most “this show saves subscriptions” claims that fall apart on review died on exactly this join.

Optimisation notes

At production scale sv_plays is the only table that matters: billions of rows, appended continuously. Four things change.

  • Partition by date, cluster by title. Almost every content question is “this title, this window”. Partitioning on started_at's date and sorting within partitions on title_id turns a full scan into a small range read. In a columnar engine this is the single highest-leverage change available.
  • Pre-aggregate to profile×title×day. Completion, reach and hours all derive from that one rollup, and it is typically two to three orders of magnitude smaller than raw plays. Build it once nightly; let the dashboards hit it.
  • COUNT(DISTINCT) is the expensive operator, not the join. Distinct viewer counts across a long window force a large hash set. Approximate counting (approx_count_distinct / HyperLogLog) is accurate to a fraction of a percent and often ten times cheaper — acceptable for a dashboard, not for a royalty calculation.
  • Keep runtime in a dimension, not in the fact. Runtimes get corrected. If you froze the runtime onto each play row, every correction rewrites history at fact grain instead of one dimension row.

The dashboard

PanelChartWhy that form
Platform watch hours, last 90 daysLine with prior-period ghostTrend plus context; a bare number hides seasonality
Top 20 titles by hoursHorizontal barLong labels; ranking is the message
Completion vs abandonmentScatter, one dot per title, size = hoursShows the four quadrants at once — the “high reach, low completion” corner is where money is being wasted
Decay curve, days since releaseSmall multiples line chartComparing shapes matters more than levels
Hours per £1k of content costBar, sorted, with catalogue median lineThe efficiency view the CFO actually asks for

Do not put a pie chart of genre share on this dashboard. Genre share moves by fractions of a percent per quarter and a pie makes small changes invisible; a stacked area over time answers the same question and shows the movement.

The executive read-out

  • The headline: “Five titles account for a third of all watch hours; twelve titles account for under two per cent between them.”
  • The surprise: “Our highest-reach title has the second-worst completion rate. People open it and leave — we are paying for a thumbnail, not a programme.”
  • The money: “Hours per pound of licence cost varies eleven-fold across the renewal list. That spread, not the total budget, is the decision.”
  • The caveat: “Retention differences between watchers and non-watchers are associations. Engaged households watch more of everything. Treat this as a ranking signal, not a proof of causation.”
  • The ask: “Renew the top eight on hours-per-pound, drop the bottom five, and let us run a proper hold-out test on the three we are unsure about before next quarter.”
Interview angle

“How would you measure whether a show is worth renewing?” is a standard media-analytics prompt. Weak answers give view counts. Good answers give watch hours and completion. Strong answers name the grain problem out loud — plays are not viewers, profiles are not accounts — and then volunteer the causal caveat: engagement and retention are confounded, so the clean measurement is a hold-out or a staggered release, and the SQL is only a ranking device until you have one.

Key takeaway

Streaming analytics is a grain discipline: plays roll up to viewers, viewers roll up to households, and money lives only at the household. Completion and abandonment must be computed after that rollup, never at play level. And every “this title retains subscribers” statement is an association until an experiment says otherwise — say so before someone else does.

Lesson 14.2·15 min read

Project 2 — Ride Hailing: Supply, Demand, Surge & Driver Utilisation (Uber-style)

Measure a two-sided marketplace where the most important rows are the ones that never happened.

The business problem

Product analytics Kerbline operates a ride-hailing app in one city. The City Operations Manager owns two levers and one constraint. The levers are driver incentives (pay a bonus to get more cars online at 07:00) and dynamic pricing (raise the fare when demand exceeds supply). The constraint is that both cost money — incentives directly, surge indirectly, because a rider who sees 2.1× often closes the app and takes the bus, and may not come back.

Every Monday the Ops Manager decides where to spend next week's incentive budget: which zones, which hours, how much. The question underneath is always the same — where are we losing rides we could have served?

That question cannot be answered from completed trips. Completed trips are the rides that worked. The signal lives in the requests that were never matched, the riders who saw a price and did not book, and the drivers who sat online and empty. A ride-hailing warehouse that only stores trips is structurally incapable of running its own business, which is why the request log — including failures — is the central table.

The data model

erDiagram
  RIDERS ||--o{ REQUESTS : "opens"
  ZONES ||--o{ REQUESTS : "originates in"
  REQUESTS ||--o| TRIPS : "may become"
  DRIVERS ||--o{ TRIPS : "fulfils"
  DRIVERS ||--o{ SHIFTS : "logs online time"
Note REQUESTS ||--o| TRIPS — zero-or-one. A request that never became a trip is not a data quality problem; it is the most valuable row in the warehouse.
TableGrainKey columns
rh_requestsone time a rider asked for a carrequest_id, rider_id, zone_id, requested_at, surge_mult, outcome
rh_tripsone completed journeytrip_id, request_id, driver_id, fare, duration_min
rh_shiftsone continuous online period per drivershift_id, driver_id, online_min, on_trip_min, zone_id
rh_zonesone geographic zonezone_id, zone_name

outcome takes three values that must never be collapsed: completed, no_driver (supply failure — we could not find a car) and rider_cancel (demand failure — usually price or ETA). Merging them into “failed” destroys the only information that tells you which lever to pull.

sqlrh_schema.sql
CREATE TABLE rh_zones (zone_id INTEGER PRIMARY KEY, zone_name VARCHAR);
INSERT INTO rh_zones VALUES (1,'Central'),(2,'Riverside'),(3,'Airport'),(4,'Northgate');

CREATE TABLE rh_requests (
  request_id   INTEGER PRIMARY KEY,
  rider_id     INTEGER,
  zone_id      INTEGER,
  requested_at TIMESTAMP,
  surge_mult   NUMERIC(4,2),
  outcome      VARCHAR      -- completed | no_driver | rider_cancel
);
INSERT INTO rh_requests VALUES
 (1, 501,1,TIMESTAMP '2025-05-06 07:10:00',1.00,'completed'),
 (2, 502,1,TIMESTAMP '2025-05-06 07:20:00',1.40,'completed'),
 (3, 503,1,TIMESTAMP '2025-05-06 07:35:00',1.80,'rider_cancel'),
 (4, 504,1,TIMESTAMP '2025-05-06 07:50:00',2.10,'no_driver'),
 (5, 505,2,TIMESTAMP '2025-05-06 07:15:00',1.00,'completed'),
 (6, 506,2,TIMESTAMP '2025-05-06 07:45:00',1.20,'completed'),
 (7, 507,3,TIMESTAMP '2025-05-06 08:05:00',1.60,'completed'),
 (8, 508,3,TIMESTAMP '2025-05-06 08:15:00',2.20,'rider_cancel'),
 (9, 509,3,TIMESTAMP '2025-05-06 08:25:00',2.40,'no_driver'),
 (10,510,4,TIMESTAMP '2025-05-06 18:05:00',1.00,'completed'),
 (11,511,4,TIMESTAMP '2025-05-06 18:30:00',1.10,'completed'),
 (12,512,4,TIMESTAMP '2025-05-06 18:55:00',1.30,'rider_cancel'),
 (13,513,1,TIMESTAMP '2025-05-06 18:20:00',1.90,'no_driver'),
 (14,514,1,TIMESTAMP '2025-05-06 18:40:00',2.00,'completed');

CREATE TABLE rh_trips (
  trip_id      INTEGER PRIMARY KEY,
  request_id   INTEGER,
  driver_id    INTEGER,
  fare         NUMERIC(8,2),
  duration_min INTEGER
);
INSERT INTO rh_trips VALUES
 (9001,1, 901,11.20,14),(9002,2, 902,16.80,18),(9003,5, 903, 9.40,11),
 (9004,6, 901,12.00,15),(9005,7, 904,31.50,26),(9006,10,902,10.80,12),
 (9007,11,903,13.60,16),(9008,14,904,24.00,21);

CREATE TABLE rh_shifts (
  shift_id   INTEGER PRIMARY KEY,
  driver_id  INTEGER,
  zone_id    INTEGER,
  online_min INTEGER,
  on_trip_min INTEGER
);
INSERT INTO rh_shifts VALUES
 (1,901,1,240,120),(2,902,1,300,110),(3,903,2,180, 95),
 (4,904,3,260,140),(5,905,4,200, 40),(6,906,4,220, 55);

SELECT outcome, count(*) AS requests FROM rh_requests GROUP BY outcome ORDER BY outcome;

The questions

  1. What is the fulfilment rate by zone and hour, split into supply failure and demand failure?
  2. Where and when is unmet demand concentrated — the incentive shortlist?
  3. How does rider conversion fall as the surge multiplier rises, and where is the cliff?
  4. What is driver utilisation (on-trip minutes ÷ online minutes) by zone, and which zones are over-supplied?
  5. What is revenue per online driver-hour by zone — the number an incentive has to beat?
  6. How much of the fare uplift from surge is recovered versus lost to cancellations?
  7. Which riders have hit a failed request three times in a row? (Churn risk list.)

The SQL

Question 1 — fulfilment decomposed. A single conditional aggregation over the request log gives all three rates in one pass. This is the shape of query you will write hundreds of times: one GROUP BY, several AVG(CASE WHEN ...) columns, each one a rate.

sqlrh_fulfilment.sql
CREATE TABLE rh_zones AS SELECT * FROM (VALUES
 (1,'Central'),(2,'Riverside'),(3,'Airport'),(4,'Northgate')
) AS t(zone_id,zone_name);

CREATE TABLE rh_requests AS SELECT * FROM (VALUES
 (1,TIMESTAMP '2025-05-06 07:10:00','completed'),(1,TIMESTAMP '2025-05-06 07:20:00','completed'),
 (1,TIMESTAMP '2025-05-06 07:35:00','rider_cancel'),(1,TIMESTAMP '2025-05-06 07:50:00','no_driver'),
 (2,TIMESTAMP '2025-05-06 07:15:00','completed'),(2,TIMESTAMP '2025-05-06 07:45:00','completed'),
 (3,TIMESTAMP '2025-05-06 08:05:00','completed'),(3,TIMESTAMP '2025-05-06 08:15:00','rider_cancel'),
 (3,TIMESTAMP '2025-05-06 08:25:00','no_driver'),(4,TIMESTAMP '2025-05-06 18:05:00','completed'),
 (4,TIMESTAMP '2025-05-06 18:30:00','completed'),(4,TIMESTAMP '2025-05-06 18:55:00','rider_cancel'),
 (1,TIMESTAMP '2025-05-06 18:20:00','no_driver'),(1,TIMESTAMP '2025-05-06 18:40:00','completed')
) AS t(zone_id,requested_at,outcome);

SELECT z.zone_name,
       EXTRACT(hour FROM r.requested_at)                  AS hr,
       COUNT(*)                                           AS requests,
       ROUND(AVG(CASE WHEN r.outcome='completed'    THEN 1.0 ELSE 0 END),2) AS fulfilment_rate,
       ROUND(AVG(CASE WHEN r.outcome='no_driver'    THEN 1.0 ELSE 0 END),2) AS supply_fail_rate,
       ROUND(AVG(CASE WHEN r.outcome='rider_cancel' THEN 1.0 ELSE 0 END),2) AS demand_fail_rate
FROM rh_requests r
JOIN rh_zones z ON z.zone_id = r.zone_id
GROUP BY z.zone_name, EXTRACT(hour FROM r.requested_at)
HAVING COUNT(*) >= 2
ORDER BY supply_fail_rate DESC, requests DESC, z.zone_name;

Read the two failure columns as a diagnosis. High supply_fail_rate with low demand_fail_rate means riders wanted the ride at the offered price and there was no car — spend the incentive. The reverse means there were cars and the price scared people off — the incentive is wasted and the pricing curve is the problem.

Question 3 — the surge cliff. Bucket the multiplier and measure conversion within each bucket. The instinct to fit a smooth curve is wrong at this stage; the operational question is “is there a step change, and where?”, and buckets answer it without assuming a functional form.

sqlrh_surge_cliff.sql
CREATE TABLE rh_requests AS SELECT * FROM (VALUES
 (1.00,'completed'),(1.40,'completed'),(1.80,'rider_cancel'),(2.10,'no_driver'),
 (1.00,'completed'),(1.20,'completed'),(1.60,'completed'),(2.20,'rider_cancel'),
 (2.40,'no_driver'),(1.00,'completed'),(1.10,'completed'),(1.30,'rider_cancel'),
 (1.90,'no_driver'),(2.00,'completed')
) AS t(surge_mult,outcome);

WITH bucketed AS (
  SELECT CASE WHEN surge_mult < 1.25 THEN '1.00-1.24'
              WHEN surge_mult < 1.50 THEN '1.25-1.49'
              WHEN surge_mult < 2.00 THEN '1.50-1.99'
              ELSE '2.00+'          END AS surge_band,
         outcome
  -- Exclude supply failures: a rider who was never offered a car
  -- cannot tell us anything about willingness to pay.
  FROM rh_requests
  WHERE outcome <> 'no_driver'
)
SELECT surge_band,
       COUNT(*) AS offered,
       SUM(CASE WHEN outcome='completed' THEN 1 ELSE 0 END) AS booked,
       ROUND(AVG(CASE WHEN outcome='completed' THEN 1.0 ELSE 0 END),3) AS conversion,
       ROUND(AVG(CASE WHEN outcome='completed' THEN 1.0 ELSE 0 END)
             - LAG(AVG(CASE WHEN outcome='completed' THEN 1.0 ELSE 0 END))
               OVER (ORDER BY surge_band), 3) AS delta_vs_prev_band
FROM bucketed
GROUP BY surge_band
ORDER BY surge_band;
Common pitfall — surge is not randomly assigned

Surge is caused by scarcity. So high-surge requests happen exactly when supply is worst, and their conversion is depressed by both price and ETA at once. A raw price-elasticity read from this table over-attributes the drop to price. Inference The correct design compares similar scarcity conditions across a price experiment, or at minimum controls for quoted ETA. Report the raw curve as descriptive and say plainly that it is confounded.

Question 4 and 5 — driver utilisation and the incentive hurdle. Utilisation is the marketplace's efficiency ratio; revenue per online hour is what an incentive must beat to be worth paying.

sqlrh_utilisation.sql
CREATE TABLE rh_zones AS SELECT * FROM (VALUES
 (1,'Central'),(2,'Riverside'),(3,'Airport'),(4,'Northgate')
) AS t(zone_id,zone_name);

CREATE TABLE rh_shifts AS SELECT * FROM (VALUES
 (901,1,240,120),(902,1,300,110),(903,2,180,95),
 (904,3,260,140),(905,4,200,40),(906,4,220,55)
) AS t(driver_id,zone_id,online_min,on_trip_min);

CREATE TABLE rh_fares AS SELECT * FROM (VALUES
 (901,1,11.20),(902,1,16.80),(903,2,9.40),(901,1,12.00),
 (904,3,31.50),(902,1,10.80),(903,2,13.60),(904,3,24.00)
) AS t(driver_id,zone_id,fare);

WITH util AS (
  SELECT zone_id,
         SUM(online_min)  AS online_min,
         SUM(on_trip_min) AS on_trip_min,
         COUNT(DISTINCT driver_id) AS drivers
  FROM rh_shifts GROUP BY zone_id
),
rev AS (
  SELECT zone_id, SUM(fare) AS gross_fare FROM rh_fares GROUP BY zone_id
)
SELECT z.zone_name,
       u.drivers,
       ROUND(u.online_min / 60.0, 1)                                  AS online_hours,
       ROUND(100.0 * u.on_trip_min / NULLIF(u.online_min,0), 1)       AS utilisation_pct,
       ROUND(COALESCE(r.gross_fare,0) / NULLIF(u.online_min/60.0,0),2) AS fare_per_online_hour
FROM util u
JOIN rh_zones z ON z.zone_id = u.zone_id
LEFT JOIN rev r ON r.zone_id = u.zone_id
ORDER BY utilisation_pct DESC;

A zone with 20% utilisation and a high supply-failure rate is not over-supplied — it is mis-timed: the cars are online at the wrong hours. That distinction only appears when you compute utilisation by zone and hour, which is the natural next iteration of this query and the reason the shift table should really carry start and end timestamps rather than a duration.

Optimisation notes

  • Requests is the hot table and it is append-only with a strong time skew — almost every query touches the last 30 days. Partition by requested_at date; the older partitions can live on cheaper storage untouched for months.
  • Never join requests to trips just to know whether a trip happened. Denormalise the outcome onto the request row at write time. That one column removes the largest join in the warehouse from ninety per cent of queries.
  • Zone×hour is the natural aggregate. Build agg_zone_hour nightly with requests, completions, failures by type, online minutes and fare. Almost every operational dashboard reads only from it, and it is small enough to hold in memory.
  • Beware EXTRACT(hour FROM ...) on a partitioned column — wrapping the partition key in a function can defeat partition pruning on some engines. Filter on the raw timestamp range first, then extract for grouping.
  • Geospatial joins are the real cost in production. Resolve latitude/longitude to a zone id once, on ingest, and store the id. Doing point-in-polygon at query time is the classic way to make a five-second dashboard take four minutes.

The dashboard

PanelChartWhy
Fulfilment rate by zone × hourHeatmapTwo categorical axes and one rate — the textbook heatmap case; the eye finds the dark block instantly
Requests vs completions, last 14 daysDual line, same axisThe gap between the lines is the lost business
Failure split100% stacked bar by zoneComposition, not level — which lever to pull
Conversion by surge bandBar with conversion line overlayVolume and rate together stops anyone over-reading a band with nine requests in it
Utilisation by zoneBullet chart against targetA single number against a threshold; a gauge wastes the same space
In the field

Ops teams will ask for the heatmap at 15-minute granularity and by micro-zone. Resist it at first. At that resolution most cells hold two or three requests, and the map becomes a picture of sampling noise that people will nonetheless make staffing decisions from. Set a minimum-volume threshold per cell and grey out the rest — showing uncertainty honestly is part of the deliverable, not a caveat you bury in a footnote.

The executive read-out

  • Headline: “One in eight requests never becomes a ride. Two-thirds of those are supply failures — we had a willing rider and no car.”
  • Concentration: “Sixty per cent of the loss sits in two zones and two two-hour windows. This is a scheduling problem, not a fleet-size problem.”
  • Price: “Conversion holds up to roughly 1.5× and falls sharply above 2×. We are pricing past the point where riders stay.”
  • Efficiency: “Drivers in the worst zone are on-trip under a quarter of the time they are online. We are paying for availability we cannot sell.”
  • Ask: “Move the incentive budget to two specific windows, cap surge at 2× in those windows for four weeks, and hold a control zone so we can actually read the result.”
Interview angle

Marketplace interviews test whether you think about the denominator. “Rides grew 10% — is that good?” The expected move is to ask what happened to requests: if requests grew 20%, fulfilment got worse and the marketplace is degrading behind a growing headline. The second test is the failure taxonomy — candidates who split supply failure from demand failure without being prompted are demonstrating that they know which lever each one implies.

Key takeaway

In a two-sided marketplace, log the failures or you are flying blind. Fulfilment rate needs a request denominator; the failure split tells you whether to buy supply or lower price; and utilisation tells you whether the supply you bought is being used. Surge conversion curves are confounded by the scarcity that caused the surge — describe them, do not price off them without an experiment.

Lesson 14.3·15 min read

Project 3 — Marketplace Retail: Basket Analysis & Seller Performance (Amazon-style)

Find which products genuinely travel together, and rank third-party sellers on the defects that cost the platform its reputation.

The business problem

E-commerce Bazaar Hill is an online marketplace: the platform owns the storefront and the checkout, but most items are shipped by independent sellers. Two people need answers weekly.

The Merchandising Lead wants to know which products to recommend alongside which — the “frequently bought together” slot is prime real estate and filling it with a poor pairing costs a sale and a scroll. The Seller Operations Lead wants to know which sellers to warn, throttle or remove, because a marketplace's brand is set by its worst seller, not its average one.

Both questions look simple and both have a statistical trap. Recommending co-purchases by raw frequency just recommends whatever is popular — batteries appear with everything, which is why naive systems recommend batteries to everyone. Ranking sellers by defect count just ranks sellers by size. Each needs a normalised measure, and both normalisations are worth understanding properly because the same shape appears everywhere in analytics.

The data model

erDiagram
  SELLERS ||--o{ PRODUCTS : "lists"
  PRODUCTS ||--o{ ORDER_ITEMS : "appears in"
  ORDERS ||--o{ ORDER_ITEMS : "contains"
  ORDER_ITEMS ||--o| RETURNS : "may be returned"
The basket lives in ORDER_ITEMS; the seller quality signal lives in RETURNS. Both hang off the same line-item row, which is why one warehouse serves two very different stakeholders.
TableGrainKey columns
mr_sellersone third-party sellerseller_id, seller_name, joined_on
mr_productsone listingproduct_id, seller_id, product_name, category, price
mr_ordersone customer checkoutorder_id, customer_id, order_date
mr_order_itemsone product within one orderorder_item_id, order_id, product_id, qty, line_total
mr_returnsone returned line itemreturn_id, order_item_id, reason, refunded
sqlmr_schema.sql
CREATE TABLE mr_sellers (seller_id INTEGER PRIMARY KEY, seller_name VARCHAR, joined_on DATE);
INSERT INTO mr_sellers VALUES
 (1,'Alder Supplies',DATE '2023-04-01'),(2,'Brightworks',DATE '2023-09-12'),
 (3,'Coastline Goods',DATE '2024-02-20'),(4,'Dunmore Direct',DATE '2024-08-05');

CREATE TABLE mr_products (
  product_id INTEGER PRIMARY KEY, seller_id INTEGER,
  product_name VARCHAR, category VARCHAR, price NUMERIC(8,2));
INSERT INTO mr_products VALUES
 (10,1,'Cast Iron Pan','kitchen', 42.00),
 (11,1,'Pan Handle Cover','kitchen', 8.00),
 (12,2,'Espresso Grinder','kitchen',129.00),
 (13,2,'Coffee Beans 1kg','grocery', 19.00),
 (14,3,'Desk Lamp','home', 34.00),
 (15,3,'LED Bulb 4-pack','home', 11.00),
 (16,4,'Yoga Mat','fitness', 27.00),
 (17,4,'Resistance Bands','fitness', 15.00);

CREATE TABLE mr_orders (order_id INTEGER PRIMARY KEY, customer_id INTEGER, order_date DATE);
INSERT INTO mr_orders VALUES
 (2001,1,DATE '2025-03-02'),(2002,2,DATE '2025-03-03'),(2003,3,DATE '2025-03-05'),
 (2004,4,DATE '2025-03-07'),(2005,5,DATE '2025-03-09'),(2006,1,DATE '2025-03-12'),
 (2007,6,DATE '2025-03-14'),(2008,7,DATE '2025-03-16'),(2009,8,DATE '2025-03-18'),
 (2010,9,DATE '2025-03-20');

CREATE TABLE mr_order_items (
  order_item_id INTEGER PRIMARY KEY, order_id INTEGER,
  product_id INTEGER, qty INTEGER, line_total NUMERIC(8,2));
INSERT INTO mr_order_items VALUES
 (1,2001,10,1, 42.00),(2,2001,11,1,  8.00),
 (3,2002,12,1,129.00),(4,2002,13,2, 38.00),
 (5,2003,10,1, 42.00),(6,2003,11,1,  8.00),
 (7,2004,14,1, 34.00),(8,2004,15,1, 11.00),
 (9,2005,12,1,129.00),(10,2005,13,1,19.00),
 (11,2006,10,1,42.00),(12,2006,15,1,11.00),
 (13,2007,16,1,27.00),(14,2007,17,1,15.00),
 (15,2008,13,1,19.00),
 (16,2009,16,1,27.00),(17,2009,17,1,15.00),
 (18,2010,14,1,34.00),(19,2010,15,2,22.00);

CREATE TABLE mr_returns (
  return_id INTEGER PRIMARY KEY, order_item_id INTEGER,
  reason VARCHAR, refunded NUMERIC(8,2));
INSERT INTO mr_returns VALUES
 (1, 3,'not_as_described',129.00),
 (2, 9,'damaged',        129.00),
 (3, 4,'damaged',         38.00),
 (4,13,'wrong_size',      27.00),
 (5,19,'not_as_described',22.00);

SELECT count(*) AS line_items FROM mr_order_items;

The questions

  1. Which product pairs appear in the same basket most often, and which of those pairings are stronger than chance?
  2. What is the average basket size and value, and how does it differ for single-seller versus multi-seller baskets?
  3. Which sellers have a return rate materially above the category norm, adjusted for volume?
  4. What share of platform revenue is refunded, by seller and by return reason?
  5. Which products are “gateway” items — first purchase in a customer's history — and what do those customers buy next?
  6. Which sellers grow their revenue month on month, and which are shrinking?
  7. Are new sellers (joined under 12 months ago) worse on returns than established ones?

The SQL

Question 1 — basket pairs with lift. The self-join on order id with an inequality is the canonical market-basket pattern. The inequality does two jobs: it stops a product pairing with itself, and it stops each pair appearing twice as (A,B) and (B,A).

sqlmr_basket_lift.sql
CREATE TABLE mr_products AS SELECT * FROM (VALUES
 (10,'Cast Iron Pan'),(11,'Pan Handle Cover'),(12,'Espresso Grinder'),
 (13,'Coffee Beans 1kg'),(14,'Desk Lamp'),(15,'LED Bulb 4-pack'),
 (16,'Yoga Mat'),(17,'Resistance Bands')
) AS t(product_id,product_name);

CREATE TABLE mr_order_items AS SELECT * FROM (VALUES
 (2001,10),(2001,11),(2002,12),(2002,13),(2003,10),(2003,11),
 (2004,14),(2004,15),(2005,12),(2005,13),(2006,10),(2006,15),
 (2007,16),(2007,17),(2008,13),(2009,16),(2009,17),(2010,14),(2010,15)
) AS t(order_id,product_id);

WITH baskets AS (
  SELECT DISTINCT order_id, product_id FROM mr_order_items   -- dedupe qty>1
),
n_orders AS (SELECT COUNT(DISTINCT order_id) AS total FROM baskets),
item_freq AS (
  SELECT product_id, COUNT(DISTINCT order_id) AS orders_with
  FROM baskets GROUP BY product_id
),
pairs AS (
  SELECT a.product_id AS p1, b.product_id AS p2, COUNT(*) AS pair_orders
  FROM baskets a
  JOIN baskets b
    ON a.order_id = b.order_id
   AND a.product_id < b.product_id      -- both jobs done by one predicate
  GROUP BY a.product_id, b.product_id
)
SELECT n1.product_name AS product_a,
       n2.product_name AS product_b,
       pr.pair_orders,
       ROUND(1.0 * pr.pair_orders / n.total, 3) AS support,
       ROUND(1.0 * pr.pair_orders / f1.orders_with, 3) AS confidence_a_to_b,
       -- lift > 1 means the pair co-occurs MORE than independence predicts
       ROUND( (1.0 * pr.pair_orders / n.total)
              / NULLIF( (1.0*f1.orders_with/n.total) * (1.0*f2.orders_with/n.total), 0), 2) AS lift
FROM pairs pr
CROSS JOIN n_orders n
JOIN item_freq f1 ON f1.product_id = pr.p1
JOIN item_freq f2 ON f2.product_id = pr.p2
JOIN mr_products n1 ON n1.product_id = pr.p1
JOIN mr_products n2 ON n2.product_id = pr.p2
WHERE pr.pair_orders >= 2
ORDER BY lift DESC, pr.pair_orders DESC;

Three measures, three meanings. Support is how common the pair is overall — it protects you from acting on a pairing seen twice. Confidence is the conditional probability of B given A — directional, and the one that maps onto a recommendation slot. Lift divides observed co-occurrence by what independence would predict; a lift of 1.0 means the two products know nothing about each other, however often they appear together.

Analogy

Lift is the difference between “these two people are often in the same room” and “these two people are friends”. Everyone is often in the same room as the person who works on reception, because that person is in every room. Confidence says “whenever A is here, B is here 80% of the time” — impressive until you learn B is here 80% of the time regardless. Lift is the correction: it asks whether A's presence changes anything.

Question 3 — seller return rate, volume-adjusted. The naive ranking is by return count and it just re-ranks sellers by size. Rate fixes that but introduces the opposite problem: a seller with three orders and one return shows 33% and tops the list on noise. The pragmatic fix is a volume floor plus shrinkage toward the platform mean.

sqlmr_seller_quality.sql
CREATE TABLE mr_sellers AS SELECT * FROM (VALUES
 (1,'Alder Supplies'),(2,'Brightworks'),(3,'Coastline Goods'),(4,'Dunmore Direct')
) AS t(seller_id,seller_name);

CREATE TABLE mr_products AS SELECT * FROM (VALUES
 (10,1),(11,1),(12,2),(13,2),(14,3),(15,3),(16,4),(17,4)
) AS t(product_id,seller_id);

CREATE TABLE mr_order_items AS SELECT * FROM (VALUES
 (1,10,42.00),(2,11,8.00),(3,12,129.00),(4,13,38.00),(5,10,42.00),
 (6,11,8.00),(7,14,34.00),(8,15,11.00),(9,12,129.00),(10,13,19.00),
 (11,10,42.00),(12,15,11.00),(13,16,27.00),(14,17,15.00),(15,13,19.00),
 (16,16,27.00),(17,17,15.00),(18,14,34.00),(19,15,22.00)
) AS t(order_item_id,product_id,line_total);

CREATE TABLE mr_returns AS SELECT * FROM (VALUES
 (3,129.00),(9,129.00),(4,38.00),(13,27.00),(19,22.00)
) AS t(order_item_id,refunded);

WITH lines AS (
  SELECT p.seller_id,
         oi.order_item_id,
         oi.line_total,
         COALESCE(r.refunded,0) AS refunded,
         CASE WHEN r.order_item_id IS NULL THEN 0 ELSE 1 END AS returned
  FROM mr_order_items oi
  JOIN mr_products p ON p.product_id = oi.product_id
  LEFT JOIN mr_returns r ON r.order_item_id = oi.order_item_id
),
platform AS (SELECT AVG(returned) AS mean_rate FROM lines),
by_seller AS (
  SELECT seller_id,
         COUNT(*)            AS lines_sold,
         SUM(returned)       AS returns,
         SUM(line_total)     AS gross,
         SUM(refunded)       AS refunded
  FROM lines GROUP BY seller_id
)
SELECT s.seller_name,
       b.lines_sold,
       b.returns,
       ROUND(1.0 * b.returns / NULLIF(b.lines_sold,0), 3) AS raw_return_rate,
       -- shrinkage: pull small sellers toward the platform mean with
       -- a pseudo-count of 5 notional "average" lines.
       ROUND((b.returns + 5 * pl.mean_rate) / (b.lines_sold + 5), 3) AS adj_return_rate,
       ROUND(100.0 * b.refunded / NULLIF(b.gross,0), 1) AS refund_pct_of_gross
FROM by_seller b
JOIN mr_sellers s ON s.seller_id = b.seller_id
CROSS JOIN platform pl
ORDER BY adj_return_rate DESC;

The shrinkage term is worth understanding because it recurs constantly — in seller ratings, in A/B tests with thin segments, in any “top 10 by rate” list. Adding k notional average observations to every seller means a seller needs real volume before their rate is allowed to stray far from the mean. Choose k deliberately and say what you chose; do not let it be an invisible default.

Optimisation notes

  • The pair self-join is quadratic in basket size. A basket of 50 items produces 1,225 pairs. One basket of 500 items — a business buyer, or a bot — produces over 124,000 and can dominate the whole job. Cap basket size in the CTE (HAVING COUNT(*) <= 30) and log what you excluded.
  • Restrict the candidate set before pairing. Items appearing in fewer than n baskets cannot clear a support threshold anyway; filtering them in item_freq before the join removes most of the work. This is the same insight the Apriori algorithm is built on.
  • Pair counts are incremental. Compute daily pair counts and sum them over a window rather than re-pairing 90 days of baskets every night.
  • The returns LEFT JOIN should be a semi-join when you only need a flag. EXISTS avoids row multiplication if an item ever gets two return records — and it eventually will, because partial returns and re-returns exist.
  • Distribute by order_id, not product_id, for the basket job: all rows of a basket must land on one node for the self-join to be local rather than a shuffle.

The dashboard

PanelChartWhy
Top pairs by lift (support floor applied)Table with lift bar in-cellMerchandisers need to read the product names; a chart of pairs is unreadable
Basket size distributionHistogramAverages hide the long tail that breaks the pair job
Seller return rate vs volumeScatter, log x-axis, mean lineMakes the small-sample noise visible instead of ranking it
Refund value by reasonStacked bar over months“Damaged” rising is a logistics problem; “not as described” rising is a listing-quality problem
Seller revenue MoMSlope chart, top 20Direction and rank change in one glance

The executive read-out

  • Headline: “Four product pairs have a lift above three and enough volume to act on. They are currently not surfaced together anywhere on the site.”
  • Contrast: “Our most frequent co-purchase has a lift near one — it is popularity, not affinity. Recommending it would have added nothing.”
  • Risk: “Two sellers exceed the platform return rate by a wide margin after adjusting for volume, and both concentrate on one reason: not as described.”
  • Money: “Refunds run at a measurable share of gross for those two sellers versus the platform norm. The cost is in returns handling, not just the refund.”
  • Ask: “Put the four pairs into the recommendation slot as a two-week test, and place the two sellers on a listing-accuracy review before peak season.”
Interview angle

“Write a query to find products frequently bought together” is one of the most common SQL interview tasks. The self-join with a.product_id < b.product_id is the expected answer, and stating why the inequality rather than <> — deduplication as well as self-exclusion — is the detail that separates candidates. The follow-up is almost always “how would you avoid recommending popular items to everyone?”, and the answer is lift.

Key takeaway

Marketplace analytics is two normalisation problems wearing different clothes. Co-occurrence must be normalised by base rate (lift), or you recommend whatever is popular. Defect rates must be normalised by volume (shrinkage), or you punish whoever is small. Both are the same instinct: never compare a raw count to a raw count when the denominators differ.

Lesson 14.4·15 min read

Project 4 — Search Advertising: Auction, Quality Score & Spend Efficiency (Google Ads-style)

Work out whether the next pound of search spend is worth spending, using impression share, relevance bands and marginal return.

The business problem

Advertising Ashgrove Home sells furniture and buys search ads. The Performance Marketing Manager reviews the account every Monday with one recurring decision: where should the next £10,000 go, and where should £10,000 come out?

Search advertising works as an auction. An advertiser bids on a keyword; when a user searches, eligible advertisers are ranked by a combination of bid and an engine-assigned relevance score, and the winners are shown. Two consequences follow, and they drive every question below. First, you never see all the demand you could have had — there are searches where you were eligible and lost, and searches where your budget had already run out. Second, relevance is a price lever: a more relevant ad wins cheaper, so improving the ad can be a better investment than raising the bid.

The failure mode here is optimising the average. An account with a healthy blended return on ad spend can contain a third of its budget in keywords that lose money, subsidised by brand terms that would have converted anyway. The analysis must therefore be marginal, not average.

The data model

erDiagram
  CAMPAIGNS ||--o{ ADGROUPS : "contains"
  ADGROUPS ||--o{ KEYWORDS : "contains"
  KEYWORDS ||--o{ AUCTION_DAILY : "reports daily"
  KEYWORDS ||--o{ CONVERSIONS : "drives"
A strict hierarchy with one daily fact per keyword. The unusual part is AUCTION_DAILY: it carries counterfactual columns — impressions you were eligible for but did not receive — which no ordinary fact table has.
TableGrainKey columns
sa_campaignsone campaigncampaign_id, campaign_name, campaign_type
sa_keywordsone keyword in one ad groupkeyword_id, campaign_id, keyword_text, match_type, quality_score
sa_auction_dailyone keyword per daykeyword_id, day, eligible_impr, impressions, clicks, cost, lost_rank, lost_budget
sa_conversionsone keyword per daykeyword_id, day, conversions, conv_value
sqlsa_schema.sql
CREATE TABLE sa_campaigns (campaign_id INTEGER PRIMARY KEY, campaign_name VARCHAR, campaign_type VARCHAR);
INSERT INTO sa_campaigns VALUES
 (1,'Brand','brand'),(2,'Sofas Generic','nonbrand'),(3,'Beds Generic','nonbrand');

CREATE TABLE sa_keywords (
  keyword_id INTEGER PRIMARY KEY, campaign_id INTEGER,
  keyword_text VARCHAR, match_type VARCHAR, quality_score INTEGER);
INSERT INTO sa_keywords VALUES
 (101,1,'ashgrove home',   'exact', 9),
 (102,1,'ashgrove sofas',  'phrase',9),
 (103,2,'corner sofa',     'phrase',7),
 (104,2,'cheap sofa',      'broad', 4),
 (105,2,'velvet sofa uk',  'exact', 8),
 (106,3,'double bed frame','phrase',6),
 (107,3,'bed',             'broad', 3);

CREATE TABLE sa_auction_daily (
  keyword_id INTEGER, day DATE, eligible_impr INTEGER, impressions INTEGER,
  clicks INTEGER, cost NUMERIC(10,2), lost_rank INTEGER, lost_budget INTEGER);
INSERT INTO sa_auction_daily VALUES
 (101,DATE '2025-04-07',4000,3720,540, 324.00, 180,100),
 (102,DATE '2025-04-07',2500,2300,290, 203.00, 150, 50),
 (103,DATE '2025-04-07',9000,5400,410, 861.00,2600,1000),
 (104,DATE '2025-04-07',12000,4800,600,1140.00,6200,1000),
 (105,DATE '2025-04-07',3000,2550,255, 484.50, 300,150),
 (106,DATE '2025-04-07',7000,3500,245, 490.00,2800,700),
 (107,DATE '2025-04-07',20000,4000,520,1196.00,14000,2000);

CREATE TABLE sa_conversions (keyword_id INTEGER, day DATE, conversions INTEGER, conv_value NUMERIC(10,2));
INSERT INTO sa_conversions VALUES
 (101,DATE '2025-04-07',54,10800.00),
 (102,DATE '2025-04-07',26, 5460.00),
 (103,DATE '2025-04-07',20, 5000.00),
 (104,DATE '2025-04-07', 6, 1200.00),
 (105,DATE '2025-04-07',15, 4500.00),
 (106,DATE '2025-04-07', 9, 2340.00),
 (107,DATE '2025-04-07', 4,  880.00);

SELECT SUM(cost) AS total_cost FROM sa_auction_daily;

The questions

  1. What is our impression share by campaign, and is the shortfall caused by rank or by budget?
  2. What would it cost to capture the impressions we lost to budget, and what would they be worth?
  3. How do cost per click and conversion rate vary across quality-score bands?
  4. Which keywords are below break-even return on ad spend, and how much budget sits in them?
  5. What is the marginal return on ad spend as spend increases — where does the curve cross one?
  6. How much of our conversion volume comes from brand terms that would likely convert regardless?
  7. Which broad-match keywords are draining spend without converting?

The SQL

Questions 1 and 2 — impression share, decomposed and priced. The decomposition is the whole point: lost-to-budget is fixable with money you already have, lost-to-rank needs a bid rise or a better ad, and those are different conversations.

sqlsa_impression_share.sql
CREATE TABLE sa_campaigns AS SELECT * FROM (VALUES
 (1,'Brand','brand'),(2,'Sofas Generic','nonbrand'),(3,'Beds Generic','nonbrand')
) AS t(campaign_id,campaign_name,campaign_type);

CREATE TABLE sa_keywords AS SELECT * FROM (VALUES
 (101,1),(102,1),(103,2),(104,2),(105,2),(106,3),(107,3)
) AS t(keyword_id,campaign_id);

CREATE TABLE sa_auction_daily AS SELECT * FROM (VALUES
 (101,4000,3720,540,324.00,180,100),(102,2500,2300,290,203.00,150,50),
 (103,9000,5400,410,861.00,2600,1000),(104,12000,4800,600,1140.00,6200,1000),
 (105,3000,2550,255,484.50,300,150),(106,7000,3500,245,490.00,2800,700),
 (107,20000,4000,520,1196.00,14000,2000)
) AS t(keyword_id,eligible_impr,impressions,clicks,cost,lost_rank,lost_budget);

CREATE TABLE sa_conversions AS SELECT * FROM (VALUES
 (101,54,10800.00),(102,26,5460.00),(103,20,5000.00),(104,6,1200.00),
 (105,15,4500.00),(106,9,2340.00),(107,4,880.00)
) AS t(keyword_id,conversions,conv_value);

WITH kw AS (
  SELECT k.campaign_id, a.*, c.conversions, c.conv_value
  FROM sa_auction_daily a
  JOIN sa_keywords k     ON k.keyword_id = a.keyword_id
  LEFT JOIN sa_conversions c ON c.keyword_id = a.keyword_id
),
camp AS (
  SELECT campaign_id,
         SUM(eligible_impr) AS eligible,
         SUM(impressions)   AS impressions,
         SUM(lost_rank)     AS lost_rank,
         SUM(lost_budget)   AS lost_budget,
         SUM(clicks)        AS clicks,
         SUM(cost)          AS cost,
         SUM(conv_value)    AS conv_value
  FROM kw GROUP BY campaign_id
)
SELECT c.campaign_name,
       ROUND(100.0 * m.impressions / NULLIF(m.eligible,0),1) AS impression_share_pct,
       ROUND(100.0 * m.lost_rank   / NULLIF(m.eligible,0),1) AS lost_to_rank_pct,
       ROUND(100.0 * m.lost_budget / NULLIF(m.eligible,0),1) AS lost_to_budget_pct,
       ROUND(m.cost / NULLIF(m.clicks,0),2)                  AS cpc,
       -- price the budget-limited gap at our own current CPC and CTR
       ROUND(m.lost_budget * (1.0*m.clicks/NULLIF(m.impressions,0))
             * (m.cost/NULLIF(m.clicks,0)), 2)               AS est_cost_to_capture,
       ROUND(m.lost_budget * (1.0*m.clicks/NULLIF(m.impressions,0))
             * (m.conv_value/NULLIF(m.clicks,0)), 2)         AS est_value_to_capture
FROM camp m
JOIN sa_campaigns c ON c.campaign_id = m.campaign_id
ORDER BY lost_to_budget_pct DESC;

The two estimate columns assume the impressions you did not get behave like the ones you did. Inference That is optimistic: you win the cheapest, most relevant auctions first, so incremental impressions are usually worse than average ones. Present these as an upper bound and say so — an unqualified “we are leaving £X on the table” is the fastest way to lose credibility when the extra budget underperforms.

Question 3 — relevance as a price lever. Banding by quality score shows whether better ads actually buy cheaper clicks in this account.

sqlsa_quality_bands.sql
CREATE TABLE sa_keywords AS SELECT * FROM (VALUES
 (101,'ashgrove home',9),(102,'ashgrove sofas',9),(103,'corner sofa',7),
 (104,'cheap sofa',4),(105,'velvet sofa uk',8),(106,'double bed frame',6),(107,'bed',3)
) AS t(keyword_id,keyword_text,quality_score);

CREATE TABLE sa_auction_daily AS SELECT * FROM (VALUES
 (101,3720,540,324.00),(102,2300,290,203.00),(103,5400,410,861.00),
 (104,4800,600,1140.00),(105,2550,255,484.50),(106,3500,245,490.00),(107,4000,520,1196.00)
) AS t(keyword_id,impressions,clicks,cost);

CREATE TABLE sa_conversions AS SELECT * FROM (VALUES
 (101,54,10800.00),(102,26,5460.00),(103,20,5000.00),(104,6,1200.00),
 (105,15,4500.00),(106,9,2340.00),(107,4,880.00)
) AS t(keyword_id,conversions,conv_value);

SELECT CASE WHEN k.quality_score >= 8 THEN 'high (8-10)'
            WHEN k.quality_score >= 5 THEN 'mid (5-7)'
            ELSE 'low (1-4)' END                              AS quality_band,
       COUNT(*)                                               AS keywords,
       SUM(a.clicks)                                          AS clicks,
       ROUND(SUM(a.cost),2)                                   AS cost,
       ROUND(SUM(a.cost)/NULLIF(SUM(a.clicks),0),2)           AS cpc,
       ROUND(100.0*SUM(a.clicks)/NULLIF(SUM(a.impressions),0),2) AS ctr_pct,
       ROUND(100.0*SUM(c.conversions)/NULLIF(SUM(a.clicks),0),2) AS cvr_pct,
       ROUND(SUM(c.conv_value)/NULLIF(SUM(a.cost),0),2)       AS roas
FROM sa_auction_daily a
JOIN sa_keywords k    ON k.keyword_id = a.keyword_id
JOIN sa_conversions c ON c.keyword_id = a.keyword_id
GROUP BY quality_band
ORDER BY cpc;
Common pitfall — averaging brand into everything

Brand keywords tend to be cheap, highly relevant and highly converting, because the person searching had already decided. Leaving them in a blended figure makes the whole account look efficient and hides losses in generic terms. Every efficiency cut in a search account should be run twice: once with brand, once without. If the two tell different stories, the without-brand version is the one that should drive budget decisions.

Question 4 and 5 — marginal return. Rank keywords worst-first by return and accumulate the spend behind them. The output answers “if I cut the bottom of this list, how much do I save and what do I give up?” directly.

sqlsa_marginal_roas.sql
CREATE TABLE sa_keywords AS SELECT * FROM (VALUES
 (101,'ashgrove home','exact'),(102,'ashgrove sofas','phrase'),(103,'corner sofa','phrase'),
 (104,'cheap sofa','broad'),(105,'velvet sofa uk','exact'),
 (106,'double bed frame','phrase'),(107,'bed','broad')
) AS t(keyword_id,keyword_text,match_type);

CREATE TABLE sa_auction_daily AS SELECT * FROM (VALUES
 (101,324.00),(102,203.00),(103,861.00),(104,1140.00),(105,484.50),(106,490.00),(107,1196.00)
) AS t(keyword_id,cost);

CREATE TABLE sa_conversions AS SELECT * FROM (VALUES
 (101,10800.00),(102,5460.00),(103,5000.00),(104,1200.00),
 (105,4500.00),(106,2340.00),(107,880.00)
) AS t(keyword_id,conv_value);

WITH kw AS (
  SELECT k.keyword_text,
         k.match_type,
         a.cost,
         c.conv_value,
         c.conv_value / NULLIF(a.cost,0) AS roas
  FROM sa_auction_daily a
  JOIN sa_keywords k    ON k.keyword_id = a.keyword_id
  JOIN sa_conversions c ON c.keyword_id = a.keyword_id
)
SELECT keyword_text,
       match_type,
       ROUND(cost,2)       AS cost,
       ROUND(conv_value,2) AS conv_value,
       ROUND(roas,2)       AS roas,
       -- running spend if we cut everything at or below this ROAS
       ROUND(SUM(cost)      OVER (ORDER BY roas
             ROWS UNBOUNDED PRECEDING),2) AS cum_spend_if_cut_here,
       ROUND(SUM(conv_value) OVER (ORDER BY roas
             ROWS UNBOUNDED PRECEDING),2) AS cum_value_forgone
FROM kw
ORDER BY roas;

Optimisation notes

  • These facts arrive pre-aggregated by the ad platform, so volumes are modest — keyword×day, not impression-level. The performance risk is not scan size, it is the number of separate API pulls and the joins between them.
  • Conversions are restated. A conversion attributed today may be re-attributed to an earlier click tomorrow. Store an as_of_date alongside the metric day and keep the snapshot, or your month-end numbers will never reconcile with the ones you reported mid-month.
  • Use LEFT JOIN from auction data to conversions, never an inner join. Keywords that spent and did not convert are precisely the rows you are hunting; an inner join silently deletes them and makes the account look profitable.
  • Aggregate before you join. Rolling keyword×day facts up to keyword level in a CTE, then joining, avoids fan-out when a keyword has multiple conversion-type rows.
  • Percentile bucketing (NTILE) beats fixed thresholds for spend deciles, because the account's cost distribution shifts every quarter and hard-coded bands quietly stop meaning anything.

The dashboard

PanelChartWhy
Impression share, split three ways100% stacked bar by campaignThe three components sum to 100 by construction — the one case where stacking is honest
Spend and return by campaignBar (spend) + line (ROAS) comboEfficiency without volume misleads; show both or neither
Cumulative spend vs cumulative value, worst-firstLine, x = cumulative spendThe flat tail is exactly the budget to cut, and it is visible instantly
CPC and CVR by quality bandGrouped bar, dual axisMakes the relevance-price relationship legible to a non-specialist
Brand vs non-brand contributionTwo separate KPI rowsNever blended — separation is the analytical point

The executive read-out

  • Headline: “Blended return looks fine. Strip out brand and the generic account is running below break-even.”
  • Where: “Two broad-match keywords take a large share of generic spend and return a fraction of it. They are the whole gap.”
  • Opportunity: “On our best generic campaign we lose a meaningful share of eligible impressions to budget, not to rank. That is demand we are choosing not to serve.”
  • Lever: “Low-relevance keywords pay a visibly higher cost per click. Fixing ad copy and landing pages is cheaper than raising bids.”
  • Ask: “Pause the two worst broad-match terms, move that budget to the budget-limited campaign, and re-read in three weeks. Expect the incremental impressions to perform below current average.”
Interview angle

The standard trap: “Our ROAS is 4. Should we spend more?” The expected answer is that average return says nothing about marginal return — the next pound buys the next-worst auction, not an average one. Strong candidates then note that even marginal ROAS from observational data is confounded by brand demand and seasonality, and that the clean answer comes from a geo hold-out or a spend-shift test.

Key takeaway

Search analytics rests on three separations: lost-to-rank versus lost-to-budget (different fixes), brand versus non-brand (different causality), and average versus marginal return (different decisions). Cumulative window functions ordered worst-first turn the third one into a chart anyone can act on.

Lesson 14.5·14 min read

Project 5 — Social Advertising: Audience Overlap & Frequency Capping (Meta Ads-style)

Stop paying twice for the same person, and find the frequency at which more impressions stop helping.

The business problem

Advertising Ashgrove Home also runs social ads across several audience definitions at once: a broad interest audience, a lookalike built from past buyers, a website-retargeting pool, and an email-match list. Each is set up by a different person at a different time, and nobody has checked whether they describe the same people.

Two things go wrong when they do. First, internal competition: the advertiser's own campaigns bid against each other in the same auction, raising the price paid for the same impression. Second, frequency saturation: a person who lands in three overlapping audiences sees the ad three times as often, and past a certain point additional impressions produce irritation rather than conversions.

The decision at stake is an audience consolidation plan and a frequency cap, owned by the Paid Social Manager. Both require measuring people, not impressions — and that shift of grain is what makes this analysis different from the search project.

The data model

erDiagram
  AUDIENCES ||--o{ AUDIENCE_MEMBERS : "lists"
  USERS ||--o{ AUDIENCE_MEMBERS : "belongs to"
  USERS ||--o{ IMPRESSIONS : "is served"
  CAMPAIGNS ||--o{ IMPRESSIONS : "delivers"
  USERS ||--o{ CONVERSIONS : "may convert"
A user is the hub. Audience membership is many-to-many through a bridge table — the structure that makes overlap measurable at all.
TableGrainKey columns
so_audiencesone audience definitionaudience_id, audience_name, source_type
so_membersone user in one audienceaudience_id, user_id
so_impressionsone ad served to one userimpression_id, user_id, campaign_id, served_at, cost
so_conversionsone purchaseuser_id, converted_at, order_value
sqlso_schema.sql
CREATE TABLE so_audiences (audience_id INTEGER PRIMARY KEY, audience_name VARCHAR, source_type VARCHAR);
INSERT INTO so_audiences VALUES
 (1,'Broad Interest','interest'),(2,'Lookalike 1%','lookalike'),
 (3,'Site Retargeting','pixel'),(4,'Email Match','crm');

CREATE TABLE so_members (audience_id INTEGER, user_id INTEGER);
INSERT INTO so_members VALUES
 (1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(1,8),
 (2,3),(2,4),(2,5),(2,9),(2,10),
 (3,4),(3,5),(3,6),(3,11),
 (4,5),(4,10),(4,12);

CREATE TABLE so_impressions (
  impression_id INTEGER PRIMARY KEY, user_id INTEGER,
  campaign_id INTEGER, served_at TIMESTAMP, cost NUMERIC(8,4));
INSERT INTO so_impressions VALUES
 (1, 1,1,TIMESTAMP '2025-06-02 09:00:00',0.0120),
 (2, 1,1,TIMESTAMP '2025-06-03 09:00:00',0.0120),
 (3, 2,1,TIMESTAMP '2025-06-02 10:00:00',0.0115),
 (4, 3,1,TIMESTAMP '2025-06-02 11:00:00',0.0130),
 (5, 3,2,TIMESTAMP '2025-06-04 11:00:00',0.0180),
 (6, 4,1,TIMESTAMP '2025-06-02 12:00:00',0.0125),
 (7, 4,2,TIMESTAMP '2025-06-03 12:00:00',0.0190),
 (8, 4,3,TIMESTAMP '2025-06-04 12:00:00',0.0240),
 (9, 5,1,TIMESTAMP '2025-06-02 13:00:00',0.0128),
 (10,5,2,TIMESTAMP '2025-06-03 13:00:00',0.0195),
 (11,5,3,TIMESTAMP '2025-06-04 13:00:00',0.0250),
 (12,5,4,TIMESTAMP '2025-06-05 13:00:00',0.0210),
 (13,5,3,TIMESTAMP '2025-06-06 13:00:00',0.0255),
 (14,6,1,TIMESTAMP '2025-06-02 14:00:00',0.0118),
 (15,6,3,TIMESTAMP '2025-06-05 14:00:00',0.0235),
 (16,7,1,TIMESTAMP '2025-06-02 15:00:00',0.0110),
 (17,8,1,TIMESTAMP '2025-06-03 15:00:00',0.0112),
 (18,9,2,TIMESTAMP '2025-06-03 16:00:00',0.0175),
 (19,10,2,TIMESTAMP '2025-06-04 16:00:00',0.0182),
 (20,10,4,TIMESTAMP '2025-06-05 16:00:00',0.0205),
 (21,11,3,TIMESTAMP '2025-06-04 17:00:00',0.0230),
 (22,12,4,TIMESTAMP '2025-06-05 17:00:00',0.0200);

CREATE TABLE so_conversions (user_id INTEGER, converted_at TIMESTAMP, order_value NUMERIC(10,2));
INSERT INTO so_conversions VALUES
 (4, TIMESTAMP '2025-06-06 10:00:00',240.00),
 (5, TIMESTAMP '2025-06-07 10:00:00',380.00),
 (10,TIMESTAMP '2025-06-06 11:00:00',150.00),
 (3, TIMESTAMP '2025-06-08 12:00:00',210.00);

SELECT count(DISTINCT user_id) AS reached_users FROM so_impressions;

The questions

  1. How much do our audiences overlap, pairwise, as a share of each and as a Jaccard index?
  2. What is total de-duplicated reach, and how much smaller is it than the sum of audience sizes?
  3. What is the incremental reach of each audience — users it contributes that no other audience contains?
  4. What does the frequency distribution look like, and what share of spend goes to users above a proposed cap?
  5. How does conversion rate move with frequency, and where does it flatten?
  6. How much spend would a cap of n impressions per user per week have saved?
  7. Which users are in three or more audiences — the internal-competition list?

The SQL

Question 1 — pairwise overlap. Same self-join shape as basket analysis, but the entity being co-counted is a user rather than a product. Recognising that two very different business questions share one query pattern is most of what fluency means.

sqlso_overlap.sql
CREATE TABLE so_audiences AS SELECT * FROM (VALUES
 (1,'Broad Interest'),(2,'Lookalike 1%'),(3,'Site Retargeting'),(4,'Email Match')
) AS t(audience_id,audience_name);

CREATE TABLE so_members AS SELECT * FROM (VALUES
 (1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(1,8),
 (2,3),(2,4),(2,5),(2,9),(2,10),
 (3,4),(3,5),(3,6),(3,11),
 (4,5),(4,10),(4,12)
) AS t(audience_id,user_id);

WITH sizes AS (
  SELECT audience_id, COUNT(DISTINCT user_id) AS members
  FROM so_members GROUP BY audience_id
),
pairs AS (
  SELECT a.audience_id AS a1, b.audience_id AS a2,
         COUNT(DISTINCT a.user_id) AS shared
  FROM so_members a
  JOIN so_members b
    ON a.user_id = b.user_id
   AND a.audience_id < b.audience_id
  GROUP BY a.audience_id, b.audience_id
)
SELECT n1.audience_name AS audience_a,
       n2.audience_name AS audience_b,
       s1.members AS size_a,
       s2.members AS size_b,
       p.shared,
       ROUND(100.0 * p.shared / NULLIF(s1.members,0),1) AS pct_of_a,
       ROUND(100.0 * p.shared / NULLIF(s2.members,0),1) AS pct_of_b,
       -- Jaccard: shared / union. Symmetric, so it does not flatter the smaller set.
       ROUND(1.0 * p.shared / NULLIF(s1.members + s2.members - p.shared,0),3) AS jaccard
FROM pairs p
JOIN sizes s1 ON s1.audience_id = p.a1
JOIN sizes s2 ON s2.audience_id = p.a2
JOIN so_audiences n1 ON n1.audience_id = p.a1
JOIN so_audiences n2 ON n2.audience_id = p.a2
ORDER BY jaccard DESC;

Report both the asymmetric percentages and Jaccard. A small retargeting pool can be 100% contained inside a large interest audience — pct_of_a screams, pct_of_b shrugs, and Jaccard sits in between. Whichever single number you pick, someone will misread it; showing all three costs one column and prevents an argument.

Question 3 — incremental reach. The consolidation decision needs the users an audience uniquely contributes, which is an anti-join.

sqlso_incremental_reach.sql
CREATE TABLE so_audiences AS SELECT * FROM (VALUES
 (1,'Broad Interest'),(2,'Lookalike 1%'),(3,'Site Retargeting'),(4,'Email Match')
) AS t(audience_id,audience_name);

CREATE TABLE so_members AS SELECT * FROM (VALUES
 (1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(1,8),
 (2,3),(2,4),(2,5),(2,9),(2,10),
 (3,4),(3,5),(3,6),(3,11),
 (4,5),(4,10),(4,12)
) AS t(audience_id,user_id);

WITH membership_count AS (
  SELECT user_id, COUNT(DISTINCT audience_id) AS in_n_audiences
  FROM so_members GROUP BY user_id
)
SELECT a.audience_name,
       COUNT(DISTINCT m.user_id) AS members,
       COUNT(DISTINCT CASE WHEN mc.in_n_audiences = 1
                           THEN m.user_id END) AS unique_to_this,
       ROUND(100.0 * COUNT(DISTINCT CASE WHEN mc.in_n_audiences = 1
                           THEN m.user_id END)
             / NULLIF(COUNT(DISTINCT m.user_id),0),1) AS pct_incremental,
       ROUND(AVG(mc.in_n_audiences),2) AS avg_audiences_per_member
FROM so_members m
JOIN so_audiences a  ON a.audience_id = m.audience_id
JOIN membership_count mc ON mc.user_id = m.user_id
GROUP BY a.audience_name
ORDER BY pct_incremental;

Questions 4 to 6 — frequency and the cap. Count impressions per user, band them, and attach both spend and conversion to each band. The cumulative spend column tells you the saving from any candidate cap.

sqlso_frequency.sql
CREATE TABLE so_impressions AS SELECT * FROM (VALUES
 (1,0.0120),(1,0.0120),(2,0.0115),(3,0.0130),(3,0.0180),
 (4,0.0125),(4,0.0190),(4,0.0240),(5,0.0128),(5,0.0195),
 (5,0.0250),(5,0.0210),(5,0.0255),(6,0.0118),(6,0.0235),
 (7,0.0110),(8,0.0112),(9,0.0175),(10,0.0182),(10,0.0205),
 (11,0.0230),(12,0.0200)
) AS t(user_id,cost);

CREATE TABLE so_conversions AS SELECT * FROM (VALUES
 (4,240.00),(5,380.00),(10,150.00),(3,210.00)
) AS t(user_id,order_value);

WITH per_user AS (
  SELECT i.user_id,
         COUNT(*)   AS frequency,
         SUM(i.cost) AS spend,
         MAX(CASE WHEN c.user_id IS NOT NULL THEN 1 ELSE 0 END) AS converted
  FROM so_impressions i
  LEFT JOIN so_conversions c ON c.user_id = i.user_id
  GROUP BY i.user_id
)
SELECT frequency,
       COUNT(*)                        AS users,
       ROUND(SUM(spend),4)             AS spend,
       SUM(converted)                  AS converters,
       ROUND(AVG(converted),3)         AS conv_rate,
       -- spend that would be avoided by capping BELOW this frequency
       ROUND(SUM(SUM(spend)) OVER (ORDER BY frequency DESC
             ROWS UNBOUNDED PRECEDING),4) AS spend_at_or_above
FROM per_user
GROUP BY frequency
ORDER BY frequency;
Common pitfall — reading the frequency curve backwards

Conversion rate almost always rises with frequency in raw data, and the tempting conclusion is “show people more ads”. The causality runs the other way at least as strongly: interested people browse more, so the platform serves them more, so they accumulate frequency because they were already likely to convert. Inference A frequency curve from observational data cannot identify the optimal cap. It can identify where the curve flattens, which is a reasonable place to start a proper capped-versus-uncapped test.

Optimisation notes

  • Impressions are the largest table any advertiser touches — hundreds of millions of rows a week. Never compute overlap from impressions when a membership table exists; membership is orders of magnitude smaller.
  • Exact overlap across many audiences is combinatorial. Six audiences give fifteen pairs; twenty give a hundred and ninety. For large sets, HyperLogLog sketches support union and intersection estimates cheaply, and a one-per-cent error is irrelevant to a consolidation decision.
  • Bitmap/roaring encodings of user sets turn overlap into an AND of two bitmaps. Where your engine supports them this is dramatically faster than a join on user ids.
  • Frequency must be windowed and time-boxed. “Impressions per user” with no window is a meaningless lifetime number. Compute per rolling seven days, and make the window an explicit parameter of the query.
  • Identity resolution is the real constraint. One human is several user ids across devices, so measured frequency understates true frequency and measured overlap understates true overlap. State the direction of the bias; do not pretend the ids are people.

The dashboard

PanelChartWhy
Pairwise overlapMatrix heatmap, Jaccard shadedSymmetric measure, symmetric display; the hot cells are the consolidation candidates
Sum of audiences vs de-duplicated reachTwo-bar comparisonThe gap is the double-counting, stated in one image
Incremental reach by audienceStacked bar: unique vs shared membersDirectly answers “what do we lose if we switch this off?”
Frequency distributionHistogram with cap lineShows the tail the cap would trim and how many users it affects
Conversion rate by frequencyLine with confidence bandThe band stops people over-reading buckets containing eleven users

The executive read-out

  • Headline: “Our four audiences add up to far more people than we actually reach. A large share of members sit in two or more of them.”
  • Cost: “Those overlapping users are the ones we pay the most per impression for, because our own campaigns are bidding against each other.”
  • Consolidation: “One audience contributes almost no unique reach. Switching it off costs us very few people and removes most of the internal competition.”
  • Frequency: “A small share of users absorb a disproportionate share of impressions. Conversion rate stops improving well before the top of that range.”
  • Caveat and ask: “The frequency curve is confounded — interested people get served more. Run a capped-versus-uncapped split for four weeks rather than setting the cap from this chart.”
Interview angle

Two things get tested. The mechanical one: “compute overlap between two user sets” — they want the self-join with <, and they want you to reach for Jaccard rather than a one-sided percentage. The judgement one: “users who saw 10 ads convert better than users who saw 2 — should we raise frequency?” The correct answer is no, and the reason is selection: exposure is allocated by the platform to people already predicted to convert.

Key takeaway

Social advertising analytics is set arithmetic on people, not row arithmetic on impressions. Overlap needs a symmetric measure; consolidation needs incremental reach, which is an anti-join; and frequency needs a time window and a health warning, because exposure is assigned by an algorithm that already knows who is likely to buy.

Lesson 14.6·15 min read

Project 6 — E-commerce Platform: Merchant Cohort Health (Shopify-style)

Separate a platform that is growing from one that is merely acquiring, using cohort survival and net revenue retention.

The business problem

SaaS Storefront Nine sells hosted online shops to small merchants. It charges a monthly subscription plus a percentage of the merchant's sales, so platform revenue is the sum of many small businesses' fortunes.

Total gross merchandise value is up 40% year on year and the board is pleased. The Head of Analytics is not, because that number can be produced by two completely different businesses: one where existing merchants grow, and one where existing merchants quietly die while marketing replaces them faster than they leave. The first compounds. The second is a treadmill that stops the moment acquisition spend does.

Telling them apart requires cohorts. Group merchants by the month they signed up, follow each group forward, and ask two questions of each: how many are still trading? (logo retention) and how much are the survivors worth compared with when they started? (net revenue retention). Together those answer whether the business is real.

The data model

erDiagram
  MERCHANTS ||--o{ GMV_MONTHLY : "trades each month"
  MERCHANTS ||--o{ PLAN_HISTORY : "changes plan"
  PLANS ||--o{ PLAN_HISTORY : "is referenced by"
  MERCHANTS ||--o{ SUPPORT_TICKETS : "raises"
A slim model. The important design choice is that GMV_MONTHLY holds a row only for months a merchant actually traded — absence is the churn signal, which is why every retention query here starts by generating the months rather than reading them.
TableGrainKey columns
ep_merchantsone merchant accountmerchant_id, signed_up_on, country, acquisition_channel
ep_gmvone merchant per trading monthmerchant_id, month, gmv, orders
ep_plansone subscription tierplan_code, monthly_fee, take_rate_pct
ep_plan_historyone merchant on one plan for a periodmerchant_id, plan_code, valid_from, valid_to
sqlep_schema.sql
CREATE TABLE ep_merchants (
  merchant_id INTEGER PRIMARY KEY, signed_up_on DATE,
  country VARCHAR, acquisition_channel VARCHAR);
INSERT INTO ep_merchants VALUES
 (1,DATE '2024-01-14','GB','paid_search'),
 (2,DATE '2024-01-22','GB','organic'),
 (3,DATE '2024-01-29','IE','referral'),
 (4,DATE '2024-02-05','GB','paid_search'),
 (5,DATE '2024-02-18','SE','organic'),
 (6,DATE '2024-02-27','GB','paid_social'),
 (7,DATE '2024-03-08','IE','paid_search'),
 (8,DATE '2024-03-19','GB','referral'),
 (9,DATE '2024-03-25','SE','organic');

CREATE TABLE ep_plans (plan_code VARCHAR PRIMARY KEY, monthly_fee NUMERIC(8,2), take_rate_pct NUMERIC(5,3));
INSERT INTO ep_plans VALUES ('starter',19.00,2.000),('growth',59.00,1.500),('scale',199.00,1.000);

CREATE TABLE ep_plan_history (merchant_id INTEGER, plan_code VARCHAR, valid_from DATE, valid_to DATE);
INSERT INTO ep_plan_history VALUES
 (1,'starter',DATE '2024-01-14',DATE '2024-04-30'),
 (1,'growth', DATE '2024-05-01',NULL),
 (2,'starter',DATE '2024-01-22',NULL),
 (3,'growth', DATE '2024-01-29',NULL),
 (4,'starter',DATE '2024-02-05',NULL),
 (5,'growth', DATE '2024-02-18',DATE '2024-06-30'),
 (5,'scale',  DATE '2024-07-01',NULL),
 (6,'starter',DATE '2024-02-27',NULL),
 (7,'starter',DATE '2024-03-08',NULL),
 (8,'growth', DATE '2024-03-19',NULL),
 (9,'starter',DATE '2024-03-25',NULL);

CREATE TABLE ep_gmv (merchant_id INTEGER, month DATE, gmv NUMERIC(12,2), orders INTEGER);
INSERT INTO ep_gmv VALUES
 (1,DATE '2024-01-01', 1200.00, 18),(1,DATE '2024-02-01', 2100.00, 31),
 (1,DATE '2024-03-01', 3400.00, 47),(1,DATE '2024-04-01', 4100.00, 55),
 (2,DATE '2024-01-01',  800.00, 12),(2,DATE '2024-02-01',  650.00, 10),
 (3,DATE '2024-01-01', 5400.00, 60),(3,DATE '2024-02-01', 6100.00, 68),
 (3,DATE '2024-03-01', 7300.00, 79),(3,DATE '2024-04-01', 8800.00, 92),
 (4,DATE '2024-02-01',  900.00, 14),(4,DATE '2024-03-01', 1100.00, 17),
 (4,DATE '2024-04-01', 1050.00, 16),
 (5,DATE '2024-02-01', 4200.00, 46),(5,DATE '2024-03-01', 5900.00, 63),
 (5,DATE '2024-04-01', 7400.00, 80),
 (6,DATE '2024-02-01',  400.00,  6),
 (7,DATE '2024-03-01', 1500.00, 22),(7,DATE '2024-04-01', 1750.00, 26),
 (8,DATE '2024-03-01', 3300.00, 38),(8,DATE '2024-04-01', 3900.00, 44),
 (9,DATE '2024-03-01',  600.00,  9);

SELECT count(*) AS gmv_rows FROM ep_gmv;

The questions

  1. What share of each signup cohort is still trading in month 1, 2, 3?
  2. What is net revenue retention by cohort — total GMV in month n relative to month 0 for the same merchants?
  3. How concentrated is GMV? What share comes from the top decile of merchants?
  4. How long does a merchant take to make a first sale, and does that predict survival?
  5. Which acquisition channels produce merchants who survive and grow, rather than merchants who sign up?
  6. What is platform revenue (subscription plus take rate) by cohort, given plan changes over time?
  7. Which merchants declined more than 30% month on month — the intervention list?

The SQL

Question 1 — the retention triangle. The key move is date_diff between the merchant's cohort month and the activity month, giving a “month number” that lets cohorts of different ages sit in one grid.

sqlep_cohort_retention.sql
CREATE TABLE ep_merchants AS SELECT * FROM (VALUES
 (1,DATE '2024-01-14'),(2,DATE '2024-01-22'),(3,DATE '2024-01-29'),
 (4,DATE '2024-02-05'),(5,DATE '2024-02-18'),(6,DATE '2024-02-27'),
 (7,DATE '2024-03-08'),(8,DATE '2024-03-19'),(9,DATE '2024-03-25')
) AS t(merchant_id,signed_up_on);

CREATE TABLE ep_gmv AS SELECT * FROM (VALUES
 (1,DATE '2024-01-01',1200.00),(1,DATE '2024-02-01',2100.00),
 (1,DATE '2024-03-01',3400.00),(1,DATE '2024-04-01',4100.00),
 (2,DATE '2024-01-01', 800.00),(2,DATE '2024-02-01', 650.00),
 (3,DATE '2024-01-01',5400.00),(3,DATE '2024-02-01',6100.00),
 (3,DATE '2024-03-01',7300.00),(3,DATE '2024-04-01',8800.00),
 (4,DATE '2024-02-01', 900.00),(4,DATE '2024-03-01',1100.00),
 (4,DATE '2024-04-01',1050.00),(5,DATE '2024-02-01',4200.00),
 (5,DATE '2024-03-01',5900.00),(5,DATE '2024-04-01',7400.00),
 (6,DATE '2024-02-01', 400.00),(7,DATE '2024-03-01',1500.00),
 (7,DATE '2024-04-01',1750.00),(8,DATE '2024-03-01',3300.00),
 (8,DATE '2024-04-01',3900.00),(9,DATE '2024-03-01', 600.00)
) AS t(merchant_id,month,gmv);

WITH cohorts AS (
  SELECT merchant_id,
         DATE_TRUNC('month', signed_up_on) AS cohort_month
  FROM ep_merchants
),
sized AS (
  SELECT cohort_month, COUNT(*) AS cohort_size
  FROM cohorts GROUP BY cohort_month
),
activity AS (
  SELECT c.cohort_month,
         DATE_DIFF('month', c.cohort_month, g.month) AS month_no,
         COUNT(DISTINCT g.merchant_id)               AS active
  FROM cohorts c
  JOIN ep_gmv g ON g.merchant_id = c.merchant_id
  GROUP BY c.cohort_month, DATE_DIFF('month', c.cohort_month, g.month)
)
SELECT STRFTIME(a.cohort_month,'%Y-%m') AS cohort,
       s.cohort_size,
       a.month_no,
       a.active,
       ROUND(100.0 * a.active / NULLIF(s.cohort_size,0),1) AS retention_pct
FROM activity a
JOIN sized s ON s.cohort_month = a.cohort_month
ORDER BY cohort, month_no;
Common pitfall — the truncated triangle

The most recent cohort has only had one month to live, so its month-3 cell is empty — not zero, empty. Charting the triangle without masking those cells produces a plunging line that looks like a catastrophe and is actually the calendar. Always compute the maximum observable month_no per cohort and blank anything beyond it. This one mistake has caused more false alarms in board decks than any other in analytics.

Question 2 — net revenue retention. Logo retention counts bodies; NRR counts money, and the two can point in opposite directions. A cohort can lose half its merchants and still be worth more than at signup if the survivors grew. That is precisely the shape of a healthy small-business platform, and it is invisible in a headcount chart.

sqlep_nrr.sql
CREATE TABLE ep_merchants AS SELECT * FROM (VALUES
 (1,DATE '2024-01-14'),(2,DATE '2024-01-22'),(3,DATE '2024-01-29'),
 (4,DATE '2024-02-05'),(5,DATE '2024-02-18'),(6,DATE '2024-02-27'),
 (7,DATE '2024-03-08'),(8,DATE '2024-03-19'),(9,DATE '2024-03-25')
) AS t(merchant_id,signed_up_on);

CREATE TABLE ep_gmv AS SELECT * FROM (VALUES
 (1,DATE '2024-01-01',1200.00),(1,DATE '2024-02-01',2100.00),
 (1,DATE '2024-03-01',3400.00),(1,DATE '2024-04-01',4100.00),
 (2,DATE '2024-01-01', 800.00),(2,DATE '2024-02-01', 650.00),
 (3,DATE '2024-01-01',5400.00),(3,DATE '2024-02-01',6100.00),
 (3,DATE '2024-03-01',7300.00),(3,DATE '2024-04-01',8800.00),
 (4,DATE '2024-02-01', 900.00),(4,DATE '2024-03-01',1100.00),
 (4,DATE '2024-04-01',1050.00),(5,DATE '2024-02-01',4200.00),
 (5,DATE '2024-03-01',5900.00),(5,DATE '2024-04-01',7400.00),
 (6,DATE '2024-02-01', 400.00),(7,DATE '2024-03-01',1500.00),
 (7,DATE '2024-04-01',1750.00),(8,DATE '2024-03-01',3300.00),
 (8,DATE '2024-04-01',3900.00),(9,DATE '2024-03-01', 600.00)
) AS t(merchant_id,month,gmv);

WITH base AS (
  SELECT DATE_TRUNC('month', m.signed_up_on) AS cohort_month,
         DATE_DIFF('month', DATE_TRUNC('month', m.signed_up_on), g.month) AS month_no,
         g.gmv
  FROM ep_merchants m
  JOIN ep_gmv g ON g.merchant_id = m.merchant_id
),
by_month AS (
  SELECT cohort_month, month_no, SUM(gmv) AS gmv
  FROM base GROUP BY cohort_month, month_no
)
SELECT STRFTIME(cohort_month,'%Y-%m') AS cohort,
       month_no,
       ROUND(gmv,2) AS cohort_gmv,
       -- month 0 value of the SAME cohort, carried across every row
       ROUND(FIRST_VALUE(gmv) OVER (PARTITION BY cohort_month
                                    ORDER BY month_no),2) AS month0_gmv,
       ROUND(100.0 * gmv / NULLIF(FIRST_VALUE(gmv) OVER (PARTITION BY cohort_month
                                    ORDER BY month_no),0),1) AS nrr_pct
FROM by_month
ORDER BY cohort, month_no;

Question 3 — concentration. Platforms are power-law businesses. A cumulative share curve tells you how exposed you are to your largest merchants leaving.

sqlep_concentration.sql
CREATE TABLE ep_gmv AS SELECT * FROM (VALUES
 (1,10800.00),(2,1450.00),(3,27600.00),(4,3050.00),(5,17500.00),
 (6,400.00),(7,3250.00),(8,7200.00),(9,600.00)
) AS t(merchant_id,gmv);

WITH ranked AS (
  SELECT merchant_id,
         gmv,
         SUM(gmv) OVER ()                                   AS total_gmv,
         SUM(gmv) OVER (ORDER BY gmv DESC
                        ROWS UNBOUNDED PRECEDING)           AS cum_gmv,
         ROW_NUMBER() OVER (ORDER BY gmv DESC)              AS rn,
         COUNT(*) OVER ()                                   AS n
  FROM ep_gmv
)
SELECT rn                                              AS merchant_rank,
       merchant_id,
       ROUND(gmv,2)                                    AS gmv,
       ROUND(100.0 * rn / n, 1)                        AS pct_of_merchants,
       ROUND(100.0 * cum_gmv / NULLIF(total_gmv,0), 1) AS cum_pct_of_gmv
FROM ranked
ORDER BY rn;

Optimisation notes

  • Materialise the cohort assignment. DATE_TRUNC on signup runs on every cohort query in the company. Store cohort_month as a column on the merchant dimension and index it.
  • Cohort grids are small outputs from large inputs. Aggregate to merchant×month first, then to cohort×month_no. Never join raw order rows into a cohort query.
  • Retention needs a spine. If you must show zeros for gaps rather than missing rows, cross-join cohorts against a generated month series and left-join activity onto it. generate_series or a date dimension is the standard tool; hand-written UNION ALL lists rot.
  • FIRST_VALUE without a frame clause defaults to RANGE UNBOUNDED PRECEDING AND CURRENT ROW, which happens to be correct here only because month 0 sorts first. If you ever order descending, that expression silently changes meaning — state the frame explicitly when the answer matters.
  • Plan history is a slowly changing dimension. Revenue by month requires an interval join (month BETWEEN valid_from AND COALESCE(valid_to, DATE '9999-12-31')), which is far more expensive than an equality join. Pre-flatten plan history into merchant×month once, nightly.

The dashboard

PanelChartWhy
Logo retention by cohortTriangle heatmap, incomplete cells greyedReading down a column compares cohorts at the same age — the only fair comparison
Net revenue retention by cohortLine per cohort, 100% reference lineCrossing 100% is the single most important fact about the business
GMV concentrationLorenz-style cumulative curveShows customer-concentration risk in one shape
Retention by acquisition channelSmall multiples of the triangleChannel quality differences appear as different triangle shapes
Merchants declining >30% MoMTable with sparkline per merchantAn operational list, not an aggregate — someone has to phone these people

The executive read-out

  • Headline: “GMV growth is real, but it is coming from a minority of merchants growing, not from the majority surviving.”
  • Retention: “Logo retention falls steeply in the first three months and then flattens. The flattening point is where a merchant becomes a business rather than an experiment.”
  • Money: “Net revenue retention runs well above logo retention. We lose bodies and keep value — that is the model working, and it should be the headline metric, not merchant count.”
  • Risk: “A small number of merchants account for a large share of GMV. If two of them leave, the quarter misses regardless of how many we sign.”
  • Ask: “Fund onboarding through first sale, not more signups. Time-to-first-sale is the earliest measurable thing that separates survivors from the rest.”
Interview angle

“Write a cohort retention query” is a top-five SQL interview task. The expected pieces are: derive the cohort month from signup, derive a period number with a date difference, aggregate to cohort×period, and divide by cohort size taken from a separate CTE. The distinguishing detail is unprompted mention of incomplete cohorts. The natural follow-up — “NRR is 110% but logo retention is 60%, what does that mean?” — is testing whether you can hold two metrics in mind at once.

Key takeaway

Cohort analysis converts a calendar into an age, which is the only way to compare groups fairly. Track two retention curves — logos and revenue — because a platform can lose most of its customers and still compound. And mask the incomplete corner of the triangle, always.

Lesson 14.7·14 min read

Project 7 — Travel Marketplace: Supply Liquidity & Booking Funnel (Airbnb-style)

Measure whether a city has enough of the right supply, using occupancy, search liquidity and listing activation.

The business problem

Retail Roamlet lets hosts list spare properties and travellers book them by night. The Market Manager for each city has one budget and one question: do we need more supply, different supply, or better conversion of the supply we have?

The three answers imply completely different spending. “More supply” means host recruitment. “Different supply” means recruiting specific shapes — two-bedroom places for families, or listings in the one neighbourhood everyone searches and nobody lists. “Better conversion” means photography, pricing guidance and calendar hygiene, because a listing whose calendar is stale is invisible supply.

The distinguishing evidence is search liquidity: what fraction of searches return enough available, suitable listings to be bookable at all. A city can look healthy on occupancy while turning away half its demand, because occupancy is measured over the supply that exists, and the searches that found nothing leave no trace in the bookings table.

The data model

erDiagram
  HOSTS ||--o{ LISTINGS : "owns"
  LISTINGS ||--o{ CALENDAR : "has nightly state"
  LISTINGS ||--o{ BOOKINGS : "receives"
  SEARCHES ||--o{ SEARCH_RESULTS : "returns"
  LISTINGS ||--o{ SEARCH_RESULTS : "appears in"
The calendar table is the unusual one: one row per listing per night, whether or not anything happened. That density is what makes occupancy computable and what makes the table enormous.
TableGrainKey columns
tv_listingsone property listinglisting_id, host_id, city, bedrooms, listed_on
tv_calendarone listing per nightlisting_id, night, state (available/blocked/booked), price
tv_searchesone search performedsearch_id, city, checkin, nights, guests, results_shown
tv_bookingsone confirmed reservationbooking_id, listing_id, checkin, nights, total_price
sqltv_schema.sql
CREATE TABLE tv_listings (
  listing_id INTEGER PRIMARY KEY, host_id INTEGER,
  city VARCHAR, bedrooms INTEGER, listed_on DATE);
INSERT INTO tv_listings VALUES
 (1,901,'Porto',1,DATE '2024-06-01'),(2,901,'Porto',2,DATE '2024-06-01'),
 (3,902,'Porto',3,DATE '2024-09-15'),(4,903,'Porto',1,DATE '2025-01-10'),
 (5,904,'Bergen',2,DATE '2024-05-20'),(6,905,'Bergen',1,DATE '2024-11-02'),
 (7,906,'Bergen',4,DATE '2025-02-01');

CREATE TABLE tv_calendar (listing_id INTEGER, night DATE, state VARCHAR, price NUMERIC(8,2));
INSERT INTO tv_calendar VALUES
 (1,DATE '2025-07-01','booked',   110.00),(1,DATE '2025-07-02','booked',   110.00),
 (1,DATE '2025-07-03','available',110.00),(1,DATE '2025-07-04','available',110.00),
 (2,DATE '2025-07-01','booked',   180.00),(2,DATE '2025-07-02','booked',   180.00),
 (2,DATE '2025-07-03','booked',   195.00),(2,DATE '2025-07-04','blocked',  180.00),
 (3,DATE '2025-07-01','available',260.00),(3,DATE '2025-07-02','available',260.00),
 (3,DATE '2025-07-03','available',260.00),(3,DATE '2025-07-04','available',260.00),
 (4,DATE '2025-07-01','blocked',   95.00),(4,DATE '2025-07-02','blocked',   95.00),
 (4,DATE '2025-07-03','blocked',   95.00),(4,DATE '2025-07-04','blocked',   95.00),
 (5,DATE '2025-07-01','booked',   220.00),(5,DATE '2025-07-02','booked',   220.00),
 (5,DATE '2025-07-03','available',220.00),(5,DATE '2025-07-04','available',220.00),
 (6,DATE '2025-07-01','available',140.00),(6,DATE '2025-07-02','booked',   140.00),
 (6,DATE '2025-07-03','booked',   140.00),(6,DATE '2025-07-04','available',140.00),
 (7,DATE '2025-07-01','available',390.00),(7,DATE '2025-07-02','available',390.00),
 (7,DATE '2025-07-03','available',390.00),(7,DATE '2025-07-04','available',390.00);

CREATE TABLE tv_searches (
  search_id INTEGER PRIMARY KEY, city VARCHAR, checkin DATE,
  nights INTEGER, guests INTEGER, results_shown INTEGER);
INSERT INTO tv_searches VALUES
 (1,'Porto', DATE '2025-07-01',2,2, 3),(2,'Porto', DATE '2025-07-01',3,4, 1),
 (3,'Porto', DATE '2025-07-02',2,6, 0),(4,'Porto', DATE '2025-07-03',1,2, 2),
 (5,'Porto', DATE '2025-07-03',2,8, 0),(6,'Bergen',DATE '2025-07-01',2,2, 2),
 (7,'Bergen',DATE '2025-07-02',3,6, 1),(8,'Bergen',DATE '2025-07-03',1,2, 2),
 (9,'Bergen',DATE '2025-07-01',4,8, 1),(10,'Porto',DATE '2025-07-04',2,2,1);

CREATE TABLE tv_bookings (
  booking_id INTEGER PRIMARY KEY, listing_id INTEGER, search_id INTEGER,
  checkin DATE, nights INTEGER, total_price NUMERIC(10,2));
INSERT INTO tv_bookings VALUES
 (5001,1,1,DATE '2025-07-01',2,220.00),
 (5002,2,2,DATE '2025-07-01',3,555.00),
 (5003,5,6,DATE '2025-07-01',2,440.00),
 (5004,6,8,DATE '2025-07-02',2,280.00);

SELECT count(*) AS calendar_nights FROM tv_calendar;

The questions

  1. What is occupancy by city, and does it change if blocked nights are excluded from the denominator?
  2. What share of searches return zero results, and what do those searches have in common?
  3. What is revenue per available night — the measure that combines price and occupancy?
  4. What share of listings received at least one booking (activation), and how does it vary by listing age?
  5. Where is unmet demand by property shape — which bedroom counts are searched for and not available?
  6. How concentrated is supply among hosts, and what is the risk if the largest host leaves?
  7. What is the search-to-book conversion rate, and how does it move with the number of results shown?

The SQL

Question 1 and 3 — occupancy and revenue per available night. Note the two denominators. Occupancy over all nights punishes a host for taking their own holiday; occupancy over listed nights (available plus booked) measures how well the offered supply sells. Both are legitimate, they answer different questions, and confusing them is a recurring source of disagreement between supply teams and finance.

sqltv_occupancy.sql
CREATE TABLE tv_listings AS SELECT * FROM (VALUES
 (1,901,'Porto',1),(2,901,'Porto',2),(3,902,'Porto',3),(4,903,'Porto',1),
 (5,904,'Bergen',2),(6,905,'Bergen',1),(7,906,'Bergen',4)
) AS t(listing_id,host_id,city,bedrooms);

CREATE TABLE tv_calendar AS SELECT * FROM (VALUES
 (1,'booked',110.00),(1,'booked',110.00),(1,'available',110.00),(1,'available',110.00),
 (2,'booked',180.00),(2,'booked',180.00),(2,'booked',195.00),(2,'blocked',180.00),
 (3,'available',260.00),(3,'available',260.00),(3,'available',260.00),(3,'available',260.00),
 (4,'blocked',95.00),(4,'blocked',95.00),(4,'blocked',95.00),(4,'blocked',95.00),
 (5,'booked',220.00),(5,'booked',220.00),(5,'available',220.00),(5,'available',220.00),
 (6,'available',140.00),(6,'booked',140.00),(6,'booked',140.00),(6,'available',140.00),
 (7,'available',390.00),(7,'available',390.00),(7,'available',390.00),(7,'available',390.00)
) AS t(listing_id,state,price);

SELECT l.city,
       COUNT(*)                                                     AS calendar_nights,
       SUM(CASE WHEN c.state='booked'  THEN 1 ELSE 0 END)           AS booked_nights,
       SUM(CASE WHEN c.state='blocked' THEN 1 ELSE 0 END)           AS blocked_nights,
       ROUND(100.0 * SUM(CASE WHEN c.state='booked' THEN 1 ELSE 0 END)
             / NULLIF(COUNT(*),0),1)                                AS occupancy_all_nights,
       ROUND(100.0 * SUM(CASE WHEN c.state='booked' THEN 1 ELSE 0 END)
             / NULLIF(SUM(CASE WHEN c.state IN ('booked','available')
                               THEN 1 ELSE 0 END),0),1)             AS occupancy_listed_nights,
       -- average daily rate: only nights that actually sold
       ROUND(AVG(CASE WHEN c.state='booked' THEN c.price END),2)     AS adr,
       -- revenue per available night: price and occupancy in one number
       ROUND(SUM(CASE WHEN c.state='booked' THEN c.price ELSE 0 END)
             / NULLIF(SUM(CASE WHEN c.state IN ('booked','available')
                               THEN 1 ELSE 0 END),0),2)             AS revpan
FROM tv_calendar c
JOIN tv_listings l ON l.listing_id = c.listing_id
GROUP BY l.city
ORDER BY revpan DESC;

RevPAN is the number to lead with. A host can raise the average daily rate and lose more nights than they gain; a host can fill every night at a price that leaves money on the table. Only the combined measure ranks them honestly, which is why the same construction (revenue per available unit) appears in hotels, in advertising inventory and in cloud capacity planning.

Question 2 and 5 — zero-result searches. These rows are the whole reason to keep a search log. They are demand you had and could not serve, and their attributes are the recruitment brief.

sqltv_liquidity.sql
CREATE TABLE tv_searches AS SELECT * FROM (VALUES
 (1,'Porto',2,2,3),(2,'Porto',3,4,1),(3,'Porto',2,6,0),(4,'Porto',1,2,2),
 (5,'Porto',2,8,0),(6,'Bergen',2,2,2),(7,'Bergen',3,6,1),(8,'Bergen',1,2,2),
 (9,'Bergen',4,8,1),(10,'Porto',2,2,1)
) AS t(search_id,city,nights,guests,results_shown);

CREATE TABLE tv_bookings AS SELECT * FROM (VALUES
 (5001,1),(5002,2),(5003,6),(5004,8)
) AS t(booking_id,search_id);

WITH s AS (
  SELECT sc.*,
         CASE WHEN b.search_id IS NULL THEN 0 ELSE 1 END AS booked,
         CASE WHEN sc.guests <= 2 THEN '1-2 guests'
              WHEN sc.guests <= 5 THEN '3-5 guests'
              ELSE '6+ guests' END                        AS party_size
  FROM tv_searches sc
  LEFT JOIN tv_bookings b ON b.search_id = sc.search_id
)
SELECT city,
       party_size,
       COUNT(*)                                                    AS searches,
       SUM(CASE WHEN results_shown = 0 THEN 1 ELSE 0 END)          AS zero_result_searches,
       ROUND(100.0 * AVG(CASE WHEN results_shown = 0 THEN 1.0 ELSE 0 END),1) AS zero_result_pct,
       ROUND(AVG(results_shown),1)                                 AS avg_results,
       ROUND(100.0 * AVG(booked),1)                                AS search_to_book_pct
FROM s
GROUP BY city, party_size
ORDER BY zero_result_pct DESC, city, party_size;
💡Intuition — liquidity is a supply-shape question

Zero-result searches are almost never uniformly distributed. They cluster on a specific dimension — large parties, long stays, one weekend, one neighbourhood. That clustering converts a vague “we need more supply” into a recruitment brief a market manager can actually execute: find four-bedroom places, in this district, that accept stays over five nights. The value of the query is not the headline percentage; it is the group-by that follows it.

Question 4 — listing activation. A listing that has never been booked is either priced wrong, photographed badly, or blocking its own calendar. Activation rate by cohort of listing date separates “new and not yet started” from “old and never worked”.

sqltv_activation.sql
CREATE TABLE tv_listings AS SELECT * FROM (VALUES
 (1,901,'Porto',DATE '2024-06-01'),(2,901,'Porto',DATE '2024-06-01'),
 (3,902,'Porto',DATE '2024-09-15'),(4,903,'Porto',DATE '2025-01-10'),
 (5,904,'Bergen',DATE '2024-05-20'),(6,905,'Bergen',DATE '2024-11-02'),
 (7,906,'Bergen',DATE '2025-02-01')
) AS t(listing_id,host_id,city,listed_on);

CREATE TABLE tv_bookings AS SELECT * FROM (VALUES
 (5001,1,220.00),(5002,2,555.00),(5003,5,440.00),(5004,6,280.00)
) AS t(booking_id,listing_id,total_price);

SELECT l.city,
       CASE WHEN l.listed_on < DATE '2024-10-01' THEN 'listed 2024 H2 or earlier'
            ELSE 'listed since Oct 2024' END       AS listing_vintage,
       COUNT(*)                                    AS listings,
       COUNT(b.listing_id)                         AS activated_listings,
       ROUND(100.0 * COUNT(b.listing_id)
             / NULLIF(COUNT(*),0),1)               AS activation_pct,
       ROUND(COALESCE(SUM(b.revenue),0),2)         AS revenue
FROM tv_listings l
LEFT JOIN (
  SELECT listing_id, SUM(total_price) AS revenue
  FROM tv_bookings GROUP BY listing_id
) b ON b.listing_id = l.listing_id
GROUP BY l.city, listing_vintage
ORDER BY l.city, listing_vintage;

Note the COUNT(b.listing_id) against COUNT(*). Counting a column from the outer-joined side counts only rows where the join succeeded — a compact idiom for “how many matched” that avoids a second subquery. It is also a classic source of bugs when someone later changes it to COUNT(*) for tidiness and silently turns activation into 100%.

Optimisation notes

  • The calendar is the giant. Listings times nights times a horizon of a year or more, updated constantly. Partition by night, and keep only a rolling forward window plus a compacted history.
  • Do not store nightly rows for blocked-forever listings. Represent long blocked ranges as intervals and expand only when needed; a materialised nightly table for dormant supply is pure cost.
  • Search logs are write-heavy and read-rarely. Keep them in cheap columnar storage, aggregated nightly to city×date×party-size×length-of-stay. Nobody needs the individual search after 24 hours.
  • Date-range overlap joins between bookings and calendar are expensive. Where possible, denormalise booking state onto the calendar row at write time so occupancy is a simple GROUP BY.
  • Beware double counting nights across booking modifications. Bookings get extended and cancelled; if the calendar and the bookings table disagree, define one as authoritative for occupancy and reconcile rather than averaging the two.

The dashboard

PanelChartWhy
Occupancy and RevPAN by cityBar plus line comboVolume and yield together; either alone can be gamed
Zero-result rate by party size and stay lengthHeatmapTurns unmet demand into a recruitment target grid
Booking calendar paceLine: nights booked by check-in date, versus last yearForward-looking; occupancy is a rear-view number
Listing activation by vintageStacked barSeparates “new” from “never worked”
Host concentrationCumulative share curveNames the dependency risk before it becomes a surprise

The executive read-out

  • Headline: “Occupancy looks healthy, but a substantial share of searches return nothing at all. We are full because we are small, not because demand is satisfied.”
  • Shape: “Nearly all zero-result searches are large parties. We have almost no supply above two bedrooms in one of our two cities.”
  • Yield: “Revenue per available night differs sharply between our cities, and it is price mix, not occupancy, driving the gap.”
  • Dormancy: “A meaningful group of listings older than six months has never taken a booking. That is supply on paper only.”
  • Ask: “Target host recruitment on three-plus bedroom properties, and run a calendar-and-pricing intervention on the dormant listings before recruiting more.”
Interview angle

The classic marketplace prompt: “Occupancy is 85%. Is that good?” The expected reasoning is that occupancy is bounded by supply, so a high figure with high zero-result search rates means constrained supply, not a healthy market. Candidates who ask for the search log rather than accepting the bookings table are demonstrating the single most valuable instinct in marketplace analytics: look for the demand that left no record.

Key takeaway

Occupancy alone cannot distinguish a healthy market from a starved one. Pair it with RevPAN (yield), zero-result search rate (liquidity) and listing activation (whether supply is real). Then group the zero-result searches by property shape — that group-by is the recruitment plan.

Lesson 14.8·14 min read

Project 8 — Food Delivery: Courier Logistics & Delivery-Time SLAs (DoorDash-style)

Break a late delivery into its stages so you know whether to blame the kitchen, the dispatcher or the road.

The business problem

Supply chain Halfmile delivers restaurant food. It quotes an arrival time at checkout, and that quote is a promise customers remember. Late deliveries generate refunds, support contacts and churn, so the Operations Director tracks one headline number: the share of orders delivered inside the promise.

That number, on its own, is useless for action. “Twelve per cent late” does not tell you whether the kitchen is slow, the courier arrived before the food was ready and waited, no courier was assigned for six minutes, or the traffic was bad. Each has a different owner and a different fix — restaurant coaching, dispatch tuning, courier supply, or the quoting model itself.

So the analysis is fundamentally a decomposition: an order's lifetime is a chain of timestamps, and every question worth asking is about a gap between two of them. Get the chain right and the operational conversation practically writes itself.

The data model

erDiagram
  RESTAURANTS ||--o{ ORDERS : "prepares"
  COURIERS ||--o{ ORDERS : "delivers"
  ORDERS ||--o{ ORDER_EVENTS : "emits timestamps"
  COURIERS ||--o{ SHIFTS : "logs online time"
The event table is deliberately tall — one row per stage transition — because stages get added over time. A wide table with fixed timestamp columns needs a schema migration every time operations invents a new stage.
TableGrainKey columns
fd_ordersone customer orderorder_id, restaurant_id, courier_id, placed_at, promised_min
fd_eventsone stage transition on one orderorder_id, stage, event_at
fd_restaurantsone restaurantrestaurant_id, name, city, cuisine
fd_shiftsone courier online periodcourier_id, shift_date, online_min

The stages are accepted (restaurant confirms), ready (food is up), picked_up (courier leaves) and delivered. The four gaps between placement and those four marks are the entire diagnostic vocabulary of the business.

sqlfd_schema.sql
CREATE TABLE fd_restaurants (restaurant_id INTEGER PRIMARY KEY, name VARCHAR, city VARCHAR, cuisine VARCHAR);
INSERT INTO fd_restaurants VALUES
 (1,'Silver Wok','Leeds','chinese'),(2,'Nonna Rosa','Leeds','italian'),
 (3,'Curry Post','Leeds','indian'),(4,'Bun House','York','burgers');

CREATE TABLE fd_orders (
  order_id INTEGER PRIMARY KEY, restaurant_id INTEGER, courier_id INTEGER,
  placed_at TIMESTAMP, promised_min INTEGER);
INSERT INTO fd_orders VALUES
 (1,1,701,TIMESTAMP '2025-05-09 18:00:00',35),
 (2,1,702,TIMESTAMP '2025-05-09 18:10:00',35),
 (3,2,701,TIMESTAMP '2025-05-09 18:20:00',40),
 (4,2,703,TIMESTAMP '2025-05-09 18:30:00',40),
 (5,3,702,TIMESTAMP '2025-05-09 19:00:00',45),
 (6,3,703,TIMESTAMP '2025-05-09 19:05:00',45),
 (7,4,704,TIMESTAMP '2025-05-09 19:15:00',30),
 (8,4,704,TIMESTAMP '2025-05-09 19:40:00',30);

CREATE TABLE fd_events (order_id INTEGER, stage VARCHAR, event_at TIMESTAMP);
INSERT INTO fd_events VALUES
 (1,'accepted', TIMESTAMP '2025-05-09 18:02:00'),
 (1,'ready',    TIMESTAMP '2025-05-09 18:18:00'),
 (1,'picked_up',TIMESTAMP '2025-05-09 18:20:00'),
 (1,'delivered',TIMESTAMP '2025-05-09 18:33:00'),
 (2,'accepted', TIMESTAMP '2025-05-09 18:12:00'),
 (2,'ready',    TIMESTAMP '2025-05-09 18:36:00'),
 (2,'picked_up',TIMESTAMP '2025-05-09 18:44:00'),
 (2,'delivered',TIMESTAMP '2025-05-09 18:58:00'),
 (3,'accepted', TIMESTAMP '2025-05-09 18:23:00'),
 (3,'ready',    TIMESTAMP '2025-05-09 18:41:00'),
 (3,'picked_up',TIMESTAMP '2025-05-09 18:43:00'),
 (3,'delivered',TIMESTAMP '2025-05-09 18:57:00'),
 (4,'accepted', TIMESTAMP '2025-05-09 18:34:00'),
 (4,'ready',    TIMESTAMP '2025-05-09 18:52:00'),
 (4,'picked_up',TIMESTAMP '2025-05-09 19:04:00'),
 (4,'delivered',TIMESTAMP '2025-05-09 19:19:00'),
 (5,'accepted', TIMESTAMP '2025-05-09 19:03:00'),
 (5,'ready',    TIMESTAMP '2025-05-09 19:29:00'),
 (5,'picked_up',TIMESTAMP '2025-05-09 19:31:00'),
 (5,'delivered',TIMESTAMP '2025-05-09 19:48:00'),
 (6,'accepted', TIMESTAMP '2025-05-09 19:08:00'),
 (6,'ready',    TIMESTAMP '2025-05-09 19:38:00'),
 (6,'picked_up',TIMESTAMP '2025-05-09 19:50:00'),
 (6,'delivered',TIMESTAMP '2025-05-09 20:06:00'),
 (7,'accepted', TIMESTAMP '2025-05-09 19:17:00'),
 (7,'ready',    TIMESTAMP '2025-05-09 19:31:00'),
 (7,'picked_up',TIMESTAMP '2025-05-09 19:33:00'),
 (7,'delivered',TIMESTAMP '2025-05-09 19:43:00'),
 (8,'accepted', TIMESTAMP '2025-05-09 19:42:00'),
 (8,'ready',    TIMESTAMP '2025-05-09 19:56:00'),
 (8,'picked_up',TIMESTAMP '2025-05-09 20:01:00'),
 (8,'delivered',TIMESTAMP '2025-05-09 20:14:00');

CREATE TABLE fd_shifts (courier_id INTEGER, shift_date DATE, online_min INTEGER);
INSERT INTO fd_shifts VALUES
 (701,DATE '2025-05-09',180),(702,DATE '2025-05-09',210),
 (703,DATE '2025-05-09',150),(704,DATE '2025-05-09',120);

SELECT count(*) AS events FROM fd_events;

The questions

  1. What is the median and 90th-percentile end-to-end delivery time, by city and by hour?
  2. How does that time decompose into confirm, prep, wait-at-restaurant and transit?
  3. What share of orders breach the promise, and which stage is longest on breached orders?
  4. Which restaurants have prep times far above their cuisine's norm?
  5. How much courier time is spent waiting at restaurants — paid, unproductive minutes?
  6. What is courier throughput (deliveries per online hour), and how does it vary by courier?
  7. Is our quoted time systematically optimistic, and by how much, per restaurant?

The SQL

Questions 1 to 3 — the decomposition. The first move is to pivot the tall event table into one row per order with one column per stage. Conditional aggregation does this in a single pass; a four-way self-join does the same thing four times as slowly.

sqlfd_decomposition.sql
CREATE TABLE fd_orders AS SELECT * FROM (VALUES
 (1,1,TIMESTAMP '2025-05-09 18:00:00',35),(2,1,TIMESTAMP '2025-05-09 18:10:00',35),
 (3,2,TIMESTAMP '2025-05-09 18:20:00',40),(4,2,TIMESTAMP '2025-05-09 18:30:00',40),
 (5,3,TIMESTAMP '2025-05-09 19:00:00',45),(6,3,TIMESTAMP '2025-05-09 19:05:00',45),
 (7,4,TIMESTAMP '2025-05-09 19:15:00',30),(8,4,TIMESTAMP '2025-05-09 19:40:00',30)
) AS t(order_id,restaurant_id,placed_at,promised_min);

CREATE TABLE fd_events AS SELECT * FROM (VALUES
 (1,'accepted',TIMESTAMP '2025-05-09 18:02:00'),(1,'ready',TIMESTAMP '2025-05-09 18:18:00'),
 (1,'picked_up',TIMESTAMP '2025-05-09 18:20:00'),(1,'delivered',TIMESTAMP '2025-05-09 18:33:00'),
 (2,'accepted',TIMESTAMP '2025-05-09 18:12:00'),(2,'ready',TIMESTAMP '2025-05-09 18:36:00'),
 (2,'picked_up',TIMESTAMP '2025-05-09 18:44:00'),(2,'delivered',TIMESTAMP '2025-05-09 18:58:00'),
 (3,'accepted',TIMESTAMP '2025-05-09 18:23:00'),(3,'ready',TIMESTAMP '2025-05-09 18:41:00'),
 (3,'picked_up',TIMESTAMP '2025-05-09 18:43:00'),(3,'delivered',TIMESTAMP '2025-05-09 18:57:00'),
 (4,'accepted',TIMESTAMP '2025-05-09 18:34:00'),(4,'ready',TIMESTAMP '2025-05-09 18:52:00'),
 (4,'picked_up',TIMESTAMP '2025-05-09 19:04:00'),(4,'delivered',TIMESTAMP '2025-05-09 19:19:00'),
 (5,'accepted',TIMESTAMP '2025-05-09 19:03:00'),(5,'ready',TIMESTAMP '2025-05-09 19:29:00'),
 (5,'picked_up',TIMESTAMP '2025-05-09 19:31:00'),(5,'delivered',TIMESTAMP '2025-05-09 19:48:00'),
 (6,'accepted',TIMESTAMP '2025-05-09 19:08:00'),(6,'ready',TIMESTAMP '2025-05-09 19:38:00'),
 (6,'picked_up',TIMESTAMP '2025-05-09 19:50:00'),(6,'delivered',TIMESTAMP '2025-05-09 20:06:00'),
 (7,'accepted',TIMESTAMP '2025-05-09 19:17:00'),(7,'ready',TIMESTAMP '2025-05-09 19:31:00'),
 (7,'picked_up',TIMESTAMP '2025-05-09 19:33:00'),(7,'delivered',TIMESTAMP '2025-05-09 19:43:00'),
 (8,'accepted',TIMESTAMP '2025-05-09 19:42:00'),(8,'ready',TIMESTAMP '2025-05-09 19:56:00'),
 (8,'picked_up',TIMESTAMP '2025-05-09 20:01:00'),(8,'delivered',TIMESTAMP '2025-05-09 20:14:00')
) AS t(order_id,stage,event_at);

CREATE TABLE fd_restaurants AS SELECT * FROM (VALUES
 (1,'Silver Wok','chinese'),(2,'Nonna Rosa','italian'),
 (3,'Curry Post','indian'),(4,'Bun House','burgers')
) AS t(restaurant_id,name,cuisine);

WITH pivoted AS (          -- tall -> wide, one pass
  SELECT order_id,
         MAX(CASE WHEN stage='accepted'  THEN event_at END) AS accepted_at,
         MAX(CASE WHEN stage='ready'     THEN event_at END) AS ready_at,
         MAX(CASE WHEN stage='picked_up' THEN event_at END) AS picked_at,
         MAX(CASE WHEN stage='delivered' THEN event_at END) AS delivered_at
  FROM fd_events GROUP BY order_id
),
timed AS (
  SELECT o.order_id,
         r.name AS restaurant,
         o.promised_min,
         DATE_DIFF('minute', o.placed_at,  p.accepted_at)  AS confirm_min,
         DATE_DIFF('minute', p.accepted_at, p.ready_at)    AS prep_min,
         DATE_DIFF('minute', p.ready_at,    p.picked_at)   AS wait_min,
         DATE_DIFF('minute', p.picked_at,   p.delivered_at) AS transit_min,
         DATE_DIFF('minute', o.placed_at,   p.delivered_at) AS total_min
  FROM fd_orders o
  JOIN pivoted p     ON p.order_id = o.order_id
  JOIN fd_restaurants r ON r.restaurant_id = o.restaurant_id
)
SELECT restaurant,
       COUNT(*)                                   AS orders,
       ROUND(AVG(confirm_min),1)                  AS avg_confirm,
       ROUND(AVG(prep_min),1)                     AS avg_prep,
       ROUND(AVG(wait_min),1)                     AS avg_courier_wait,
       ROUND(AVG(transit_min),1)                  AS avg_transit,
       ROUND(MEDIAN(total_min),1)                 AS p50_total,
       ROUND(QUANTILE_CONT(total_min, 0.9),1)     AS p90_total,
       ROUND(100.0 * AVG(CASE WHEN total_min > promised_min
                              THEN 1.0 ELSE 0 END),1) AS breach_pct
FROM timed
GROUP BY restaurant
ORDER BY breach_pct DESC, p90_total DESC;
Common pitfall — averaging a latency

Delivery times are right-skewed: most are fine, a few are terrible, and the terrible ones cause the refunds. The mean is dragged around by the tail while hiding it. Report the median for the typical experience and the 90th or 95th percentile for the experience that generates complaints. If you may report only one number, report the percentile — nobody ever contacted support because their delivery was average.

Question 3, continued — attributing the breach. Knowing an order was late is not enough; you need the stage that owned the lateness. Comparing each stage against its own normal isolates the culprit.

sqlfd_breach_cause.sql
CREATE TABLE fd_stage_min AS SELECT * FROM (VALUES
 (1, 2,16, 2,13,35),(2, 2,24, 8,14,35),(3, 3,18, 2,14,40),(4, 4,18,12,15,40),
 (5, 3,26, 2,17,45),(6, 3,30,12,16,45),(7, 2,14, 2,10,30),(8, 2,14, 5,13,30)
) AS t(order_id,confirm_min,prep_min,wait_min,transit_min,promised_min);

WITH t AS (
  SELECT *,
         confirm_min + prep_min + wait_min + transit_min AS total_min
  FROM fd_stage_min
),
norms AS (       -- what "normal" looks like for each stage
  SELECT MEDIAN(confirm_min) AS m_confirm, MEDIAN(prep_min) AS m_prep,
         MEDIAN(wait_min)    AS m_wait,    MEDIAN(transit_min) AS m_transit
  FROM t
),
excess AS (
  SELECT t.order_id,
         t.total_min,
         t.promised_min,
         t.confirm_min - n.m_confirm AS x_confirm,
         t.prep_min    - n.m_prep    AS x_prep,
         t.wait_min    - n.m_wait    AS x_wait,
         t.transit_min - n.m_transit AS x_transit
  FROM t CROSS JOIN norms n
  WHERE t.total_min > t.promised_min          -- breached orders only
)
SELECT order_id,
       total_min,
       promised_min,
       total_min - promised_min AS minutes_late,
       ROUND(x_confirm,1) AS excess_confirm,
       ROUND(x_prep,1)    AS excess_prep,
       ROUND(x_wait,1)    AS excess_wait,
       ROUND(x_transit,1) AS excess_transit,
       CASE GREATEST(x_confirm, x_prep, x_wait, x_transit)
            WHEN x_prep    THEN 'kitchen'
            WHEN x_wait    THEN 'dispatch timing'
            WHEN x_transit THEN 'road'
            ELSE 'restaurant confirm' END AS primary_cause
FROM excess
ORDER BY minutes_late DESC;

GREATEST compared against each candidate in a CASE is a compact way to name the largest of several columns. It is worth knowing because the alternative — unpivoting to rows, ranking, and pivoting back — is three times the code for the same answer.

Question 5 and 6 — courier productivity. Wait-at-restaurant time is the most under-managed cost in delivery logistics: it is paid, it produces nothing, and it is caused by dispatching too early rather than by the courier.

sqlfd_courier_productivity.sql
CREATE TABLE fd_orders AS SELECT * FROM (VALUES
 (1,701,2,13),(2,702,8,14),(3,701,2,14),(4,703,12,15),
 (5,702,2,17),(6,703,12,16),(7,704,2,10),(8,704,5,13)
) AS t(order_id,courier_id,wait_min,transit_min);

CREATE TABLE fd_shifts AS SELECT * FROM (VALUES
 (701,180),(702,210),(703,150),(704,120)
) AS t(courier_id,online_min);

SELECT s.courier_id,
       COUNT(o.order_id)                                        AS deliveries,
       s.online_min,
       ROUND(60.0 * COUNT(o.order_id) / NULLIF(s.online_min,0),2) AS deliveries_per_hour,
       COALESCE(SUM(o.wait_min),0)                              AS total_wait_min,
       ROUND(100.0 * COALESCE(SUM(o.wait_min),0)
             / NULLIF(s.online_min,0),1)                        AS pct_online_waiting,
       ROUND(100.0 * COALESCE(SUM(o.wait_min + o.transit_min),0)
             / NULLIF(s.online_min,0),1)                        AS pct_online_on_task
FROM fd_shifts s
LEFT JOIN fd_orders o ON o.courier_id = s.courier_id
GROUP BY s.courier_id, s.online_min
ORDER BY deliveries_per_hour DESC;

Optimisation notes

  • Pivot once, store wide. The conditional-aggregation pivot is cheap on one day and expensive on two years. Materialise an order_timings table at delivery time with the stage timestamps and the derived gaps already computed.
  • Exact percentiles require a sort. QUANTILE_CONT over a billion rows is a full sort; APPROX_QUANTILE uses a sketch and is orders of magnitude cheaper. For a dashboard, approximate is right; for an SLA credit calculation, it is not.
  • Clock skew is a real data-quality problem. Restaurant tablets, courier phones and servers each have their own clocks, so negative durations appear. Filter and count them explicitly — a silently dropped negative is a silently biased median.
  • Late-arriving events break naive incremental jobs. A delivered event can land minutes after the partition it belongs to closes. Reprocess a trailing window (say three days) rather than only the newest partition.
  • Index or cluster on (order_id) for the event table, since the pivot groups by it; and partition by event date for retention pruning.

The dashboard

PanelChartWhy
p50 and p90 delivery time by hourTwo lines, same axisThe widening gap during peak is the story a single average hides
Stage decompositionStacked bar per restaurantSegments sum to the total, so stacking is meaningful — and the tall segment names the owner
Breach rate vs promise accuracyScatter, promised vs actual, 45° linePoints above the line are quoting failures, not operational ones
Courier wait minutesBar by restaurant, descendingWait is caused by the restaurant and the dispatcher, so display it by restaurant
Deliveries per online hourDistribution with median markerA ranked list invites blaming individuals; a distribution shows the system

The executive read-out

  • Headline: “Typical deliveries are comfortably inside the promise. The problem is entirely in the tail — the slowest tenth run far beyond it.”
  • Cause: “On late orders, kitchen preparation is the stage most above its own normal. This is not a courier supply problem.”
  • Hidden cost: “Couriers spend a meaningful share of paid time standing in restaurants because we dispatch before food is ready.”
  • Quoting: “For two restaurants our quoted time is systematically optimistic. We are manufacturing our own breaches at checkout.”
  • Ask: “Re-quote those two restaurants from their actual prep distribution, delay dispatch by the predicted prep time, and coach the single worst kitchen. Re-measure at p90, not the average.”
Interview angle

“Given an events table with one row per stage, compute time spent in each stage” is a standard logistics SQL question. Conditional aggregation into a wide row is the expected answer; LEAD() over ordered events is the accepted alternative and is better when stages vary in number. The follow-up is always about percentiles: if a candidate reports mean delivery time without prompting, the interviewer has learned they have not worked with latency data.

Key takeaway

An SLA number without a decomposition is a complaint, not an analysis. Pivot the event log into stage durations, compare each stage against its own norm, and report percentiles rather than means. The stage that is furthest above its normal on breached orders names the team that owns the fix.

Lesson 14.9·14 min read

Project 9 — Music Streaming: Listening Behaviour & Playlist Impact (Spotify-style)

Use skip rates and before-and-after windows to judge whether a playlist placement actually did anything.

The business problem

Digital media Cadence is a music streaming service. Its editorial team curates playlists, and a placement on a large one is the most valuable thing the platform can give an artist. Labels ask for placements constantly; the editorial team has finite slots and needs to know which placements actually generate listening rather than merely coinciding with it.

Two distinctive features shape the analysis. First, the skip is the primary negative signal. Unlike video, a listener rejects a track within seconds, cheaply and constantly, so skip rate is a fast, high-volume measure of fit. Second, context determines expectation: a track skipped in an algorithmic radio stream means something very different from the same track skipped when the user searched for it by name. Any skip rate reported without its source is uninterpretable.

The decision is which tracks to place and which to remove, made weekly by an editorial lead who is being lobbied hard and needs defensible numbers.

The data model

erDiagram
  ARTISTS ||--o{ TRACKS : "records"
  TRACKS ||--o{ STREAMS : "is played in"
  USERS ||--o{ STREAMS : "plays"
  PLAYLISTS ||--o{ PLAYLIST_TRACKS : "contains"
  TRACKS ||--o{ PLAYLIST_TRACKS : "is placed on"
PLAYLIST_TRACKS carries an added_on date. That single column is what turns a static membership list into a natural before-and-after experiment.
TableGrainKey columns
ms_tracksone recordingtrack_id, artist_id, title, duration_sec
ms_streamsone playbackstream_id, user_id, track_id, played_on, ms_played, source
ms_playlistsone playlistplaylist_id, playlist_name, curator_type
ms_playlist_tracksone track on one playlistplaylist_id, track_id, added_on
sqlms_schema.sql
CREATE TABLE ms_tracks (track_id INTEGER PRIMARY KEY, artist_id INTEGER, title VARCHAR, duration_sec INTEGER);
INSERT INTO ms_tracks VALUES
 (1,11,'Slate Harbour',214),(2,11,'Nightbus',198),(3,12,'Copper Wire',245),
 (4,12,'Low Tide',176),(5,13,'Paper Moth',232),(6,13,'Winterlight',301);

CREATE TABLE ms_playlists (playlist_id INTEGER PRIMARY KEY, playlist_name VARCHAR, curator_type VARCHAR);
INSERT INTO ms_playlists VALUES
 (1,'Fresh Finds','editorial'),(2,'Deep Focus','editorial'),(3,'User Mix 4412','user');

CREATE TABLE ms_playlist_tracks (playlist_id INTEGER, track_id INTEGER, added_on DATE);
INSERT INTO ms_playlist_tracks VALUES
 (1,3,DATE '2025-03-10'),(1,5,DATE '2025-03-10'),
 (2,6,DATE '2025-03-17'),(3,1,DATE '2025-02-01');

CREATE TABLE ms_streams (
  stream_id INTEGER PRIMARY KEY, user_id INTEGER, track_id INTEGER,
  played_on DATE, ms_played INTEGER, source VARCHAR);
INSERT INTO ms_streams VALUES
 (1, 101,3,DATE '2025-03-05', 18000,'search'),
 (2, 102,3,DATE '2025-03-06',245000,'album'),
 (3, 103,3,DATE '2025-03-07', 22000,'radio'),
 (4, 101,3,DATE '2025-03-12',245000,'playlist'),
 (5, 104,3,DATE '2025-03-12',230000,'playlist'),
 (6, 105,3,DATE '2025-03-13', 15000,'playlist'),
 (7, 106,3,DATE '2025-03-14',245000,'playlist'),
 (8, 107,3,DATE '2025-03-15',240000,'search'),
 (9, 101,5,DATE '2025-03-04',232000,'album'),
 (10,102,5,DATE '2025-03-11',232000,'playlist'),
 (11,103,5,DATE '2025-03-12', 12000,'playlist'),
 (12,104,5,DATE '2025-03-13',225000,'playlist'),
 (13,101,1,DATE '2025-03-06',214000,'playlist'),
 (14,102,1,DATE '2025-03-12', 20000,'radio'),
 (15,103,2,DATE '2025-03-07',  9000,'radio'),
 (16,104,2,DATE '2025-03-13',198000,'search'),
 (17,105,6,DATE '2025-03-14', 25000,'radio'),
 (18,106,6,DATE '2025-03-20',301000,'playlist'),
 (19,107,6,DATE '2025-03-21',295000,'playlist'),
 (20,101,4,DATE '2025-03-18', 11000,'radio');

SELECT count(*) AS streams FROM ms_streams;

The questions

  1. What is the skip rate by source, where a skip is under 30 seconds played?
  2. Which tracks have a high skip rate specifically in editorial playlist context — the removal candidates?
  3. Did daily streams for a track rise after its playlist placement, relative to before?
  4. What share of a track's streams comes from playlists versus search — is it discovered or demanded?
  5. How many unique listeners does each track reach, and how many return to it?
  6. Which artists convert playlist exposure into catalogue listening (people who go on to play their other tracks)?
  7. What is completion rate by track, adjusted for track length?

The SQL

Question 1 and 2 — skip rate in context. The 30-second threshold is a convention rather than a law; what matters is applying it consistently and reporting completion alongside it, since a track can be rarely skipped and still rarely finished.

sqlms_skip_rate.sql
CREATE TABLE ms_tracks AS SELECT * FROM (VALUES
 (1,'Slate Harbour',214),(2,'Nightbus',198),(3,'Copper Wire',245),
 (4,'Low Tide',176),(5,'Paper Moth',232),(6,'Winterlight',301)
) AS t(track_id,title,duration_sec);

CREATE TABLE ms_streams AS SELECT * FROM (VALUES
 (3,18000,'search'),(3,245000,'album'),(3,22000,'radio'),(3,245000,'playlist'),
 (3,230000,'playlist'),(3,15000,'playlist'),(3,245000,'playlist'),(3,240000,'search'),
 (5,232000,'album'),(5,232000,'playlist'),(5,12000,'playlist'),(5,225000,'playlist'),
 (1,214000,'playlist'),(1,20000,'radio'),(2,9000,'radio'),(2,198000,'search'),
 (6,25000,'radio'),(6,301000,'playlist'),(6,295000,'playlist'),(4,11000,'radio')
) AS t(track_id,ms_played,source);

SELECT s.source,
       COUNT(*)                                                   AS streams,
       ROUND(AVG(CASE WHEN s.ms_played < 30000 THEN 1.0 ELSE 0 END),3) AS skip_rate,
       ROUND(AVG(CASE WHEN s.ms_played >= 0.90 * t.duration_sec * 1000
                      THEN 1.0 ELSE 0 END),3)                     AS completion_rate,
       ROUND(AVG(1.0 * s.ms_played / NULLIF(t.duration_sec*1000,0)),3) AS avg_pct_played
FROM ms_streams s
JOIN ms_tracks t ON t.track_id = s.track_id
GROUP BY s.source
ORDER BY skip_rate DESC;
Analogy

Judging a track by its overall skip rate is like judging a shop assistant by how often customers walk away, without asking whether they were greeting people at the door or ringing up items someone had already chosen. Radio is the door; search is the till. The same person performs very differently at each, and only a comparison within the same position means anything.

Question 3 — did the placement do anything? Compare a window before the add date with an equal window after. This is a before-and-after design, and its weakness must be stated plainly: playlists are curated because a track is gaining momentum, so some of the lift would have happened anyway.

sqlms_playlist_lift.sql
CREATE TABLE ms_playlist_tracks AS SELECT * FROM (VALUES
 (1,3,DATE '2025-03-10'),(1,5,DATE '2025-03-10'),
 (2,6,DATE '2025-03-17'),(3,1,DATE '2025-02-01')
) AS t(playlist_id,track_id,added_on);

CREATE TABLE ms_tracks AS SELECT * FROM (VALUES
 (1,'Slate Harbour'),(2,'Nightbus'),(3,'Copper Wire'),
 (4,'Low Tide'),(5,'Paper Moth'),(6,'Winterlight')
) AS t(track_id,title);

CREATE TABLE ms_streams AS SELECT * FROM (VALUES
 (101,3,DATE '2025-03-05'),(102,3,DATE '2025-03-06'),(103,3,DATE '2025-03-07'),
 (101,3,DATE '2025-03-12'),(104,3,DATE '2025-03-12'),(105,3,DATE '2025-03-13'),
 (106,3,DATE '2025-03-14'),(107,3,DATE '2025-03-15'),
 (101,5,DATE '2025-03-04'),(102,5,DATE '2025-03-11'),(103,5,DATE '2025-03-12'),
 (104,5,DATE '2025-03-13'),(101,1,DATE '2025-03-06'),(102,1,DATE '2025-03-12'),
 (103,2,DATE '2025-03-07'),(104,2,DATE '2025-03-13'),(105,6,DATE '2025-03-14'),
 (106,6,DATE '2025-03-20'),(107,6,DATE '2025-03-21'),(101,4,DATE '2025-03-18')
) AS t(user_id,track_id,played_on);

WITH placement AS (            -- first editorial placement per track
  SELECT track_id, MIN(added_on) AS added_on
  FROM ms_playlist_tracks
  WHERE playlist_id IN (1,2)          -- editorial playlists only
  GROUP BY track_id
),
windowed AS (
  SELECT p.track_id,
         p.added_on,
         SUM(CASE WHEN s.played_on >= p.added_on - INTERVAL 5 DAY
                   AND s.played_on <  p.added_on THEN 1 ELSE 0 END) AS streams_before,
         SUM(CASE WHEN s.played_on >= p.added_on
                   AND s.played_on <  p.added_on + INTERVAL 5 DAY THEN 1 ELSE 0 END) AS streams_after,
         COUNT(DISTINCT CASE WHEN s.played_on >= p.added_on THEN s.user_id END) AS listeners_after
  FROM placement p
  LEFT JOIN ms_streams s ON s.track_id = p.track_id
  GROUP BY p.track_id, p.added_on
)
SELECT t.title,
       w.added_on,
       w.streams_before,
       w.streams_after,
       w.streams_after - w.streams_before AS absolute_change,
       ROUND(1.0 * w.streams_after / NULLIF(w.streams_before,0),2) AS ratio_after_before,
       w.listeners_after
FROM windowed w
JOIN ms_tracks t ON t.track_id = w.track_id
ORDER BY absolute_change DESC;
Common pitfall — ratios on tiny denominators

A track with one stream before and twelve after shows a twelve-fold lift, which will be quoted in a meeting and cannot survive contact with reality. Always report the absolute change beside the ratio, and set a minimum pre-period volume before a ratio is displayed at all. NULLIF protects you from dividing by zero; it does not protect you from dividing by one.

Optimisation notes

  • Streams are among the highest-volume events in consumer software. Nobody queries raw streams interactively. Build track×day×source aggregates carrying stream count, skip count, completion count and distinct listeners, and point all analysis at that.
  • Distinct listeners do not sum across days, so the aggregate cannot answer monthly-unique questions. Store an HLL sketch per track×day; sketches do merge, which is the entire reason they exist.
  • Define skip once, centrally. The 30-second rule should live in one view or one dbt macro. When it lives in forty queries, half of them will still say 25 seconds two years later and nobody will know which report is right.
  • The before-and-after join is a range join and will not use an equality hash. Reduce it first to a small placement table (one row per track), then join — never scan all streams against all placements.
  • Partition streams by date and cluster by track_id: artist and label reporting is overwhelmingly “this track, this window”.

The dashboard

PanelChartWhy
Skip rate by sourceGrouped bar with stream volume labelsRate alone invites over-reading thin sources
Track skip rate vs streamsScatter, playlist context onlyThe high-volume, high-skip corner is the removal shortlist
Streams around placement dateLine with vertical marker at add dateShows both the jump and the pre-trend that undermines it
Source mix per track100% stacked barDistinguishes a discovered track from a demanded one
Listener return rateCohort-style grid by week of first listenRepeat listening, not first plays, predicts catalogue value

The executive read-out

  • Headline: “Skip rates differ enormously by context. Comparing tracks without holding source constant has been giving us the wrong shortlist.”
  • Removals: “Two tracks on our largest editorial playlist are skipped far more often than their neighbours. They are costing us listening minutes on our most valuable real estate.”
  • Placement effect: “Placed tracks gained streams. Some of that gain was already in motion before the placement — the pre-trend is visible in the chart.”
  • Discovery: “One artist converts playlist exposure into catalogue listening at a much higher rate than the others. That is the sign of a real fanbase forming rather than a passive stream.”
  • Ask: “Swap the two high-skip tracks, and let us hold back a random subset of the next batch of placements for two weeks so we can measure lift properly instead of arguing about pre-trends.”
Interview angle

The pattern being tested is the conditional-aggregation before-and-after window: two SUM(CASE WHEN date BETWEEN ...) columns anchored on a per-entity event date. Expect the follow-up “streams tripled after the placement — is the playlist responsible?” The answer names selection bias (tracks are placed because they are rising) and proposes a hold-out or a matched comparison group of similar tracks that were not placed.

Key takeaway

Skip rate is only interpretable within a source context, and playlist lift measured before-versus-after is contaminated by the reason the placement was made. Compute both, report the absolute change next to every ratio, and be the person who says which half of the lift was already happening.

Reference · 110 terms

SQL Glossary

Every key term from the course in one place — a plain-language line and a precise technical note for each. Filter to find what you need.

Relational Core
Relation (table)
A set of rows with named, typed columns — the core structure SQL operates on.
Formally a relation from Codd's model; operations on tables produce more tables (closure).
Relational Core
Row (tuple)
A single record — one entity instance — in a table.
Also called a tuple or record; ordering among rows is not significant without ORDER BY.
Relational Core
Column (attribute)
A named field with one data type, holding one kind of value for every row.
Also an attribute; its type is a contract enforced on every write.
Relational Core
Schema
The structural definition of tables, columns, types, and constraints.
Also a namespace grouping objects (e.g. Postgres public schema).
Relational Core
Data type
The kind of value a column stores: integer, text, date, boolean, etc.
Determines valid operations and storage; e.g. NUMERIC(p,s) for exact money.
Relational Core
NULL
A marker meaning “no value” — unknown or not applicable; not zero or empty string.
Comparisons with NULL yield unknown; test only with IS NULL / IS NOT NULL.
Relational Core
Three-valued logic
SQL predicates evaluate to true, false, or unknown — not just true/false.
WHERE keeps only rows where the predicate is true; unknown rows are dropped.
Relational Core
Constraint
A rule the database enforces on data (keys, NOT NULL, CHECK, UNIQUE).
Centralises invariants for every writer; rejects violating writes.
Relational Core
Primary key (PK)
A column (or set) that uniquely identifies each row.
Implies UNIQUE + NOT NULL; one per table; often backed by an index.
Relational Core
Foreign key (FK)
A column that references another table's primary key, encoding a relationship.
Enforces referential integrity; ON DELETE/ON UPDATE control cascade behaviour.
Relational Core
Composite key
A key made of two or more columns together.
Relevant to 2NF — non-key columns must depend on the whole composite key.
Relational Core
Surrogate key
An artificial identifier (e.g. auto-increment id) with no business meaning.
Contrast a natural key (e.g. email); surrogates are stable across business changes.
Relational Core
Referential integrity
The guarantee that every foreign-key value matches an existing parent row.
Enforced by FK constraints; prevents orphaned references.
Relational Core
Cardinality
The type of a relationship: 1:1, 1:N, N:1, or M:N.
Predicts a join's effect on row counts before you run it; M:N needs a junction table.
Relational Core
Junction (bridge) table
A table that resolves a many-to-many relationship via two foreign keys.
E.g. guild_members(player_id, guild_id); may carry extra attributes.
Retrieval
SELECT
The clause that chooses which columns (a projection) to return.
Logically evaluated after FROM/WHERE/GROUP BY; can't be seen by WHERE.
Retrieval
FROM
Names the table(s) a query reads from.
First clause evaluated logically; where joins are expressed.
Retrieval
Projection
Selecting a subset of columns from a table.
The vertical slice; contrast selection (rows) via WHERE.
Retrieval
WHERE
Filters rows by a predicate before grouping.
Keeps rows where the condition is true; runs before aggregation, so can't reference aggregates.
Retrieval
Predicate
A boolean condition evaluated per row.
Built from comparison (=, <, >) and logical (AND/OR/NOT) operators.
Retrieval
DISTINCT
Removes duplicate rows from a result.
Equivalent set to a GROUP BY on the same columns, without per-group aggregation.
Retrieval
ORDER BY
Sorts the result by one or more columns, ascending or descending.
Without it, row order is not guaranteed; add tie-breakers for determinism.
Retrieval
LIMIT / OFFSET
Returns a slice of rows; OFFSET skips rows for pagination.
Always pair with a total ORDER BY; SQL Server uses FETCH NEXT.
Retrieval
Alias (AS)
A temporary name for a column or table in a query.
Improves readability; table aliases are essential for self-joins.
Retrieval
LIKE
Pattern-matches text: % matches any run, _ matches one character.
Leading wildcards typically defeat B-tree indexes; Postgres ILIKE is case-insensitive.
Retrieval
IN
Tests membership in a set or subquery result.
Cleaner than chained ORs; beware NOT IN with NULL (returns no rows).
Retrieval
BETWEEN
Tests an inclusive range: x BETWEEN a AND b.
Both endpoints included; for timestamps prefer a half-open >= a AND < b.
Aggregation
Aggregate function
A function that collapses many rows into one value.
COUNT/SUM/AVG/MIN/MAX; all except COUNT(*) ignore NULLs.
Aggregation
COUNT
Counts rows or non-NULL values.
COUNT(*) counts rows; COUNT(col) skips NULLs; COUNT(DISTINCT col) counts unique values.
Aggregation
SUM / AVG
Total and mean of a numeric column.
Both ignore NULLs; AVG = SUM/COUNT over non-NULL values; average a ratio with SUM/SUM.
Aggregation
GROUP BY
Partitions rows into groups so aggregates run once per group.
Non-aggregated SELECT columns must appear here (strict in Postgres; MySQL ONLY_FULL_GROUP_BY).
Aggregation
HAVING
Filters groups after aggregation.
The only place to filter on an aggregate; WHERE can't see SUM/COUNT.
Aggregation
Grain
The level of detail of a row or result (per day, per SKU, etc.).
Choosing and stating a consistent grain is the practical hard part of reporting.
Aggregation
COALESCE
Returns the first non-NULL of its arguments.
Used to substitute defaults, e.g. COALESCE(SUM(amount),0) after a LEFT JOIN.
Aggregation
NULLIF
Returns NULL if two values are equal, else the first.
Handy to avoid division-by-zero: x / NULLIF(y,0).
Aggregation
Conditional aggregation
Aggregating only rows that match a condition, via CASE inside the aggregate.
The portable pivot pattern: SUM(CASE WHEN … THEN … END); Postgres has FILTER.
Joins
JOIN
Combines rows from two tables based on a related column.
Inner join is commutative/associative for results; order can affect performance.
Joins
INNER JOIN
Returns only rows with a match in both tables.
An intersection on the join key; silently drops non-matching rows.
Joins
LEFT (OUTER) JOIN
Keeps all left-table rows, NULL-filling where the right has no match.
The dominant outer join; RIGHT JOIN is the same with tables swapped.
Joins
FULL OUTER JOIN
Keeps all rows from both tables, NULL-filling either side as needed.
Useful for reconciling two sources; not in older MySQL versions.
Joins
CROSS JOIN
Every row of A paired with every row of B (the Cartesian product).
Intentional for generating combinations; accidental when an ON is forgotten.
Joins
Cartesian product
All possible row pairings between two tables.
Row count = |A| × |B|; the symptom of a missing/wrong join condition.
Joins
Self-join
A table joined to itself via two aliases.
Relates rows within one table — e.g. employee to manager, or consecutive dates.
Joins
Anti-join
Finding rows in one table with no match in another.
Idiom: LEFT JOIN … WHERE right.key IS NULL; or NOT EXISTS.
Joins
Fan-out
Row multiplication caused by a one-to-many join.
Causes double-counting if you then SUM the “one” side; aggregate first or use DISTINCT.
Joins
UNION / UNION ALL
Stacks the rows of two result sets vertically.
UNION removes duplicates; UNION ALL keeps them and is cheaper.
Joins
INTERSECT / EXCEPT
Rows common to both queries / rows in the first only.
Set operators; EXCEPT is MINUS in Oracle; limited support in older MySQL.
Subqueries
Subquery
A SELECT nested inside another statement.
Can appear in SELECT, FROM (derived table), WHERE, or HAVING.
Subqueries
Scalar subquery
A subquery that returns exactly one value.
Usable anywhere a single value is expected, e.g. comparison to an overall average.
Subqueries
Correlated subquery
A subquery that references the outer query's current row.
Conceptually re-runs per outer row; powerful but potentially slow — verify with EXPLAIN.
Subqueries
EXISTS / NOT EXISTS
Tests whether a subquery returns at least one row.
NULL-safe and short-circuits; preferred over NOT IN when NULLs are possible.
Subqueries
CTE (WITH)
A named, temporary result defined with WITH, referenced like a table.
Adds readability, not power; Postgres 12+ can inline non-recursive CTEs.
Subqueries
Recursive CTE
A WITH RECURSIVE that references itself to walk hierarchies.
Anchor member + UNION ALL + recursive member; must terminate (guard cycles).
Subqueries
Derived table
A subquery in the FROM clause, used as a temporary table.
Must be aliased; an alternative to a CTE for one-off intermediate results.
Window Functions
Window function
Computes across related rows without collapsing them.
Defined by OVER (PARTITION BY … ORDER BY …); keeps every input row.
Window Functions
OVER
The clause that turns a function into a window function.
Specifies the partition, ordering, and frame the function operates over.
Window Functions
PARTITION BY
Divides rows into groups for a window function.
Like GROUP BY for windows — but the rows survive.
Window Functions
Window frame
The subset of partition rows the function sees (ROWS/RANGE BETWEEN).
Default with ORDER BY is RANGE UNBOUNDED PRECEDING TO CURRENT ROW (a running total).
Window Functions
ROW_NUMBER
Assigns a unique sequential number per partition.
Ties broken arbitrarily; use for “exactly N rows” per group.
Window Functions
RANK / DENSE_RANK
Rank with gaps after ties / rank with no gaps.
RANK: 1,1,3; DENSE_RANK: 1,1,2; choose by tie-handling intent.
Window Functions
NTILE
Splits partition rows into N roughly equal buckets.
E.g. NTILE(4) for quartiles; useful for cohorting.
Window Functions
LAG / LEAD
Reads a previous / next row's value within the partition.
Great for deltas (week-over-week) and next-event retention checks.
Window Functions
Running total
A cumulative sum over an ordering.
SUM(x) OVER (ORDER BY t) with the default frame; specify ROWS for precision.
Window Functions
Moving average
A mean over a sliding window of rows.
AVG(x) OVER (ORDER BY t ROWS BETWEEN 3 PRECEDING AND CURRENT ROW).
Window Functions
Gaps and islands
A pattern for finding runs of consecutive (or missing) values.
Trick: subtract ROW_NUMBER() from the sequence; consecutive rows share a constant key.
Window Functions
CASE
SQL's if/else expression; first true branch wins.
Usable in SELECT/WHERE/ORDER BY and inside aggregates; omitting ELSE yields NULL.
DDL & DML
DDL
Data Definition Language — defines structure.
CREATE, ALTER, DROP, TRUNCATE; usually auto-committing.
DDL & DML
DML
Data Manipulation Language — changes rows.
INSERT, UPDATE, DELETE (and SELECT as DQL); transaction-controlled.
DDL & DML
CREATE TABLE
Defines a new table's columns, types, and constraints.
The primary DDL statement; constraints make it a database, not a spreadsheet.
DDL & DML
ALTER TABLE
Modifies an existing table's structure.
Add/modify/rename/drop columns and constraints; large-table alters may lock or rewrite.
DDL & DML
DROP vs TRUNCATE
Remove a whole table / remove all rows fast keeping structure.
DROP deletes the table; TRUNCATE empties it (faster than unqualified DELETE).
DDL & DML
INSERT
Adds one or more rows to a table.
INSERT INTO t (cols) VALUES (…); can insert from a SELECT.
DDL & DML
UPDATE
Changes values in existing rows.
A missing WHERE updates every row — preview with SELECT, use a transaction.
DDL & DML
DELETE
Removes selected rows.
Row-by-row, logged, transaction-safe; a missing WHERE empties the table.
DDL & DML
UPSERT
Insert, or update if the row already exists.
Postgres ON CONFLICT DO UPDATE; MySQL ON DUPLICATE KEY UPDATE; standard MERGE.
DDL & DML
CHECK / UNIQUE / NOT NULL
Constraints for custom rules, no-duplicates, and required values.
Enforced on every write; e.g. CHECK (total >= 0).
DDL & DML
View
A saved query that behaves like a virtual table.
Simplifies reuse; a materialized view stores results and must be refreshed.
DDL & DML
Materialized view
A view whose results are physically stored for fast reads.
Trades freshness for speed; must be refreshed (full or incremental).
Normalization
Normalization
Structuring tables to reduce redundancy and prevent anomalies.
Goal: every fact in exactly one place; 1NF→2NF→3NF progressively stricter.
Normalization
First normal form (1NF)
Every cell holds a single (atomic) value — no lists or repeating groups.
Fix violations by splitting repeated values into separate rows.
Normalization
Second normal form (2NF)
1NF plus: every non-key column depends on the whole (composite) key.
Relevant only with composite keys; remove partial dependencies.
Normalization
Third normal form (3NF)
2NF plus: non-key columns depend only on the key, not each other.
Remove transitive dependencies; the common OLTP default.
Normalization
Functional dependency
When one column's value determines another's.
The formal basis of normalization rules (e.g. customer_id determines city).
Normalization
Update anomaly
Inconsistency caused by storing the same fact in many rows.
Prevented by normalization; the practical motivation for 3NF.
Normalization
Denormalization
Deliberately introducing redundancy to speed reads.
Common in analytics (star schemas, wide tables); a managed trade-off, not a mistake.
Normalization
OLTP vs OLAP
Transactional (many small writes) vs analytical (large reads) workloads.
OLTP favours 3NF; OLAP often denormalizes for query speed.
Normalization
Star schema
A denormalized analytics design: a fact table joined to dimension tables.
Optimised for reporting; trades normalization for fewer, simpler joins.
Indexing & Optimization
Index
An auxiliary structure for fast row lookup by column value.
Usually a B-tree; speeds reads, slows writes, consumes storage.
Indexing & Optimization
B-tree index
A balanced sorted tree enabling seeks and range scans.
The default index; turns selective lookups into roughly O(log n).
Indexing & Optimization
Composite index
An index on multiple columns, usable left-to-right.
Leftmost-prefix rule: (a,b) helps a or a,b, not b alone.
Indexing & Optimization
Covering index
An index containing every column a query needs.
Enables an index-only scan; defeated by SELECT *.
Indexing & Optimization
Selectivity
How well a predicate narrows rows (the fraction returned).
High selectivity favours index use; low-selectivity filters may prefer a scan.
Indexing & Optimization
Sequential (full) scan
Reading every row of a table.
Sometimes optimal (small tables, low selectivity); a smell on large selective queries.
Indexing & Optimization
Query optimizer / planner
The engine component that chooses an execution plan.
Cost-based, using table statistics; behaviour differs across engines.
Indexing & Optimization
Execution plan
The steps the engine uses to run a query (scans, joins, sorts).
Read it via EXPLAIN; trees execute bottom-up, inner-most first.
Indexing & Optimization
EXPLAIN / EXPLAIN ANALYZE
Show the planned / actually-executed query plan.
ANALYZE runs the query and reports real timings and row counts.
Indexing & Optimization
Statistics
Summaries of data distribution the planner uses to estimate cost.
Stale stats cause bad estimates; refresh with ANALYZE.
Indexing & Optimization
VACUUM
Reclaims space from dead row versions in Postgres MVCC.
autovacuum usually handles it; VACUUM ANALYZE also refreshes stats.
Indexing & Optimization
MVCC
Multi-version concurrency control — readers don't block writers.
Each transaction sees a consistent snapshot; creates dead rows that need vacuuming.
Transactions
Transaction
A group of statements that all succeed or all fail.
BEGIN … COMMIT; ROLLBACK undoes everything since BEGIN.
Transactions
ACID
Atomicity, Consistency, Isolation, Durability.
The four guarantees that make transactions safe for money and inventory.
Transactions
Atomicity
All statements in a transaction commit, or none do.
On error or ROLLBACK, partial changes are reverted.
Transactions
Isolation level
Controls which concurrency anomalies are possible.
READ COMMITTED (Postgres default) to SERIALIZABLE; MySQL defaults to REPEATABLE READ.
Transactions
Read anomalies
Dirty, non-repeatable, and phantom reads prevented at stricter isolation.
Reading uncommitted data / a value that changed / new rows appearing mid-transaction.
Transactions
Deadlock
Two transactions each waiting on a lock the other holds.
The engine aborts one; keep transactions short and access objects in a consistent order.
Programmability
Stored procedure
Executable logic stored in the database that performs actions.
Can manage transactions; dialect-specific (PL/pgSQL, T-SQL, PL/SQL).
Programmability
Function (UDF)
A routine that returns a value and is callable in queries.
Distinct from a procedure; e.g. a scalar or table-valued function.
Programmability
Trigger
Logic that runs automatically in response to INSERT/UPDATE/DELETE.
Accesses NEW/OLD rows; best for auditing/derived data; beware hidden side-effects.
Programmability
Dialect
An engine's specific SQL variant beyond the ANSI standard.
Core queries are portable; functions, LIMIT, upsert, and procedural code differ.
Programmability
ANSI SQL standard
The published SQL specification engines implement (to varying degrees).
Defines core operations; engines extend and occasionally diverge from it.
Reference · sources & further reading

References & Resources

The authoritative documentation, practice platforms, and learning resources behind this course. When a claim depends on engine behaviour, confirm it here first.

Official Documentation
Practice & Interview Preparation
Learning & Tutorials

All examples are constructed for teaching and use no proprietary data. Product, platform, and company names are referenced for educational context only and belong to their respective owners. Where this course states a performance or behavioural claim, it labels it a fact, an inference, or an open question — and the documentation above is the place to verify version-specific specifics.

Reference · study plan

The Five-Month Roadmap

A suggested cadence through the course, what to focus on each month, and quick-reference cheat sheets to keep beside you while you practise.

1
Retrieval Foundations
Month 1 · SELECT · WHERE · ORDER BY

Get the model right before the syntax: tables, types, and especially NULL's three-valued logic. Then drill the four retrieval clauses until they're automatic.

  • Set up a sandbox on day one (Postgres in Docker, or DB Fiddle/SQLZoo) — you can't learn SQL by reading.
  • Practise: Big Countries, Recyclable and Low Fat Products, Find Customer Referee, Article Views I.
  • By month-end you should filter, sort, paginate, and pattern-match without thinking — and never trust row order without ORDER BY.
2
Aggregation & Joins
Month 2 · GROUP BY · JOIN

The heart of analytics. Learn to collapse rows into business numbers, then connect tables by their keys — with a sharp eye for fan-out and the WHERE-vs-HAVING split.

  • Complete the gaming mini-project (revenue per paying user + ARPPU) before moving on.
  • Practise: Combine Two Tables, Customers Who Never Order, Employees Earning More Than Their Managers, Classes More Than 5 Students.
  • Run EXPLAIN on your joins now, even though plans aren't covered until Month 5 — build the habit early.
3
Subqueries & Window Functions
Month 3 · CTE · OVER · PARTITION BY

The skills that separate intermediate from advanced. Master correlated subqueries and EXISTS, refactor to readable CTEs, then unlock ranking, running totals, and gaps-and-islands.

  • Drill the top-N-per-group pattern (rank in a CTE, filter outside) until it's reflexive.
  • Practise: Rank Scores, Department Top Three Salaries, Consecutive Numbers, Game Play Analysis IV, Human Traffic of Stadium.
  • This is the densest month — go slower, and re-derive the date-minus-row-number trick by hand.
4
Database Design & Modification
Month 4 · CREATE · INSERT/UPDATE/DELETE · 1NF–3NF

Shift from querying to building. Design constrained schemas, modify data safely, normalize to 3NF (and know when to denormalize), and add your first indexes.

  • Build a small normalized schema from scratch with PKs, FKs, and CHECK constraints.
  • Practise: Delete Duplicate Emails, Swap Salary; design a 3NF schema for one vertical.
  • Internalise the safety habit: preview destructive DML with a matching SELECT, inside a transaction.
5
Optimization & Portfolio
Month 5 · EXPLAIN · transactions · capstone

Make it fast and correct, then prove it. Read execution plans, index strategically, guarantee correctness with transactions, and ship a portfolio-grade capstone.

  • Do a real before/after EXPLAIN ANALYZE on your slowest query — this is the centrepiece of the capstone.
  • Build the capstone in a Git repo with a clear README, schema diagram, and 8+ analytical queries.
  • State results as measured facts vs inferences, and note where they won't generalise across engines.
Cheat Sheets & Quick References
Keep these beside you while practising

JOIN decision guide. Want only matching rows → INNER. Want all of the left table, matches where they exist → LEFT. Want rows with no match → LEFT JOIN … WHERE right.key IS NULL (anti-join). Relating a table to itself → self-join with two aliases. Forgot the ON? → accidental Cartesian product.

WHERE vs HAVING. Filter raw rows → WHERE (before grouping). Filter group totals (SUM, COUNT) → HAVING (after grouping). Logical order: FROMWHEREGROUP BYHAVINGSELECTORDER BYLIMIT.

Window ranking on ties. Exactly N rows → ROW_NUMBER. Same rank, skip after ties (1,1,3) → RANK. Same rank, no gaps (1,1,2) → DENSE_RANK. Top-N per group → rank in a CTE, then WHERE rnk <= N (windows can't go in WHERE).

Normalization in one line. 1NF: atomic cells (no lists). 2NF: no partial-key dependencies. 3NF: no transitive dependencies (non-key columns depend only on the key). Spirit: every fact in exactly one place. Denormalize deliberately for read-heavy analytics.

Optimization checklist. (1) Find the slow query. (2) EXPLAIN ANALYZE it — look for Seq Scan on big tables and estimate-vs-actual gaps. (3) Hypothesise (missing index? stale stats → ANALYZE? fan-out?). (4) Change one thing. (5) Re-measure. Index columns you filter/join on; avoid SELECT * so covering indexes can work.

LeetCode ladder. Easy (Months 1–2): Big Countries, Combine Two Tables, Customers Who Never Order, Classes More Than 5 Students. Medium (Month 3): Rank Scores, Consecutive Numbers, Exchange Seats, Game Play Analysis IV. Hard (Month 3+): Department Top Three Salaries, Human Traffic of Stadium, Find Median Given Frequency of Numbers.