Break
SQL
Nano quiz ↗
press b or esc to carry on
M1

Spotify Database

The course runs on three tables: Users, Songs, and Listens.

2 figures
spotify-db · The Three Tables

The Spotify schema: Users, Songs, and Listens, joined by two foreign keys

The Spotify schema: Users, Songs, and Listens, joined by two foreign keysThree tables shown together. Users has a primary key user_id; Songs has a primary key song_id; Listens has a primary key listen_id and two foreign keys, user_id pointing at Users.user_id and song_id pointing at Songs.song_id. One row is decoded first: listen 8 means Daffy (user 3) played Willow (song 2) and rated it 4.9. Then two links are drawn value to value: a box round the 3 in that row's user_id, an arrow to a box round the 3 in Users, and a box round the 2 in its song_id, an arrow to a box round the 2 in Songs. Color key: blue PK and FK badges mark the keys, per-user identity bars color the rows by user.One schema: three tables, two keys tie them togetherEvery query in the course is a question over these. The IDs are how they connect.Users (4 rows)user_idPKnameemail1Mickeymickey@ex.com2Minnieminnie@ex.com3Daffydaffy@ex.com4Plutopluto@ex.comPK = primary keyuser_id is one unique value per user,like a student ID or a social securitynumber. No two users share it.Songs (10 rows)song_idPKtitleartistgenre1EvermoreTaylor SwiftPop2WillowTaylor SwiftPop3Shape of YouEd SheeranRock4PhotographEd SheeranRock5ShiversEd SheeranRock6YesterdayBeatlesClassic7Yellow SubBeatlesClassic8Hey JudeBeatlesClassic9Bad BloodTaylor SwiftRock10DJ MixDJNULLListens (9 rows)listen_idPKuser_idFKsong_idFKratinglisten_time1114.52024-08-302124.2NULL3163.92024-08-294224.7NULL5274.62024-08-286283.92024-08-277312.9NULL8324.92024-08-26936NULLNULLReading one rowlisten 8: user_id 3, song_id 2,rating 4.9Daffy played Willow,and rated it 4.9.user_id = Users.user_idsong_id = Songs.song_idFK = foreign keyuser_id and song_id are foreign keys.Each must match a real id in Users orSongs, so a listen can't point at nobody.
The whole schema on one page. Each table has a primary key (blue PK): user_id, song_id, listen_id. Listens carries two foreign keys (blue FK) that point back at the other tables, so one Listens row reads as a sentence: listen 8 is Daffy (user 3) playing Willow (song 2), rated 4.9. Every query in the course joins these three tables along those IDs.
Notes ↗
spotify-db · How the Schema Is Defined

Reading SQL DDL: how the Listens table is defined

Reading SQL DDL: how the Listens table is definedThe CREATE TABLE listens statement, annotated. Each column names a type: INT for the integer ids, DECIMAL(2,1) for a rating like 4.5, TIMESTAMP for the time. PRIMARY KEY marks listen_id as the unique row id; NOT NULL marks a column required, and rating and listen_time omit it so they may be NULL. Two FOREIGN KEY ... REFERENCES clauses tie user_id to a real Users row and song_id to a real Songs row, so the database rejects a listen that points at a user or song that does not exist. Blue annotations in the margin label the type, the primary key, and the foreign keys.Reading the DDL: how the Listens table is definedDDL, the Data Definition Language: CREATE TABLE names each column, types it, and states its rules.CREATETABLElistens(listen_idINTPRIMARYKEY,user_idINTNOTNULL,song_idINTNOTNULL,ratingDECIMAL(2,1),listen_timeTIMESTAMP,FOREIGNKEY(user_id)REFERENCESusers(user_id),FOREIGNKEY(song_id)REFERENCESsongs(song_id));PRIMARY KEYlisten_id is the unique id for the row.NOT NULL = required; rating / listen_timeomit it, so those may be NULL.the column's typeDECIMAL(2,1) holds a rating like 4.5.INT, TIMESTAMP - every column names one.FOREIGN KEY ... REFERENCESevery user_id must be a real Users row,every song_id a real Songs row. The DBrejects a listen that points at nobody.Users and Songs are defined the same way, just without the foreign keys.
CREATE TABLE assigns each column a name, a type (INT, DECIMAL(2,1), TIMESTAMP), and constraints. PRIMARY KEY selects the unique row identifier. NOT NULL requires a value. FOREIGN KEY ... REFERENCES constrains user_id and song_id to values that exist in the parent table.
Notes ↗
M1

SELECT-FROM-WHERE: The SQL Foundation

A basic query has three clauses.

3 figures
select-from-where

SELECT-FROM-WHERE keeps some rows, then some columns

SELECT-FROM-WHERE keeps some rows, then some columnsFROM Listens loads nine rows with columns listen_id, user_id, song_id, rating, colored by user. WHERE rating greater than 4.0 strikes the four failing rows (3.9, 3.9, 2.9, and the NULL which is UNKNOWN). SELECT user_id, song_id, rating projects the five survivors to three columns, dropping listen_id. Color key: Mickey blue, Minnie orange, Daffy purple; struck rows fail WHERE.SELECT-FROM-WHERE: keep some rows, then some columnsExecution order: FROM loads rows, WHERE filters rows, SELECT picks columns.FROM Listens9 rowslisten_iduser_idsong_idrating1114.52124.23163.94224.75274.66283.97312.98324.9936NULLWHERE rating > 4.0keep 5 rows,then 3 columnsSELECT user_id, song_id, rating5 rowsuser_idsong_idrating114.5124.2224.7274.6324.9rows survive WHERE, then SELECT drops listen_id
FROM produces nine Listens rows. WHERE keeps the five rows where rating > 4.0 evaluates to TRUE and drops rows where it evaluates to FALSE or UNKNOWN, including the NULL rating. SELECT returns only user_id, song_id, and rating and drops listen_id.
Notes ↗
select-from-where · WHERE with AND and OR

AND keeps a row only when both conditions hold; OR when either does

AND keeps a row only when both conditions hold; OR when either doesTwo queries side by side on the same nine rows. Left: WHERE user_id = 1 AND rating > 4.0 keeps only rows where both are true, two rows (Mickey 4.5 and 4.2); the rest are struck out. Right: WHERE user_id = 1 OR rating > 4.0 keeps a row when either is true, six rows. The NULL rating makes rating > 4.0 UNKNOWN, so that row is dropped by both. Color key: Mickey blue, Minnie orange, Daffy purple; green check keeps, grey strike drops.WHERE with AND and OR: same two conditions, opposite strictnessKeep a row only when BOTH holdSELECT user_id, song_id, ratingFROM ListensWHERE user_id = 1 AND rating > 4.0user_idrating14.5✓ keep14.2✓ keep13.9fails WHERE24.7fails WHERE24.6fails WHERE23.9fails WHERE32.9fails WHERE34.9fails WHERE3NULLfails WHEREAND keeps 2 rowsKeep a row when EITHER holdsSELECT user_id, song_id, ratingFROM ListensWHERE user_id = 1 OR rating > 4.0user_idrating14.5✓ keep14.2✓ keep13.9✓ keep24.7✓ keep24.6✓ keep23.9fails WHERE32.9fails WHERE34.9✓ keep3NULLfails WHEREOR keeps 6 rows
AND keeps a row when both predicates evaluate to TRUE, so two rows survive. OR keeps a row when either predicate evaluates to TRUE, so six rows survive. The NULL rating makes rating > 4.0 evaluate to UNKNOWN, and UNKNOWN does not satisfy WHERE.
Notes ↗
select-from-where · Understanding Aliases (AS Keyword)

An alias renames the output column, but WHERE cannot use it

An alias renames the output column, but WHERE cannot use itAS renames a column in the result: rating * 2 AS double_rating, so 4.5 shows as 9.0. Then the same filter written two ways: WHERE double_rating > 8 fails, because no column of that name exists yet, and WHERE rating * 2 > 8 works, because the expression already exists. Last, the reason: execution order runs FROM, then WHERE, then SELECT, and the alias is created in SELECT, after WHERE has finished. Color key: green is the alias and the working query, red is the failing reference.Aliases (AS): rename a column, but WHERE can't use the new nameAS renames the output columnratingrating * 2( = double_rating )4.59.04.28.43.97.8FailsSELECT rating * 2 AS double_ratingFROM ListensWHERE double_rating > 8✗ ERROR: no column named double_rating yetWorksSELECT rating * 2 AS double_ratingFROM ListensWHERE rating * 2 > 8✓ WHERE uses the expression, which already existsExecution order: the alias is created last1FROM Listens2WHERE …3SELECT … AS double_ratingdouble_rating is born HERE (3rd)WHERE runs 2nd, before the alias exists
AS names an expression in the result produced by SELECT. WHERE runs earlier, so WHERE double_rating > 8 fails. WHERE can evaluate WHERE rating * 2 > 8 because it uses the underlying expression.
Notes ↗
M1

NULL Values: The Unknown in SQL

NULL means "unknown," not 0 or empty.

6 figures
nulls

NULL adds a third truth value, UNKNOWN

NULL adds a third truth value, UNKNOWNBinary logic: 4.5 > 4.0 is TRUE, 4.5 > 5.0 is FALSE. But any comparison with NULL (4.5 > NULL, NULL = NULL, rating = NULL) is UNKNOWN, the third truth value, shown grey with a question mark. WHERE keeps only TRUE rows, so UNKNOWN and NULL drop silently; rating IS NULL is the only test that returns a real TRUE or FALSE and finds NULL rows. Color key: green TRUE, red FALSE, grey question mark UNKNOWN.NULL adds a third truth value: UNKNOWNNULL is not zero and not empty. It means unknown, and it spreads.expressionresultBinary logic: every test is TRUE or FALSE4.5 > 4.0TRUE4.5 > 5.0FALSEAdd NULL, a missing value: every comparison with it is UNKNOWN, a third truth value.4.5 > NULL?UNKNOWNNULL = NULL?UNKNOWNrating = NULL?UNKNOWNWHERE keeps only TRUE rows, so UNKNOWN (and NULL) drop silently.rating IS NULL is the only test that gives a real TRUE/FALSE and finds them.
SQL uses three-valued logic. Comparisons with NULL evaluate to UNKNOWN. WHERE keeps TRUE and drops FALSE and UNKNOWN.
Notes ↗
nulls · Two-Valued Logic Refresher

Two-valued boolean logic: AND, OR, NOT truth tables

Two-valued boolean logic: AND, OR, NOT truth tablesTwo-valued boolean logic: AND, OR, NOT truth tables Color key: green TRUE, red FALSE, grey UNKNOWN/NULL.Two-valued logic: every test is TRUE or FALSEThe refresher before NULL: AND needs both, OR needs one, NOT flips.a AND babANDTTTTFFFTFFFFa OR babORTTTTFTFTTFFFNOT aaNOTTFFT
Two-valued boolean logic. AND requires TRUE on both sides. OR requires TRUE on at least one side. NOT flips the value.
Notes ↗
nulls · What Changes When NULL Enters

NULL's rules in two groups: operators vs comparisons

NULL's rules in two groups: operators vs comparisonsNULL's rules in two groups: operators vs comparisons Color key: green TRUE, red FALSE, grey NULL/UNKNOWN.NULL's rules, in two groupsOperators have a dominant value; comparisons all go UNKNOWN; only IS NULL escapes.Operators: a value dominatesexpressionresultFALSE AND NULLFALSEfalse winsTRUE AND NULLNULLTRUE OR NULLTRUEtrue winsFALSE OR NULLNULLNOT NULLNULLNULL staysComparisons go UNKNOWN; IS NULL is the escapeexpressionresult4.5 > NULLNULLNULL wins4 = NULLNULLNULL = NULLNULLrating IS NULLTRUE / FALSEthe real test
Boolean operators sometimes return a definite value with NULL. Comparisons with NULL return UNKNOWN. IS NULL returns TRUE or FALSE.
Notes ↗
nulls · NULL with AND/OR Logic

One NULL row: TRUE AND UNKNOWN drops it, TRUE OR UNKNOWN keeps it

One NULL row: TRUE AND UNKNOWN drops it, TRUE OR UNKNOWN keeps itOne NULL row: TRUE AND UNKNOWN drops it, TRUE OR UNKNOWN keeps it Color key: green TRUE, red FALSE, grey NULL/UNKNOWN.Same row, two operators, two fatesDaffy's row, run through AND on the left and OR on the right. The operator decides whether the NULL survives.ANDSELECTnameFROMListensWHEREuser_id=3ANDrating>4ORSELECTnameFROMListensWHEREuser_id=3ORrating>4user_id = 3 ?TRUErating > 4 ?UNKNOWNuser_id = 3 ?TRUErating > 4 ?UNKNOWNTRUE AND UNKNOWN = UNKNOWNDaffy user_id = 3 rating = NULLrow droppedTRUE OR UNKNOWN = TRUEDaffy user_id = 3 rating = NULLrow kept
One row, two operators. Daffy's user_id = 3 is TRUE but rating > 4 is UNKNOWN. Under AND the UNKNOWN drops the row; under OR the TRUE keeps it. The operator decides whether a NULL survives.
Notes ↗
nulls · NULL in WHERE Clause

The same NULL row across three WHERE filters

The same NULL row across three WHERE filtersThe same NULL row across three WHERE filters Color key: green TRUE, red FALSE, grey UNKNOWN/NULL.The NULL row: kept only by IS NULL, dropped by every comparisonQuery 1SELECT listen_id, ratingFROM ListensWHERE rating IS NULLlisten_idrating9NULL1 row: IS NULL keeps itQuery 2SELECT listen_id, ratingFROM ListensWHERE rating > 4.0listen_idrating14.524.244.754.684.95 rows: NULL droppedQuery 3SELECT listen_id, ratingFROM ListensWHERE rating <= 4.0listen_idrating33.963.972.93 rows: NULL dropped againRow 9 is NULL. Only IS NULL (rule 5) returns TRUE; > and <= return UNKNOWN, which WHERE drops.
One NULL row, three filters. IS NULL (rule 5) returns TRUE and keeps row 9; both rating > 4.0 and rating <= 4.0 evaluate to UNKNOWN on a NULL, and WHERE keeps only TRUE rows, so the NULL is gone from both.
Notes ↗
nulls · NULL in Aggregates

COUNT(*)=9 counts all; COUNT(rating)=8 and AVG=4.2 skip the NULL

COUNT(*)=9 counts all; COUNT(rating)=8 and AVG=4.2 skip the NULLCOUNT(*)=9 counts all; COUNT(rating)=8 and AVG=4.2 skip the NULL Color key: green TRUE, red FALSE, grey NULL/UNKNOWN.NULL and aggregates: counted once, skipped twiceCOUNT(*) counts every row; COUNT(rating) and AVG skip the NULL.Listens.ratingrating4.54.23.94.74.63.92.94.9NULLCOUNT(*)counts every row, including the NULL9COUNT(rating)skips the NULL: 8 ratings, not 9 rows8AVG(rating)33.6 ÷ 8 = 4.2 (8 ratings, not 9 rows)4.2
COUNT(*) counts every row, so it returns 9. COUNT(rating) counts non-NULL values, so it returns 8. AVG(rating) divides by that same non-NULL count of 8, not 9.
Notes ↗
M1

GROUP BY: Grouping and Aggregating Data

GROUP BY partitions the input rows by the named column values and produces one output row per partition.

5 figures
groupby

GROUP BY collapses a nine-row Listens table into a three-row grouped table

GROUP BY collapses a nine-row Listens table into a three-row grouped tableA nine-row Listens table (user_id, song_id, rating), colour-barred by user, is grouped by user_id into a three-row table of user_id, COUNT and AVG.Listens9 rowsuser_idsong_idrating114.5124.2163.9224.7274.6283.9312.9324.936NULLGROUP BY user_idGrouped by user_id3 rowsuser_idCOUNT(*)AVG134.2234.4333.9
GROUP BY produces one output row per group: nine listen rows become three rows keyed by user_id, each row carries aggregate values. Nine rows, three groups, three output rows.
Notes ↗
groupby · Simple GROUP BY

GROUP BY user_id: nine listens collapse into three buckets, one row each

GROUP BY user_id: nine listens collapse into three buckets, one row eachGROUP BY user_id collapses nine Listens rows into three buckets, one per user, each with COUNT of three. Pluto has no listens, so Pluto gets no bucket and no output row. Color key: per-user identity bars (Mickey blue, Minnie orange, Daffy purple, Pluto cyan).GROUP BY user_id: same user_id falls in one bucketNine listens collapse into three buckets, one row each.SELECTuser_id,COUNT(*)ASlisten_countFROMListensGROUPBYuser_idListens (9 rows, bucketed by user)the inputlisten_iduser_idsong_idrating1114.52124.23163.94224.75274.66283.97312.98324.9936NULLGROUP BYGrouped by user_id (3 rows)one row per bucketuser_idlisten_count132333Pluto: 0 listens, so no bucket and no row.
The nine input listens (left, bucketed by user) collapse into the three-row output (right): one row per bucket, COUNT(*) = 3 each. Pluto has zero listens, so Pluto forms no bucket and appears in no output row.
Notes ↗
groupby · GROUP BY with Multiple Aggregates

GROUP BY user_id with four aggregates per group: COUNT, AVG, MAX, MIN

GROUP BY user_id with four aggregates per group: COUNT, AVG, MAX, MINNine listens grouped by user into three rows, each carrying COUNT, AVG, MAX and MIN of the ratings. COUNT counts the rows; AVG, MAX and MIN ignore NULL ratings.Listens9 rowsuser_idsong_idrating114.5124.2163.9224.7274.6283.9312.9324.936NULLGROUP BY user_idGrouped by user_id3 rows123COUNT333AVG4.24.43.9MAX4.54.74.9MIN3.93.92.9
Notes ↗
groupby · GROUP BY Multiple Columns

Multi-column GROUP BY splits one bucket into finer buckets

Multi-column GROUP BY splits one bucket into finer bucketsBy user_id, Mickey is one bucket of three rows with COUNT three. Adding song_id splits it into three buckets of one row each, COUNT one.More columns, finer bucketsMickey's three listens: one bucket by user_id; add song_id and the bucket splits into three.GROUP BY user_idMickeyuser_id = 1song_idrating14.524.263.91 bucketCOUNT(*) = 3+ song_idGROUP BY user_id, song_id(1, 1)rating 4.5COUNT(*) = 1(1, 2)rating 4.2COUNT(*) = 1(1, 6)rating 3.9COUNT(*) = 13 buckets
The group key consists of the listed columns. GROUP BY user_id forms one group for Mickey with three rows. GROUP BY user_id, song_id forms three groups for Mickey, one per (user, song) pair, and each group contains one row.
Notes ↗
groupby · Common Mistakes

A grouped row cannot show a column you did not group by

A grouped row cannot show a column you did not group byGROUP BY user_id collapses each user's listens to a single row. The query also asks for song_id, which was not grouped, so it shows a grey question mark: Mickey's row collapsed songs 1, 2 and 6, and the database cannot pick one. Margin notes list the candidate songs. Color key: grey question mark marks the unresolved column, per-user identity bars color the rows.Ask for a column you did not group by, and the row cannot answerGROUP BY user_id makes one row per user. But song_id had three values in that group.SELECTuser_id,song_id,COUNT(*)FROMListensGROUPBYuser_idAfter GROUP BY user_id: one row per useruser_idCOUNT(*)132333song_id?collapsed songs 1, 2, 6 - which one??collapsed songs 2, 7, 8 - which one??collapsed songs 1, 2, 6 - which one?song_id was never grouped, so it has no single valueEvery column you SELECT must be grouped or aggregated.
Notes ↗
M1

HAVING: Filtering Group Aggregates

HAVING filters groups after GROUP BY by evaluating aggregate expressions such as COUNT and AVG.

4 figures
having

An average rating filter on groups: HAVING tests each group's AVG(rati

An average rating filter on groups: HAVING tests each group's AVG(ratiAn average rating filter on groups: HAVING tests each group's AVG(rating); Mickey 4.2 and Minnie 4.4 pass, Daffy 3.9 is below 4.0 and the whole group drops. Color key: Mickey blue, Minnie orange, Daffy purple; struck rows/groups are filtered out.HAVING filters GROUPS (after GROUP BY)SELECT user_id, COUNT(*), AVG(rating)FROM ListensGROUP BY user_idHAVING AVG(rating) > 4.0GROUP BY user_id -> one row per user3 groupsuser_idCOUNT(*)AVG(rating)134.2234.4333.94.2 > 4.0 keep4.4 > 4.0 keep3.9 > 4.0 failsOutput: Mickey, Minnie (Daffy's 3.9 is below 4.0)
HAVING is the gatekeeper on groups. Groups form with their aggregates, HAVING tests each one, and a group either passes whole or drops whole. Three groups enter, two survive.
Notes ↗
having · HAVING with COUNT

HAVING COUNT(*) >= 2 on listens grouped by song_id: songs 1, 2 and 6 w

HAVING COUNT(*) >= 2 on listens grouped by song_id: songs 1, 2 and 6 wHAVING COUNT(*) >= 2 on listens grouped by song_id: songs 1, 2 and 6 were played at least twice and stay; songs 7 and 8 were played once and drop. Color key: Mickey blue, Minnie orange, Daffy purple; struck rows/groups are filtered out.HAVING with COUNT: keep only songs played twice or moreSELECT song_id, COUNT(*), AVG(rating)FROM ListensGROUP BY song_idHAVING COUNT(*) >= 2GROUP BY song_id -> one row per song5 groupssong_idCOUNT(*)AVG(rating)123.7234.6623.9714.6813.92 >= 2 keep3 >= 2 keep2 >= 2 keep1 >= 2 fails1 >= 2 failsOutput: songs 1, 2, 6 (played at least twice)
Grouping listens by song_id and keeping only groups with COUNT(*) >= 2. Songs 1, 2 and 6 were played at least twice; songs 7 and 8 were played once, so they drop.
Notes ↗
having · HAVING with Multiple Conditions

HAVING COUNT(*) >= 3 AND AVG(rating) > 4.0: both conditions must hold.

HAVING COUNT(*) >= 3 AND AVG(rating) > 4.0: both conditions must hold.HAVING COUNT(*) >= 3 AND AVG(rating) > 4.0: both conditions must hold. Mickey and Minnie pass both; Daffy has three listens but his 3.9 average fails the rating test, so his whole group drops. Color key: Mickey blue, Minnie orange, Daffy purple; struck rows/groups are filtered out.HAVING with AND: a group must pass BOTH conditionsSELECT user_id, COUNT(*), AVG(rating), MIN(rating)FROM ListensGROUP BY user_idHAVING COUNT(*) >= 3 AND AVG(rating) > 4.0GROUP BY user_id -> one row per user3 groupsuser_idCOUNT(*)AVG(rating)MIN(rating)134.23.9234.43.9333.92.9both pass keepboth pass keepAVG 3.9 not > 4.0 failsOutput: Mickey, Minnie (Daffy's 3.9 average fails the second test)
Two HAVING conditions joined by AND: a group survives only if it passes both. Mickey and Minnie clear COUNT(*) >= 3 and AVG(rating) > 4.0; Daffy has three listens but his 3.9 average fails the second test.
Notes ↗
having · WHERE and HAVING

WHERE rating > 3.5 strikes Daffy's 2.9 and NULL first; the survivors g

WHERE rating > 3.5 strikes Daffy's 2.9 and NULL first; the survivors gWHERE rating > 3.5 strikes Daffy's 2.9 and NULL first; the survivors group into Mickey 3, Minnie 3, Daffy 1; HAVING COUNT(*) >= 2 then drops Daffy's one-row group. Color key: Mickey blue, Minnie orange, Daffy purple; struck rows/groups are filtered out.WHERE filters rows, then HAVING filters groupsSELECT user_id, COUNT(*), AVG(rating)FROM ListensWHERE rating > 3.5GROUP BY user_idHAVING COUNT(*) >= 2WHERE rating > 3.59 -> 7 rowsuser_idrating14.514.213.924.724.623.932.934.93NULLGROUPGROUP BY -> HAVING COUNT(*) >= 23 -> 2user_idCOUNT(*)1323313 >= 2 keep3 >= 2 keep1 >= 2 failsOutput: Mickey, Minnie
WHERE and HAVING run at different times. WHERE strikes individual rows before grouping (Daffy's 2.9 and NULL go), the survivors form groups, then HAVING drops whole groups by aggregate (Daffy's single row fails COUNT(*) >= 2). WHERE picks rows, HAVING picks groups.
Notes ↗
M1

SQL Sets and Set Operations

Set operators combine two query results.

1 figure
sets · SQL Set Operations Overview

The four set operations as row tables

The four set operations as row tablesTwo query results: A is Mickey and Minnie, B is Mickey and Daffy; Mickey (teal) is in both, Minnie (blue) only in A, Daffy (violet) only in B. UNION returns the three distinct users; UNION ALL keeps the duplicate Mickey for four rows; INTERSECT returns only Mickey (in both); EXCEPT (A minus B) returns only Minnie. Color key: teal in both, blue only A, violet only B, amber the kept duplicate.Four set operations on two query resultsCombine A and B four ways. Color = membership: teal both, blue only A, violet only B.Query Ausers who like PopnameMickeyMinnieQuery Busers who like RocknameMickeyDaffycombine the two results four ways:UNIONeither side, dedupednameMickeyMinnieDaffyUNION ALLeither side, keep dupsnameMickeyMinnieMickeyDaffyMickey appears twiceINTERSECTin BOTH A and BnameMickeyEXCEPTin A, not in BnameMinnie
Two example user sets, with the result of each set operator.
Notes ↗
M1

JOINs: Combining Tables

A JOIN pairs rows from two tables when a predicate in ON evaluates to true.

5 figures
joins

Users joined to Listens on user_id

Users joined to Listens on user_idA Users table and a Listens table are matched on user_id. Mickey, Minnie and Daffy each pair with three listens, giving nine inner-join rows. Pluto has no listens, so the inner join drops him.SELECT u.name, l.song_id, l.ratingFROM Users uJOIN Listens l ON u.user_id = l.user_idORDER BY u.user_idUsers4 rowsuser_idname1Mickey2Minnie3Daffy4PlutoListens9 rowsuser_idsong_idrating114.5124.2163.9224.7274.6283.9312.9324.936NULLonno matchUsers ⋈ Listens9 rowsuser_idnamesong_idrating1Mickey14.51Mickey24.21Mickey63.92Minnie24.72Minnie74.62Minnie83.93Daffy12.93Daffy24.93Daffy6NULLJOINON user_idINNER JOIN → matched pairs only · 9 rows
Users stores user names; Listens stores plays. A join matches each Users row with Listens rows that share the same user_id. Mickey has 3 rows in Listens, so the join outputs 3 rows for Mickey, one per play. INNER JOIN outputs only matched pairs, so Pluto contributes no rows: 9 rows.
Notes ↗
joins · Outer Joins: LEFT and RIGHT

Users joined to Listens on user_id; LEFT JOIN keeps the unmatched Pluto

Users joined to Listens on user_id; LEFT JOIN keeps the unmatched PlutoA Users table and a Listens table are matched on user_id. Mickey, Minnie and Daffy each pair with three listens, giving nine inner-join rows. Pluto has no listens and no match: INNER JOIN drops him, while LEFT JOIN keeps him as a tenth row with NULL song_id and rating.SELECT u.name, l.song_id, l.ratingFROM Users uLEFT JOIN Listens l ON u.user_id = l.user_idORDER BY u.user_idUsers4 rowsuser_idname1Mickey2Minnie3Daffy4PlutoListens9 rowsuser_idsong_idrating114.5124.2163.9224.7274.6283.9312.9324.936NULLonno matchUsers ⋈ ListensINNER 9 · LEFT 10user_idnamesong_idrating1Mickey14.51Mickey24.21Mickey63.92Minnie24.72Minnie74.62Minnie83.93Daffy12.93Daffy24.93Daffy6NULLJOINON user_idINNER JOIN → matched pairs only · 9 rows4PlutoNULLNULLLEFT JOIN → keeps Pluto, song_id / rating NULL-filled · 10 rows
Notes ↗
joins · Outer Joins: LEFT and RIGHT

Songs joined to Listens on song_id; RIGHT JOIN keeps the five unplayed songs

Songs joined to Listens on song_id; RIGHT JOIN keeps the five unplayed songsA Songs table and a Listens table are matched on song_id. Five songs (Shape of You, Photograph, Shivers, Bad Blood, DJ Mix) were never played. INNER JOIN returns the nine played-song rows; RIGHT JOIN keeps all ten songs, adding the five unplayed ones NULL-filled, for fourteen rows.SELECT s.title, l.ratingFROM Songs sRIGHT JOIN Listens l ON s.song_id = l.song_idSongs10 rowssong_idtitle1Evermore2Willow3Shape of You4Photograph5Shivers6Yesterday7Yellow Submarine8Hey Jude9Bad Blood10DJ MixListens9 rowssong_idrating14.512.924.224.724.963.96NULL74.683.9onno matchno matchno matchno matchno matchSongs ⋈ ListensINNER 9 · RIGHT 14song_idtitlerating1Evermore4.51Evermore2.92Willow4.22Willow4.72Willow4.96Yesterday3.96YesterdayNULL7Yellow Submarine4.68Hey Jude3.9RIGHTJOINON song_idINNER JOIN → only songs with a listen · 9 rows3Shape of YouNULL4PhotographNULL5ShiversNULL9Bad BloodNULL10DJ MixNULLRIGHT JOIN → keeps the 5 songs nobody played, NULL-filled · 14 rows
Five songs have no matching listens. INNER JOIN excludes those songs. RIGHT JOIN keeps every song and fills the listen columns with NULL for the five songs with no plays. The preserved unmatched side changes when the query swaps LEFT and RIGHT.
Notes ↗
joins · Foreign Keys

A foreign key turns one outer join into an inner join

A foreign key turns one outer join into an inner joinListens.user_id is a foreign key referencing Users, so every listen has a real user; only Pluto, a user with no listen, is unmatched. Two statements follow: for Users JOIN Listens, RIGHT JOIN equals INNER JOIN (only LEFT JOIN adds Pluto); for Listens JOIN Users, LEFT JOIN equals INNER JOIN (only RIGHT JOIN adds Pluto). The rule: keeping the Listens side equals the inner join, keeping the Users side surfaces the orphan Pluto. Songs and song_id behave the same way.A foreign key turns one outer join into a plain INNER joinReason it through from one fact about the key.ListensUsersuser_idListens.user_id is a foreign key: every listen has a real user.The only unmatched row is Pluto, a user with no listen.Users JOIN ListensRIGHT JOIN = INNER JOINevery listen has a user, so keeping allthe listens adds nothing new.only LEFT JOIN adds PlutoListens JOIN UsersLEFT JOIN = INNER JOINevery listen has a user, so keeping allthe listens adds nothing new.only RIGHT JOIN adds PlutoKeep the Listens side → the outer join is just INNER.Keep the Users side → Pluto, the orphan, appears. (Songs and song_id work exactly the same way.)
Notes ↗
joins · Common Mistakes

Forgetting the ON clause makes a cartesian product

Forgetting the ON clause makes a cartesian productA 4 by 9 grid pairs every one of four users with every one of nine listens, giving 36 rows. Only nine cells, where the user matches the listen owner, are real matches; the other 27 are meaningless. Adding ON user_id keeps only the nine.Forget the ON clause: every row paired with every rowUsersListens are coloured by who played themMickeyMinnieDaffyPlutoL1L2L3L4L5L6L7L8L94 users × 9 listens = 36 rows.Only the 9 green cells match on user_id; the other 27 are meaningless. The fix is one clause: ON u.user_id = l.user_id.
Notes ↗
M1

Subqueries: Basic Patterns

A subquery is a SELECT nested inside another query.

4 figures
subqueries-basic

A subquery nests queries inside queries, read inside-out

A subquery nests queries inside queries, read inside-outThree concentric boxes. The innermost finds Taylor Swift songs (1, 2, 9); the middle finds the users who played them (1, 2, 3); the outer looks up their names (Mickey, Minnie, Daffy). Beside them, the three flavors of subquery, IN/NOT IN, EXISTS and Scalar, shown as equal peers.Subqueries: queries inside queriesRead inside-out: the innermost query runs first and feeds the next, layer by layer.3Look up their namesMickey, Minnie, Daffy2Find users who played them{1, 2, 3}1Find Taylor Swift songs{1, 2, 9}Three flavorsIN / NOT INis a value in the inner list?EXISTSdoes the inner query return any row?Scalarcompare against one inner value
A subquery is a query inside a query, read inside-out: the innermost finds Taylor Swift songs, the middle finds who played them, the outer returns their names. The same nesting drives IN / NOT IN, EXISTS, and Scalar.
Notes ↗
subqueries-basic · IN Subquery: Find Taylor Swift Listeners

Walking an IN subquery inside-out across three steps

Walking an IN subquery inside-out across three stepsStep 1 filters Songs to Taylor Swift, leaving song_ids 1, 2, 9. Step 2 finds the listens on those songs and takes the distinct users 1, 2, 3. Step 3 looks those users up in Users, giving Mickey, Minnie, Daffy.IN subquery: walk the data inside-out1. Songs WHERE artist = 'Taylor Swift'10 → 3song_idartist1Taylor Swift2Taylor Swift3Ed Sheeran4Ed Sheeran5Ed Sheeran6Beatles7Beatles8Beatles9Taylor Swift10DJ{1, 2, 9}feeds2. Listens on those songsdistinctuser_idsong_id1112223132{1, 2, 3}feeds3. Users for {1, 2, 3}3 rowsnameuser_idMickey1Minnie2Daffy3Result: Mickey, Minnie, Daffy
Notes ↗
subqueries-basic · Scalar Subquery: Above Average Ratings

A scalar subquery returns one value that the outer query reuses for every row

A scalar subquery returns one value that the outer query reuses for every rowThe inner subquery runs once: AVG(rating) over all Listens, skipping the one NULL, gives 4.2 (blue, the single value). The outer query reuses 4.2 to keep every listen rated above it: Mickey 4.5, Minnie 4.7 and 4.6, Daffy 4.9 are kept; Mickey 4.2 is not strictly greater and Daffy's NULL is UNKNOWN, both struck out, leaving 4 rows. Color key: blue = the one scalar value; the per-user bars are identity; grey strike = a dropped row.Scalar subquery: one value, reused for every rowSELECT u.name, l.ratingFROM Users uJOIN Listens l ON u.user_id = l.user_idWHERE l.rating > (SELECT AVG(rating) FROM Listens)Inner subquery, runs onceAVG(rating) over all Listensthe one NULL rating is skipped (8 ratings)= 4.2one value,every rowOuter query: keep l.rating > 4.2namerating> 4.2 ?Mickey4.5Mickey4.2Minnie4.7Minnie4.6Daffy4.9DaffyNULL? UNKNOWN4 rows kept
A scalar subquery returns one value. The inner query computes AVG(rating) over Listens and skips the NULL, producing 4.2. The outer query compares each row's rating to 4.2 and keeps four rows. Mickey's 4.2 fails a strict > comparison, and Daffy's NULL > 4.2 evaluates to UNKNOWN, so both rows drop.
Notes ↗
subqueries-basic · Common Mistakes

A NULL inside NOT IN silently returns zero rows; the IS NOT NULL guard fixes it

A NULL inside NOT IN silently returns zero rows; the IS NOT NULL guard fixes itTwo columns. Left (red, the failure): the unsafe query rating NOT IN (Daffy's ratings) expands to 4.5 != 2.9 AND 4.5 != 4.9 AND 4.5 != NULL; the first two are TRUE but the comparison against NULL is UNKNOWN (grey, marked ?), so the whole AND is UNKNOWN and WHERE drops every row: 0 rows. Right (green, the fix): adding AND rating IS NOT NULL removes the NULL from the list, both comparisons are TRUE, and 6 rows come back. Color key: red = the silent failure, green = the guarded query, grey with ? = the UNKNOWN NULL comparison.NOT IN with a NULL: guard it, or lose every rowRun this querywhat does it return?SELECT listen_id, ratingFROM ListensWHERE rating NOT IN (SELECT rating FROM ListensWHERE user_id = 3)Daffy's ratings: 2.9, 4.9, and a NULL4.5 != 2.9TRUE4.5 != 4.9TRUE4.5 != NULL?UNKNOWNTRUE AND TRUE AND UNKNOWN = UNKNOWNWHERE drops every row0 rowsThe fixadd AND rating IS NOT NULLSELECT listen_id, ratingFROM ListensWHERE rating NOT IN (SELECT rating FROM ListensWHERE user_id = 3AND rating IS NOT NULL)The NULL never enters the list: (2.9, 4.9)4.5 != 2.9TRUE4.5 != 4.9TRUETRUE AND TRUE = TRUEevery matching row is kept6 rows
Notes ↗
M1

Correlated Subqueries: Row-by-Row Comparisons

A correlated subquery reads at least one column from the outer query, so it executes once per outer row.

5 figures
subqueries-correlated

Regular gives one global average; correlated gives each user their own

Regular gives one global average; correlated gives each user their ownA Listens table grouped by user (Mickey blue, Minnie orange, Daffy purple) feeds two aggregations. A regular subquery sends all rows to one global average, 4.2, used for everyone. A correlated subquery sends each user's rows, along a colored arrow, to that user's own average: Mickey 4.2, Minnie 4.4, Daffy 3.9. The colored arrow is the correlation, WHERE user_id = u.user_id. Color key: blue Mickey, orange Minnie, purple Daffy; grey is the global path.Regular: one global average. Correlated: each user's own.Same data, two aggregations. The colored arrow links each user to their own average.Listens9 rowsnameratingMickey4.5Mickey4.2Mickey3.9Minnie4.7Minnie4.6Minnie3.9Daffy2.9Daffy4.9DaffyNULLRegular subquery: one global averageAVG over all ratings4.2every user is compared to the same 4.2Correlated subquery: each user's own averageuserAVG of THEIR ratingsMickey4.2Minnie4.4Daffy3.9each colored arrow is the correlation: WHERE user_id = u.user_id keeps only that user's rows
A regular subquery produces one global value and the outer query compares every row against that value. A correlated subquery executes the inner query once per outer row with the outer row's values as parameters. Mickey compares to 4.2, Minnie compares to 4.4, Daffy compares to 3.9.
Notes ↗
subqueries-correlated · Example: Users Above Their Own Average

Users above THEIR OWN average

Users above THEIR OWN averageUsers above THEIR OWN average. One row per rating, grouped by user. The inner query runs once per user (the correlation). Rows that fail the comparison are struck out; the survivors give the output.Users above THEIR OWN averageSELECT DISTINCT u.nameFROM Users u JOIN Listens l ON l.user_id = u.user_idWHERE l.rating > (SELECT AVG(rating) FROM Listens l2WHERE l2.user_id = u.user_id)userratingMickey4.5Mickey4.2Mickey3.9Minnie4.7Minnie4.6Minnie3.9Daffy2.9Daffy4.9DaffyNULLPluto(no listens)their average4.24.24.24.44.44.43.93.93.9NULLabove their average?✓ yes✗ no✗ no✓ yes✓ yes✗ no✗ no✓ yes✗ no✗ noA user is kept when any of their rows survives.Output: Mickey, Minnie (twice), Daffy
The outer query selects a user. The inner query filters to that user's rows and computes the user's average rating. Mickey gets 4.2, Minnie gets 4.4, Daffy gets 3.9. Pluto has no ratings, so the inner query produces no average and the outer condition fails for Pluto.
Notes ↗
subqueries-correlated · Example: Existence Checks (EXISTS and NOT EXISTS)

EXISTS / NOT EXISTS: any rating above 4.5?

EXISTS / NOT EXISTS: any rating above 4.5?EXISTS / NOT EXISTS: any rating above 4.5?. One row per rating, grouped by user. The inner query runs once per user (the correlation). Rows that fail the comparison are struck out; the survivors give the output.EXISTS / NOT EXISTS: any rating above 4.5?SELECT u.name FROM Users uWHERE NOT EXISTS (SELECT 1 FROM Listens lWHERE l.user_id = u.user_id AND l.rating > 4.5)userratingMickey4.5Mickey4.2Mickey3.9Minnie4.7Minnie4.6Minnie3.9Daffy2.9Daffy4.9DaffyNULLPluto(no listens)rating > 4.5 ?✗ no✗ no✗ no✓ yes✓ yes✗ no✗ no✓ yes✗ no✗ noThe survivors split the users into two complementary sets:EXISTS keeps (has a survivor)nameMinnieDaffyNOT EXISTS keeps (has none)nameMickeyPluto
One correlated check answers both EXISTS and NOT EXISTS. For each user the inner query asks whether any rating beats 4.5. NOT EXISTS keeps the users with none (Mickey, whose top is exactly 4.5, and Pluto, who has no listens at all); EXISTS keeps the opposite pair, Minnie and Daffy.
Notes ↗
subqueries-correlated · Mental Model: A Double For-Loop

A correlated subquery is a double for-loop linking inner and outer

A correlated subquery is a double for-loop linking inner and outerPseudocode of a double for-loop: the outer loop runs once per user u; the inner SELECT AVG runs each pass over Listens l2 WHERE l2.user_id = u.user_id. An arrow links the inner reference u.user_id back to the outer loop variable u: that cross-reference is the correlation. Two scopes: u is the outer scope (current user), l2 is the inner scope (this query's Listens). The inner re-runs once per outer user: Mickey 4.2, Minnie 4.4, Daffy 3.9.A correlated subquery is a double for-loopThe correlation is the inner query reading a variable scoped to the outer loop.for u in Users:u_avg = ( # u's own averageSELECT AVG(rating)FROM Listens l2WHERE l2.user_id = u.user_id)keep u when u has a rating above u_avgthe inner readsthe outer's u:the correlationTwo scopes, cross-referenceduouter scope:the current user (outer loop)l2inner scope:this query's own ListensThe inner WHERE reaches OUT to u,the outer loop's variable. Thatcross-reference is the correlation.So the inner query re-runs once per outer user, each on their own rows:u = MickeyAVG = 4.2u = MinnieAVG = 4.4u = DaffyAVG = 3.9
The execution model. The outer loop walks one user u at a time; the inner query re-runs each pass. The correlation is the pink link: inside the inner query, l2.user_id = u.user_id reads u from the outer scope. So the inner average is computed only over that user's rows, and the inner query runs once per outer row.
Notes ↗
subqueries-correlated · Common Mistakes with Correlated Subqueries

The N plus one problem and the single-JOIN rewrite

The N plus one problem and the single-JOIN rewriteLeft, the naive correlated form: one outer query over Users, then the inner query re-runs once per user, so 1 outer plus N inner equals N+1 queries, 10,001 for 10,000 users. Right, the optimizer rewrite: a single JOIN with a window function (covered later in the module), one pass over the data, just 1 query. Color key: red top-strip is the costly N+1 form, green is the one-query rewrite.The N+1 problem: one inner query per row adds upRunning the inner query once per outer row scales linearly; a single JOIN does not.Naive: one inner query per row1 SELECT name FROM Usersthe outer query runs once, returns 10,000 usersthen the inner query re-runs once per user:AVG WHERE user_id = 1AVG WHERE user_id = 2AVG WHERE user_id = 3⋮ one per userAVG WHERE user_id = 10000N = 10,000= 10,001 queries1 outer + N inner; grows with NRewritten: one passThe optimizer rewrites the N+1 form intoa single JOIN with a window function.Window functions are covered laterin this module.Postgres, MySQL, and SQL Server allrecognize the pattern and apply it.= 1 querysame answer, one pass over the data
The outer query runs once and the inner query runs once per user, so the plan executes 1 + N inner evaluations. 10,000 users produce 10,001 evaluations. Many optimizers recognize common correlated patterns and rewrite them as a single JOIN with a windowed aggregate.
Notes ↗
M1

CTEs: Common Table Expressions

A CTE (WITH name AS (...)) names an intermediate query result so the rest of the query can reference it like a table.

3 figures
cte

CTEs: turn nested chaos into named, linear steps

CTEs: turn nested chaos into named, linear steps A before-and-after. The left panel (red, the problem) shows three subqueries nested inside one another, read inside-out and hard to debug. The right panel (green, the better way) shows the same logic as three named CTE steps read top to bottom: ClassicSongs returns {3,4,5,9}, ClassicListeners returns {1,2,3}, the final query returns {Mickey, Minnie, Daffy}. Color key: red is the hard-to-read nested way, green is the clear CTE way, grey is a neutral step label, and the ink target marks the takeaway. CTEs: turn nested chaos into named, linear steps Goal: find the users who listen to Classic songs. Nested subqueries Read inside-out · hard to debug SELECT name FROM Users WHERE user_id IN ( ... ) Step 3 (outer) SELECT user_id FROM Listens WHERE song_id IN ( ... ) Step 2 (middle) SELECT song_id FROM Songs WHERE genre = 'Classic' Step 1 (innermost) Problems • Must read inside-out • Hard to debug a single step • Cannot reuse a piece ? ? ? CTE approach Linear · clear · maintainable WITH ClassicSongs Step 1: find Classic songs SELECT song_id FROM Songs WHERE genre = 'Classic' → {6, 7, 8} ClassicListeners Step 2: find users who listened SELECT DISTINCT user_id FROM Listens WHERE song_id IN (SELECT song_id FROM ClassicSongs) → {1, 2, 3} Final query Step 3: get user names SELECT name FROM Users WHERE user_id IN (SELECT user_id FROM ClassicListeners) → {Mickey, Minnie, Daffy} Benefits ✓ Each step has a name    ✓ Self-documenting ✓ Easy to debug       ✓ Reuse a CTE Test each intermediate result on its own transform Color key  red = the hard-to-read nested way  ·  green = the clear CTE way  ·  grey = a neutral step label
A CTE turns nested chaos into named, linear steps. Left (red): subqueries nested inside one another, read inside-out and hard to debug. Right (green): the same logic as named steps read top to bottom. Same result, far easier to follow.
Notes ↗
cte · Why CTEs?

Three reasons CTEs are first-class

Three reasons CTEs are first-classReadable named blocks, optimizer can reason about each block on its own, and recursion lets a CTE reference itself to walk hierarchies.Why databases made CTEs first-classNot just sugar for subqueries. Three real wins.1ReadableEach step is a named block,read top to bottom, notinside-out.2Optimizer-friendlyThe engine can reason aboutand optimize each namedblock on its own.3RecursionA CTE can reference itself:walk org charts, trees, anddependency graphs.
Notes ↗
cte · Common Patterns

CTE common patterns and performance notes

CTE common patterns and performance notesA CTE names a result you can reference several times instead of repeating a subquery; index the base-table columns its result joins on; and whether the engine computes the CTE once (MATERIALIZED, a reused temp result) or inlines it into the query and may re-run it depends on the database.Common patterns and performance notesA CTE names a result. Whether the engine computes it once or re-runs it depends on the engine.A reusable named stepWITH gives a result a name. Referenceit several times in one query, insteadof repeating the same subquery.Index the join keysA CTE result joins back on sourcecolumns, so index those base-tablecolumns to keep the join fast.Materialized or inlinedComputed once, or re-run? It depends.MATERIALIZED keeps it as a reusedtemp result. Without that, enginesmay inline the CTE and push filters in.
Notes ↗
M1

Window Functions: Calculations Without Collapsing

Window functions compute aggregates across a partition defined by OVER, returning one result per input row instead of collapsing them like GROUP BY.

6 figures
window-functions · GROUP BY vs PARTITION BY

GROUP BY collapses; a window keeps the rows in a new table plus a column

GROUP BY collapses; a window keeps the rows in a new table plus a columnThe nine Listens rows. GROUP BY collapses them to three rows of per-user average. A window function instead returns the same nine Listens rows in a new table, each with a user_avg column holding its user average.GROUP BY collapses the rows; a window keeps them and adds a columnSame per-user average, two outputs. The window result is the Listens rows again, plus one column.Listensuser_idsong_idrating114.5124.2163.9224.7274.6283.9312.9324.936NULLGROUP BY user_iduser_idavg_rating14.224.433.9GROUP BY9 rows → 3, detail goneAVG(rating) OVER (PARTITION BY user_id)user_idsong_idratinguser_avg114.54.2124.24.2163.94.2224.74.4274.64.4283.94.4312.93.9324.93.936NULL3.9or keepevery rowthe same 9 Listens rows, plus a user_avg column
The same per-user average, two ways. GROUP BY collapses the nine listen rows into one summary row per user (Mickey 4.2, Minnie 4.4, Daffy 3.9), losing the detail. A window function, AVG(rating) OVER (PARTITION BY user_id), keeps all nine rows and attaches each user's average as a new column. Same math, but the window preserves row granularity.
Notes ↗
window-functions · Ranking Functions

RANK in three steps: partition, order, assign

RANK in three steps: partition, order, assignThe RANK query, then three side-by-side tables: PARTITION BY makes three lanes, ORDER BY rating DESC sorts each lane with NULL last, and the rank column numbers each lane from one.RANK in three steps: partition, order, numberThe same query expansion, side by side. Each clause does one thing.-- Rank each user's listens by rating, highest first.SELECTuser_id,song_id,rating,RANK()OVER(PARTITIONBYuser_idORDERBYratingDESC)ASrankFROMListensStep 1: PARTITION BY user_iduser_idrating14.514.213.924.724.623.932.934.93NULLthree lanes, as-isStep 2: ORDER BY rating DESCuser_idrating14.514.213.924.724.623.934.932.93NULLordered: user 1ordered: user 2ordered: user 3each user sorted on its own, NULL lastStep 3: assign the rankuser_idratingrank14.5114.2213.9324.7124.6223.9334.9132.923NULL3top = 1, then 2, then 3 per lane
Notes ↗
window-functions · Ranking Functions

ROW_NUMBER, RANK and DENSE_RANK on a tie

ROW_NUMBER, RANK and DENSE_RANK on a tieFour rows sorted by rating with two tied 4.5s. ROW_NUMBER is 1,2,3,4. RANK is 1,1,3,4 (skips after the tie). DENSE_RANK is 1,1,2,3 (no gap).Three ways to rank, when there is a tieMickey's listens by rating, plus a hypothetical second 4.5. The functions agree until the tie.rating4.54.54.23.9ROW_NUMBER1234never tiesRANK1134shares 1, then SKIPS to 3 (a gap)DENSE_RANK1123shares 1, stays PACKED at 2 (no gap)
The three ranking functions agree until a tie. ROW_NUMBER is always unique (1, 2, 3, 4). RANK lets the tie share a rank then skips, leaving a gap (1, 1, 3, 4). DENSE_RANK lets the tie share a rank but stays packed, no gap (1, 1, 2, 3). The shaded band is Mickey's hypothetical 4.5 tie.
Notes ↗
end of lecture 1~70 min of figures
window-functions · Key Rules

The OVER clause makes a function a window function

The OVER clause makes a function a window functionAVG(rating) collapses like GROUP BY. AVG(rating) OVER (PARTITION BY user_id) is a window function: one value per row, all rows kept. Any aggregate plus LAG and LEAD work in a window.What makes it a window? The OVER clause.The same aggregate collapses without OVER, and keeps every row with it.AVG(rating)GROUP BY stylecollapses to one row per groupAVG(rating)OVER(PARTITIONBYuser_id)window functionone value per row, all rows keptAny aggregate works in a window, plus LAG and LEAD to see the previous or next row.
Notes ↗
window-functions · Common Patterns

Five common window-function patterns

Five common window-function patternsRanking, running totals with SUM OVER ORDER BY, moving averages with a ROWS window, LAG and LEAD, and NTILE percentiles.Window functions go well beyond rankingThe same OVER skeleton, five everyday shapes.Rankingorder rows within a groupROW_NUMBER() / RANK() / DENSE_RANK()Running totalaccumulate over timeSUM(x) OVER (ORDER BY date)Moving averagesmooth a sliding windowAVG(x) OVER (ROWS BETWEEN ...)Lead / Lagcompare to the previous or next rowLAG(x) / LEAD(x)Percentilesbucket rows into quantilesNTILE(4) / PERCENT_RANK()
Notes ↗
window-functions · Common Mistakes

Three common window-function mistakes

Three common window-function mistakesMissing PARTITION BY ranks the whole table; wrong ORDER BY direction flips the ranking; NULL ordering varies by database.Three ways window queries go wrongAll three are silent: the query runs, the answer is just wrong.!Missing PARTITION BYLeave it out and you rank across the WHOLE table, not per group. One global #1, not one per user.!Wrong ORDER BY directionASC vs DESC flips the ranking. Highest-first needs DESC, or your #1 is your worst row.!Forgetting NULLs in ORDER BYNULLs sort first or last depending on the database (Postgres first, MySQL last). Be explicit.
Notes ↗
M1

Writing Debug Tables: Hand-Tracing a Query

Debug tables, also called handtraces, validate a query's logic by walking the input rows through each clause and writing down what survives at each step.

3 figures
writing-debug-tables · Why This Matters

Why hand-trace a query

Why hand-trace a queryLLMs miss semantics; interviews ask if a query is correct; a 5-minute trace beats a 30-minute re-prompt loop.Why hand-trace at all?A syntax check passes a wrong query. A trace catches it.LLMs miss semanticsSyntax: 95%+ correct.Semantics: 16-77% (BIRD).A query can parse and stillbe logically wrong.Interviews and tests"Is this query correct?" isthe classic question.Reading the logic is whatgets you hired.It is fasterRe-prompting an LLM: ~30 min.Drawing one debug table: ~5.And you build real SQLintuition along the way.
Notes ↗
writing-debug-tables · Example 1: Users' Genres with 2+ Songs

Hand-trace of the GROUP BY / HAVING query

Hand-trace of the GROUP BY / HAVING queryThe query, then three tables side by side: FROM+JOIN gives 9 rows, GROUP BY makes 6 user-genre groups, and HAVING COUNT >= 2 keeps 3, not the 4 the query claimed.Hand-trace the query: rows in, survivors outThe query claims 4 user-genre pairs. Walk each clause and see what really survives.-- Find user-genre pairs where the user listened to >= 2 songs in that genre.SELECTu.user_id,s.genre,COUNT(*)ASsong_countFROMUsersuJOINListenslONl.user_id=u.user_idJOINSongssONs.song_id=l.song_idGROUPBYu.user_id,s.genreHAVINGCOUNT(*)>=2Step 1 · FROM + JOIN9 rows, every listen with its genreusergenrerating1Pop4.51Pop4.21Classic3.92Pop4.72Classic4.62Classic3.93Pop2.93Pop4.93ClassicNULLGROUP BYStep 2 · GROUP BY9 rows -> 6 user-genre groupsusergenrecount1Pop21Classic12Pop12Classic23Pop23Classic1HAVINGStep 3 · HAVING COUNT(*) >= 26 groups -> 3 survive (not 4)usergenrecount1Pop21Classic12Pop12Classic23Pop23Classic1Answer: 3 pairs survive, not the 4 claimed.
Notes ↗
writing-debug-tables · Quick Verification Checklist

A five-point query checklist

A five-point query checklistCheck JOIN row counts, NULL handling, group granularity, aggregate choice, and window partitions.Five things to check on every traceRun down the list; each one is a place real queries go wrong.JOINsExpected row count? Not accidentally cartesian? INNER vs LEFT vs OUTER?NULLsHandled correctly? IS NULL, not = NULL? NOT IN guarded against NULLs?GroupsRight granularity? Grouped by user, or by user and genre?AggregatesMakes sense? COUNT(*) counts rows; COUNT(column) skips NULLs.WindowsPartitions correct? RANK vs ROW_NUMBER vs DENSE_RANK on ties?
Notes ↗
M1

Query Equivalence

Two queries are equivalent if they return the same result for any input data.

6 figures
sql-query-equivalence · Important: Query Equivalence vs Output Equivalence

Query equivalence vs output equivalence

Two queries can agree today and disagree tomorrowThat is the difference between output equivalence and real query equivalence.Query equivalenceSame result for ANY input.WHERE x BETWEEN 1 AND 5WHERE x >= 1 AND x <= 5Output equivalenceSame result for THIS data only.WHERE rating > 4.5WHERE rating >= 4.6Tomorrow a row rates 4.55:> 4.5 keeps it, >= 4.6 drops it.They split. A coincidence.Any x, any table: the two are identical.Always the same. A guarantee.Query equivalence vs output equivalenceQuery equivalence holds for any input and is a guarantee; output equivalence holds only for the current data and is a coincidence that can break on a new row: WHERE rating greater than 4.5 and rating at least 4.6 agree until a row rates 4.55, then they split.
Notes ↗
sql-query-equivalence · Example 1: Users Above Their Personal Average

Two shapes, one answer: users above their personal average

Two shapes, one answer: users above their personal averageBoth compute the same value, this user's average rating, then keep anyone who beats it.Correlated subquerySELECTDISTINCTu.nameFROMUsersuJOINListenslONu.user_id=l.user_id-- this user's avg, recomputed per rowWHEREl.rating>(SELECTAVG(l2.rating)FROMListensl2WHEREl2.user_id=u.user_id)CTE + JOINWITHUserAvgsAS(-- this user's avg, computed onceSELECTuser_id,AVG(rating)ASavgFROMListensGROUPBYuser_id)SELECTDISTINCTu.nameFROMUsersuJOINListenslONu.user_id=l.user_idJOINUserAvgsuaONua.user_id=u.user_idWHEREl.rating>ua.avgcomputes each user's averagekeeps rows above itboth queries return the same rows, for any inputnameMickeyMinnieDaffyTwo shapes, one answer: users above their personal averageBoth compute the same value, this user's average rating, then keep anyone who beats it. They return the same rows.
Notes ↗
sql-query-equivalence · Example 2: Users Who Listen to Multiple Genres

Two shapes, one answer: users who span at least 2 genres

Two shapes, one answer: users who span at least 2 genresTwo operations: count distinct genres per user, then keep 2 or more. HAVING fuses them; the CTE names the count, then filters. JOINs and GROUP BY are shared scaffolding.GROUP BY + HAVINGSELECTu.nameFROMUsersuJOINListenslONu.user_id=l.user_idJOINSongssONl.song_id=s.song_idGROUPBYu.user_id,u.name-- one clause does both: count, then keep >= 2HAVINGCOUNT(DISTINCTs.genre)>=2CTE for clarityWITHUserGenreCountsAS(-- name the distinct-genre countSELECTl.user_id,COUNT(DISTINCTs.genre)ASgenre_countFROMListenslJOINSongssONl.song_id=s.song_idGROUPBYl.user_id)SELECTu.nameFROMUsersuJOINUserGenreCountsugcONugc.user_id=u.user_idWHEREugc.genre_count>=2counts distinct genres per userkeeps users with 2 or moreboth queries return the same rows, for any inputnameMickeyMinnieDaffyTwo shapes, one answer: users who span at least 2 genresTwo operations: count distinct genres per user, then keep 2 or more. HAVING fuses them; the CTE names the count, then filters. JOINs and GROUP BY are shared scaffolding. They return the same rows.
Notes ↗
sql-query-equivalence · Example 3: Top 3 Songs per User

Top 3 per user: output-equivalent but not the same query

Output-equivalent, not the same query: each user's top 3 songsBoth rank a song within its user. On distinct ratings they agree; one extra tied song splits them.Window: ROW_NUMBER() <= 3WITHrankedAS(SELECT*,ROW_NUMBER()OVER(PARTITIONBYuser_id-- per userORDERBYratingDESC)ASrn-- highest firstFROMListensWHEREratingISNOTNULL)SELECT*FROMrankedWHERErn<=3-- top 3Subquery: count how many rate higherSELECT*FROMListenslWHEREl.ratingISNOTNULLAND(SELECTCOUNT(*)FROMListensl2WHEREl2.user_id=l.user_idANDl2.rating>l.rating-- count higher)<3-- fewer than 3 rate higherMickey's songs by rating:ratingROW_NUMBERsongs rated higherA: rn <= 3B: # higher < 34.5104.2213.932On distinct ratings, both queries keep all three. They agree.3.942← the extra tupleAdd one more rated 3.9, tying for 3rd: ROW_NUMBER <= 3 keeps 3, COUNT(higher) < 3 keeps 4.Rank 4 to ROW_NUMBER (dropped), but still only 2 rate higher (kept). The tie at the cutoff is where they split.Top 3 per user: output-equivalent but not the same queryTwo queries for each user top 3 songs: ROW_NUMBER() less-equal 3, and a correlated subquery keeping songs with fewer than 3 rated higher. On distinct ratings both keep 3. Add one more song rated 3.9, tying for third: ROW_NUMBER assigns it rank 4 and drops it (3 rows), but only 2 songs rate higher than 3.9 so the subquery keeps it (4 rows). The tie at the cutoff is where they diverge.
Notes ↗
sql-query-equivalence · Where the Look-Alikes Split

Where the look-alike queries split

Where the look-alikes splitEqual on our nine rows. Change the data, or the schema, and they come apart.A tie breaks Example 3One user, four songs tied at 5.0:ratingrow_number# higherrn<=3#h<35.0105.0205.0305.040ROW_NUMBER keeps 3 · COUNT(higher) keeps 4A NULL breaks NOT INExclude-list = { 2, NULL }:user_idNOT IN (2, NULL)NOT EXISTS1UNKNOWN → dropkeep3UNKNOWN → dropkeep4UNKNOWN → dropkeepNOT IN → nothing · NOT EXISTS → 1, 3, 4Where the look-alike queries splitTwo concrete divergences. With four songs tied at 5.0, ROW_NUMBER keeps 3 while COUNT of higher-rated keeps 4. With a NULL in the exclude-list, NOT IN returns nothing while NOT EXISTS returns the right rows; on this schema user_id is NOT NULL so it cannot happen here.
Notes ↗
sql-query-equivalence · The Takeaway

Same answer today is not the same query

Same answer today is not the same queryReal equivalence holds for every future input, not just the rows you can see today.1Holds for ANY inputEquivalent means they agree on every possible table, not only the one in front of you.2Edges are where they splitNULLs, ties, and boundary values are exactly where look-alike queries quietly diverge.3Read first, trust the optimizerWhen the logic matches, pick the clearest shape and let the optimizer make it fast.Same answer today is not the same queryReal query equivalence holds for any input: equivalent queries agree on every possible table; NULLs, ties, and edge values are where look-alike queries diverge; when the logic matches, pick the clearest shape and trust the optimizer.
Notes ↗
M1

Schema Design: One Fact, One Place

Schema design decides which tables hold which columns.

3 figures
schema-design · The problem: one big table repeats itself

One big table repeats itself

One big table repeats itself Left: a single wide table holding listen_id, name, email, title, artist, and rating. Mickey's email is copied on all three of his rows (highlighted amber), and the artist Taylor Swift is copied on row after row (highlighted amber). Right: three red cards naming the anomalies this repetition causes. Update: Mickey changes his email, so you must fix every Mickey row and will miss one. Insert: a brand-new song nobody has played has no listen row to live on. Delete: removing Mickey's last listen deletes his email too. Color key: amber marks a fact copied many times; red marks the three anomalies that repetition causes. One big table repeats itself Put every column in one table and the same facts appear over and over. Listens, one wide table listen_id name email title artist rating 1Mickeymickey@ex.comEvermoreTaylor Swift4.5 2Mickeymickey@ex.comWillowTaylor Swift4.2 3Mickeymickey@ex.comYesterdayBeatles3.9 7Daffydaffy@ex.comEvermoreTaylor Swift2.9 8Daffydaffy@ex.comWillowTaylor Swift4.9 Mickey's email, copied on all 3 rows one artist, copied again and again What repetition costs you Update Mickey changes his email. Fix every Mickey row, miss one, and now one person has two emails. Insert Add a brand-new song nobody has played yet. There is no listen row to hang it on. Delete Delete Mickey's last listen and his email disappears with it. The fact had nowhere else to live. Color key  amber = a fact copied many times · red = the anomalies that repetition causes.
Repeating a fact across rows creates update, insert, and delete anomalies. An update to Mickey's email changes many rows and can leave different copies of the same fact. An insert cannot store a song fact until some listen row exists. A delete can remove a fact when the last row carrying it goes.
Notes ↗
schema-design · The fix: give each fact one home

Give each fact one home

Give each fact one home Three tables. Users holds user_id, name, and email, with a note that Mickey's email lives here once. Songs holds song_id, title, artist, and genre, with a note that Taylor Swift lives here once. Listens at the bottom holds listen_id, user_id, song_id, and rating, and two arrows run from its user_id to Users and from its song_id to Songs, the foreign keys. Color key: green is the normalized design; the arrows are foreign keys that point at the one place each fact lives. Give each fact one home Each fact is stored once. Listens points to it by key. Users user_id (PK) · name · email 1 Mickey mickey@ex.com 3 Daffy daffy@ex.com Mickey's email lives here once Songs song_id (PK) · title · artist · genre 1 Evermore Taylor Swift 2 Willow Taylor Swift Taylor Swift lives here once Listens listen_id (PK) user_id (FK) · song_id (FK) · rating just the events, pointing by key Color key  green = the normalized design · arrows are foreign keys pointing at the one place each fact lives.
Normalization stores each fact once and connects tables with keys. Boyce-Codd Normal Form (BCNF) gives a formal definition of this goal. Updating Mickey's email changes one row in Users, and joins pull that value into query results. A foreign key, like `Listens.user_id`, points to the row that stores the user fact.
Notes ↗
schema-design · The trade-off: normalize first, denormalize only when forced

Normalize first. Denormalize only when forced.

Normalize first. Denormalize only when forced. Two panels. Left, green, Normalized: each fact once, no anomalies, recombine with a JOIN; the cost is a join at read time. Right, amber, Denormalized: copy facts back together for fast reads with no join, accepting the risk that the copies drift apart; used for read-heavy analytics at scale. Color key: green is the safe default, amber is the deliberate exception you take only when a measured read cost forces it. Normalize first. Denormalize only when forced. Same data, two layouts, opposite trade-offs. Normalized (the default) Each fact stored once. No update, insert, or delete anomalies. Cost: you JOIN to recombine at read time. safe by default; correctness first Denormalized (the exception) Copy facts back together so a read needs no join. Faster reads. Cost: the copies can drift apart. used for read-heavy analytics at scale Color key  green = the safe default · amber = the deliberate exception, taken only when a measured read cost forces it.
Normalization avoids anomalies and requires joins during reads. Denormalization copies facts to reduce join cost and can create inconsistent copies. Start with a normalized schema and denormalize after measuring a read bottleneck. Module 5 uses intentional copying to serve millions of reads.
Notes ↗
M1

How a Query Runs

A database engine compiles SQL text into an execution plan.

3 figures
parallel-execution · The Challenge

The same query on one machine or a hundred, returning the same answer

Same query, a billion times the dataScale up by a factor of a billion without rewriting one line of SQL.SQLite, one machineSingle-threaded, a small table:9 rows on your laptopone core, start to finishRuns the whole query alone.BigQuery, ~100 machinesThe same SQL, distributed:5.5 TB across the clustereach machine takes a sliceSame answer, back in milliseconds.Same algorithm. Only the data layout changes.The same query on one machine or a hundred, returning the same answerThe same SQL query runs on one SQLite machine over a tiny table, or across about 100 BigQuery machines over 5.5 TB, returning the same answer in milliseconds.
Notes ↗
parallel-execution · The Journey: From Text to Results in 47ms

One query, 5.5 TB, about 100 machines, about 47 ms

One query, 5.5 TB, about 100 machines, about 47 ms A left-to-right pipeline. A SQL query is parsed, turned into a query plan, then a coordinator splits 5.5 TB into shards and fans the work out to roughly 100 machines that each scan one shard in parallel; the partial results merge into the top 10 in about 47 ms. Color key: grey is a pipeline stage, blue is the roughly 100-machine engine in focus, and the ink target marks the takeaway. It is the same algorithm SQLite runs on 9 rows, only split across machines. One query · 5.5 TB · ~100 machines · ~47 ms Top 10 most-played songs since 2024, the same algorithm SQLite runs on 9 rows, only split across machines. SQL query SELECT s.title, COUNT(*) FROM songs s JOIN listens l WHERE l.ts >= '2024-01-01' GROUP BY s.title ORDER BY 2 DESC LIMIT 10 Parse · 0–5 ms Query plan operator tree: scan → join → group → sort Plan · 5–12 ms Coordinator splits 5.5 TB into ~100 shards (~55 GB) Distribute · 12–15 ms ≈ 100 machines, in parallel scan shard scan shard scan shard scan shard scan shard each runs the same scan + group on ~55 GB Execute · 15–45 ms Result top 10 songs → 47 ms Color key  grey = a pipeline stage  ·  blue = the ~100-machine engine in focus
One SQL query over 5.5 TB runs in about 47 ms on about 100 machines. The engine parses the query into a plan. A coordinator shards the data, runs the plan on each shard in parallel, and merges partial results into the final top 10.
Notes ↗
parallel-execution · What You're Seeing

One SQL statement, one result; underneath, shards running in parallel

One statement you write, a choreography that runsThe abstraction holds because the algorithms are the same.What you writeone SQL statementSELECT ... GROUP BY ... LIMIT 10one resulttop 10 songsWhat actually runssplit the tableby hash key100 shardsrun in parallelshuffle + mergecombine partialsresultglobal top 10One SQL statement, one result; underneath, shards running in parallelA developer writes one SQL statement and gets one result; underneath, the engine splits the table into shards, runs them in parallel, then shuffles and merges the partial results.
Notes ↗
M1

SQL Libraries: Define Once, Compose

A library packages the SQL you repeat as named, parameterized queries, so you define a term once and compose it everywhere.

1 figure
sql-libraries · What a Library Spans

A small vocabulary spans a large answerable domain

A small vocabulary spans a large answerable domain Three white panels with accent top-strips. DEFINED (blue) holds parameterized primitives listener_of, active_since, liked, each pinning a choice the model would guess; a dashed power_user chip is the primitive you add next. Composing feeds the green ANSWERABLE panel of questions, none written out. Adding power_user to the DEFINED set unlocks a new dashed green composition, so the domain grows by adding to the blue. A grey OFF THE TABLE panel holds questions no definition reaches: subjective, missing-data, predictive. A small vocabulary spans a large answerable domain Define a few parameterized primitives. Compose them to answer the rest. DEFINED the vocabulary you write listener_of(artist) active_since(days) liked(min_rating) each pins a choice UTC midnight, not 24h; a NULL rating is not a like dimensions: artist, genre, window, rating ANSWERABLE compose with ∩ − ∪, none of it written All − listener_of('Taylor') active_since(7) ∩ listener_of('Beatles') listener_of('Taylor') ∩ liked(4.0) power_user(min) add power_user, and a new question composes: power_user(50) ∩ listener_of('Taylor') OFF THE TABLE no definition reaches here which songs are good? subjective why did Pluto quit? data you don't have will Daffy churn? a prediction, not a lookup Curation grows the green by adding to the blue. The grey is the edge of what any definition can reach.
A few defined terms (blue) compose into a large answerable domain (green), none of those queries written out. Add one term (power_user) and a new composition appears: the domain grows by adding to the library, not by anything in the answerable set. Past a hard boundary is what no definition can reach (grey): subjective, missing-data, or predictive questions.
Notes ↗
M1

Validating SQL: Prove the Result

A query that runs is not a query that is right.

4 figures
validating-sql · Unit Test: One Query

Query A, the LEFT JOIN, traced

Query A, the LEFT JOIN, tracedKeep every play that is not Taylor (or has no song), then group by user.SELECTu.nameFROMUsersuLEFTJOINListenslONu.user_id=l.user_idLEFTJOINSongssONl.song_id=s.song_idWHEREs.artist!='TaylorSwift'ORs.artistISNULLGROUPBYu.user_id,u.nameAfter the joinsevery play, with its artistnamesongartistMickeyEvermoreTaylorMickeyWillowTaylorMickeyYesterdayBeatlesMinnieWillowTaylorMinnieYellow SubBeatlesMinnieHey JudeBeatlesDaffyEvermoreTaylorDaffyWillowTaylorDaffyYesterdayBeatlesPlutoNULLNULLWHERE: drop the Taylor playsMickeyEvermoreTaylorMickeyWillowTaylorMinnieWillowTaylorDaffyEvermoreTaylorDaffyWillowTaylorGROUP BY: result4 usersnameMickeyMinnieDaffyPlutoAll 4 users survive, even the Taylor listeners. That is the bug.Query A, the LEFT JOIN, tracedKeep every play that is not Taylor (or has no song), then group by user. Dropped rows are greyed and struck through; survivors carry a green check. Color key: grey strike = a row the clause drops, green check = a row that survives.
Two executable readings traced clause by clause. Query A (the raw LEFT JOIN) removes the Taylor rows, but each user keeps another surviving row, so GROUP BY returns all four users. Query B (calling listener_of) removes the three Taylor listeners and returns only Pluto. A unit test resolves the ambiguity by checking the returned rows.
Notes ↗
validating-sql · Unit Test: One Query

Query B, the NOT IN, traced

Query B, the NOT IN, tracedFind everyone who heard Taylor, then exclude exactly them.SELECTu.nameFROMUsersuWHEREu.user_idNOTIN(SELECTuser_idFROMlistener_of('TaylorSwift'))Every user, did they hear Taylor?who listener_of returnsnameheard Taylor?MickeyyesMinnieyesDaffyyesPlutonoNOT IN: drop the Taylor listenersMickeyyesMinnieyesDaffyyesResult1 usernamePlutoOnly Pluto remains, exactly what the spec asked for.Query B, the NOT IN, tracedFind everyone who heard Taylor, then exclude exactly them. Dropped rows are greyed and struck through; survivors carry a green check. Color key: grey strike = a row the clause drops, green check = a row that survives.
Two executable readings traced clause by clause. Query A (the raw LEFT JOIN) removes the Taylor rows, but each user keeps another surviving row, so GROUP BY returns all four users. Query B (calling listener_of) removes the three Taylor listeners and returns only Pluto. A unit test resolves the ambiguity by checking the returned rows.
Notes ↗
validating-sql · Regression: The Eval Set

One test today, a suite that holds over time

One test today, a suite that holds over time A left-to-right timeline of validation. First, one unit test proves a single query today: non-Taylor listeners return Pluto, with a green check. Next, you keep it and the eval set grows into a stack of verified tests. Then you re-run the whole suite on every change over time, drawn as a grid of runs by date: mostly green checks, until a definition drifts and one test fails red that day. Color key: blue marks the three stages and the time axis; a green check is a passing test, a red cross is the test that catches the drift the day it happens. One test today, a suite that holds over time A unit test proves one query now. Keep it, grow the set, re-run it: a drift fails the day it happens. time 1 · Unit test prove one query today non-Taylor listeners expect: Pluto day 0 2 · Eval set keep it, add more non-Taylor listeners → Pluto Taylor listeners → 3 users active this week → 2 users as you build 3 · Re-run over time every change re-runs the suite v1 v2 v3 v4 v5 A B C a definition drifts at v4 test B fails, that day every change blue = the three stages and the time axis  ·  green = a passing test  ·  red = the drift caught
Validation over time. A unit test proves one query today (left); you keep it, so the eval set grows into a suite of verified questions (middle); then every change re-runs the whole suite (right), and a definition that drifts fails a test the day it drifts, not months later. The eval set answers "does the library still work" with a number instead of a hope.
Notes ↗
validating-sql · Verified Queries Compose

Verified blocks compose into a library

Verified blocks compose into a library Three columns of SQL blocks (blue), each marked verified with a green check. Base metrics on the left (active user, Taylor listener) feed composed queries in the middle (weekly actives, non-Taylor listeners), which feed a complex query on the right (weekly active non-Taylor listeners). Arrows run left to right showing composition: each query is built from blocks already checked, so a new query only adds its own layer to verify. Color key: blue is a SQL block in the library, the green check is verified once and then reused, arrows are composition. Verified blocks compose into a library Check each query once. Bigger queries build on blocks already verified. Base metrics active user Taylor listener Composed weekly actives non-Taylor listeners Complex query weekly active non-Taylor listeners blue = a SQL block in the library  ·   = verified once, then reused  ·  arrows = composition
Each block is a verified query (blue, green check). Base metrics compose into bigger queries, which compose into a complex one. Every block is verified once and reused, so a new query verifies only its own layer. A library of verified SQL is worth more than any single query: you build on it instead of re-proving it.
Notes ↗
M1

Why Learn SQL When an LLM Can Draft It?

The model writes the SQL in a second.

2 figures
llm-debug · One English Sentence Hides Sixteen Queries

One English question hides sixteen valid SQL queries, and different queries return different answers

One English question hides sixteen valid SQL queries, and different queries return different answers Left: one English sentence, find users who do not listen to Taylor Swift. Middle: three independent decisions the sentence leaves open (how to match Taylor Swift, what counts as a listen, and whether a user who plays nothing counts), multiplying to 2 times 4 times 2 equals 16. Right: a grid of 16 query cells, every one a valid query that runs and returns some answer. Two are labelled to show how far apart the answers are: one returns just Pluto, another returns all four users. An orange ring marks the square the model happened to land on this run; its pick is not fixed and can move to another square on another run or a newer model. Color key: grey is a valid query, all sixteen of which run, and orange is the one the model happened to pick. One Question, Sixteen Queries Each decision you leave open multiplies. Different queries return different answers. "Find users who don't listen to Taylor Swift" one English sentence Every open decision multiplies match Taylor Swift: exact · + collabs×2 “listen” means: any play · repeat · recent · favourite×4 a user who plays nothing: counts · excluded×2 2 × 4 × 2 = 16 queries 16 queries, every one runs this one returns just Pluto this one returns all four the model’s pick not fixed, run to run Settle every decision, or the model settles it for you. Color key grey = all sixteen run, and they disagree · orange = the one the model happened to pick
Sixteen valid queries behind one sentence. They do not agree: one returns a single user, another returns all four. Nothing in the grid marks which one you wanted, because nothing in the question does. The model picks a square (the orange ring) and hands you the SQL, so which one it picked is readable, if you can read SQL. Whether it is the one you meant is a separate question, and asking again tomorrow, on a newer model, or from a teammate’s seat can move the square. And this is the smallest schema you will ever see.
Notes ↗
llm-debug · The Benchmark Says 80%. A Real Warehouse Says 30%.

BIRD, the industry-standard text-to-SQL benchmark: who competes and the numbers

BIRD, the industry-standard text-to-SQL benchmark: who competes and the numbers BIRD, the industry-standard text-to-SQL benchmark. Every serious lab competes on it: OpenAI, Anthropic, Google, Databricks, Snowflake, Amazon, Alibaba, Tencent, ByteDance and Huawei. It runs 12,751 questions over 95 real databases, 33 GB of schemas, across 37 domains. The best systems reach about 80% on BIRD but only about 30% on BEAVER, a benchmark built from real company warehouses. Same labs and same models on both; the difference is the context supplied with the question. BIRD · the text-to-SQL benchmark Every serious lab competes on it, on real databases. Who competes OpenAI Anthropic Google Databricks Snowflake Amazon Alibaba Tencent ByteDance Huawei The benchmark 12,751 questions 95 databases 33 GB of real schemas 37 domains healthcare to blockchain Best systems, two benchmarks ~80% BIRD public benchmark ~30% BEAVER real company warehouses Same labs, same models. The gap is the context you supply.
BIRD uses 12,751 questions over 95 real databases, 33 GB, across 37 domains. The best systems reach about 80% execution accuracy; humans reach 93%. Each question includes an evidence string that supplies the needed definitions. The score measures SQL generation given a supplied meaning.
Notes ↗
M1

Case Study 1.1: Anthropic's Self-Service Analytics Stack

English is too vague to hand a model, so somebody has to write the meaning down somewhere precise.

4 figures
case-study-agentic-data-stack · What They Actually Built

The loop, as Anthropic actually built it

The loop, as Anthropic actually built it The same two tiers, with each part replaced by the infrastructure Anthropic runs. The top tier is what happens for one question, left to right: a skill that routes the model to the roughly thirty documents defining that domain, then Claude in grey which only generates, then their warehouse, which the model queries and retries against directly, then a suite of evals that gate it. The bare model already had the warehouse and scored twenty one percent. The bottom tier is the governed semantic layer, drawn lower and wider as a base that only grows: whatever clears the evals becomes a definition in it, and an arrow runs from the layer back up into the skill, so it is the context for the next question. Same four parts, same model, twenty one percent to about ninety five. Color key: blue is a part they built, grey is the commodity. The loop, as Anthropic actually built it Four parts around a model that only generates. 21% → ~95%, and near 99% in some domains. ONCE PER QUESTION Claude writes the SQL the commodity; swap it, this survives 1 Their warehouse it queries and retries against the real thing the bare model already had this, and scored 21% runs and retries on its own 2 The skill routes it to the ~30 docs defining this domain the mandatory default path, not a suggestion 3 Evals one suite per domain, run before anyone trusts it a domain ships to production only once it clears ~90% ACCUMULATES ACROSS QUESTIONS 4 The semantic layer the memory of this loop whatever clears the evals becomes a definition governed, verified, and it only grows what passes is kept and becomes the context for the next question Handing it the raw query history instead moved accuracy less than one point.
Four parts around a model that only generates. Their warehouse is part one, and the bare model already had it, which is why 21% is the honest starting number. The skill that loads a domain's ~30 definition docs is part two, and it is the mandatory default path. Evals are part three, and a domain reaches production only once it clears ~90%. The semantic layer is the lower tier: whatever clears the evals becomes a definition in it, and it feeds back up as the context for the next question.
Notes ↗
case-study-agentic-data-stack · How to Start on Your Own Data

Two ways to make the question precise

Two ways to make the question precise On the left, the vague question, users who don't listen to Taylor Swift, with three decisions left open and sixteen queries. It forks two ways. The top branch, in grey, spells every decision out in the prompt: it answers this one question, and the next question means writing the whole spec again. The bottom branch, in blue, operationalises the term instead, shown as three numbered steps: define it once, call it by name, set up a unit test. That one is written once and answers every later question that mentions listening. Color key: grey is the option you repeat, blue is the option you keep. Two ways to make it precise Both of them work. Only one of them you do once. The question users who don’t listen to Taylor Swift three decisions open · 16 queries Spell it out in the prompt answers this one question match: artist = 'Taylor Swift' listen: any row in Listens empty: zero listens is NOT a listener then write all of it again for the next question Operationalize it answers every question that mentions it 1 Define it once one SQL function 2 Call it by name the question composes 3 Set up a unit test one per decision Written once. Every later question about listening calls the same term. Same three decisions either way. The prompt restates them; the function keeps them.
Both branches are precise. Only one of them you do once. The same three decisions get settled either way; the difference is whether they survive the question that settled them. The three numbered steps are the whole job, and the next section does each one in SQL.
Notes ↗
case-study-agentic-data-stack · How to Start on Your Own Data

One term, three beats: define it, call it, test it

One term, three beats: define it, call it, test it Three stacked panels read top to bottom. First, in blue, the definition: listener_of is created once as an ordinary SQL function that takes an artist and returns the set of user ids who listened. Second, the usage: the original question is rewritten to call listener_of by name instead of spelling the joins out again. Third, the test: calling listener_of for Taylor Swift returns Mickey, Minnie and Daffy, and Pluto, who played nothing, is absent. A green check marks the test passing. Color key: blue is the term you define and reuse, green is the test that holds it in place. One term, three beats Define it once. Call it by name. Set up a unit test. Everything after this is composition. 1 Define it once an ordinary SQL function CREATE FUNCTION listener_of(artist TEXT) RETURNS TABLE (user_id INT) AS $$ SELECT DISTINCT l.user_id FROM Listens l JOIN Songs s ON l.song_id = s.song_id WHERE s.artist = listener_of.artist $$ LANGUAGE sql STABLE; 2 Call it by name and every later question that mentions listening calls the same term SELECT name FROM Users WHERE user_id NOT IN (SELECT user_id FROM listener_of('Taylor Swift')); 3 Set up a unit test one test per decision the term settles SELECT * FROM listener_of('Taylor Swift'); --> Mickey, Minnie, Daffy ... Pluto played nothing, so he is not there.
The definition fixes decisions that later queries would otherwise repeat. DISTINCT chooses how repeat plays count. The JOIN chooses what a listen means. A user with no listens never appears, so Pluto does not qualify as a listener. STABLE lets Postgres inline the function; without it Postgres plans the function as a black box and rebuilds it on every call. Later queries compose functions, and the model calls the term instead of inventing a definition.
Notes ↗
case-study-agentic-data-stack · How to Start on Your Own Data

What survives when you swap the model

What survives when you swap the model The library builds first, along the bottom, growing from three verified functions to nine to twenty-two against a common baseline. Only then does the top row appear: the model underneath was replaced three times while that happened, v1 to v2 to v3, each swap a configuration change. A panel on the right lists what is not model-specific: the database it runs on, the specs, the tests, and the named functions. Each stage of the library carries its own test count, seven then twenty-one then fifty-four rows, because every axis a concept names costs one row. Nothing in the library is re-verified when the model changes; the suite is instead what every new model's SQL has to pass, so the meaning cannot drift underneath you. The closing line is that the model is rented and the library is owned. Color key: blue is the piece that accumulates, grey is the piece you replace. What survives when you swap the model The library only grows. The model underneath it gets replaced, and none of that touches the library. YOUR LIBRARY · ACCUMULATED 3 functions 7 tests you start here 9 functions 21 tests each new term brings its own 22 functions 54 tests and still growing THE MODEL · REPLACED, MEANWHILE model v1 model v2 model v3 swapped twice while that happened. A config change each time. None of it is model-specific · the database it runs on · the specs · the tests · the named functions The model is rented. The library is owned. The suite is what holds the meaning still. Every swap above has to pass the same tests.
The model is rented; the library is owned. New versions land every few weeks and swapping one is a config change. Nothing underneath is re-verified, because none of what makes an answer trustworthy depends on which model produced it.
Notes ↗
M1

Case Study 1.2: How ChatGPT Stores Its Chats

The world's most-used AI product runs on SQL.

2 figures
case-study-openai-postgres · The Read-Heavy Constraint

One ChatGPT question: one write, then hundreds of reads

One ChatGPT question: one write, then hundreds of readsOne ChatGPT question is one write, orange, to save the new message, then hundreds of reads, blue, to fetch the history and build context. Color key: orange = the single write, blue = the many reads, the bottleneck.One question: one write, then hundreds of readsEvery ChatGPT turn saves one message, then re-reads the whole conversation for context.1 WRITEsave the new messagethenHUNDREDS OF READSfetch history, load the sidebar, build the LLM contextReads are the bottleneck, not writes. So scale reads.
Notes ↗
case-study-openai-postgres · The Read-Heavy Constraint

One writable primary, about fifty read replicas serving every read

One writable primary, about fifty read replicas serving every readWrites, orange, go to one writable primary that holds the truth with ACID; the primary replicates to about 50 read replicas, blue, and all read traffic fans out to them. Color key: orange = the single writable primary, blue = the read replicas.One primary for the truth, replicas for the readsWrites stay on a single primary; 50 read replicas absorb the query load.PRIMARYone writable copyholds the truth (ACID)Writesnew messageswriteread replicaread replicaread replica... 50 globally distributed replicasreplicateReadshistory, sidebarsreads fan out to the replicas
Notes ↗
M1

SQL Problem Solving: Reading Complex Queries

Read a query in execution order: FROM, then WHERE, then GROUP BY, then HAVING, then SELECT, then ORDER BY.

2 figures
sql-reading-queries · Example 1: Finding Top Genres Per User

Example 1: watch the table shrink

Example 1: watch the table shrinkRead in execution order: FROM and JOIN, then GROUP BY, then HAVING.SELECTuser_id,genre,COUNT(*)ASsong_countFROMListenslJOINSongssONl.song_id=s.song_idWHEREgenreISNOTNULLGROUPBYuser_id,genreHAVINGCOUNT(*)>=2FROM + JOIN9 rows, each with its genrenamesonggenreMickeyEvermorePopMickeyWillowPopMickeyYesterdayClassicMinnieWillowPopMinnieYellow SubClassicMinnieHey JudeClassicDaffyEvermorePopDaffyWillowPopDaffyYesterdayClassicgroupGROUP BY9 rows -> 6 user-genre groupsnamegenrecountMickeyPop2MickeyClassic1MinniePop1MinnieClassic2DaffyPop2DaffyClassic1filterHAVING >= 26 groups -> 3 survivenamegenrecountMickeyPop2MickeyClassic1MinniePop1MinnieClassic2DaffyPop2DaffyClassic13 user-genre pairs survive: Mickey-Pop, Minnie-Classic, Daffy-Pop.Example 1: watch the table shrinkRead in execution order: FROM and JOIN, then GROUP BY, then HAVING. The table transforms clause by clause; rows the final clause drops are greyed and struck through, survivors carry a green check. Color key: grey strike = a dropped row, green check = a surviving row.
Notes ↗
sql-reading-queries · Example 2: Finding Power Users

Example 2: three CTE layers, read inside-out

Example 2: three CTE layers, read inside-outPer-user metrics, then a percentile rank, then the top-half filter.WITHuser_statsAS(...per-userCOUNT,AVG...),ranked_usersAS(...PERCENT_RANK()OVER(ORDERBYavg_ratingDESC)...)SELECTname,listen_count,avg_ratingFROMranked_usersJOINUsersWHERErating_rank<0.5CTE 1: user_statsper-user metricsnamecountavgMickey34.2Minnie34.4Daffy33.9rankCTE 2: ranked_usersadd percentile ranknameavgrankMinnie4.40.0Mickey4.20.5Daffy3.91.0filterWHERE rank < 0.5keep the top halfnameavgrankMinnie4.40.0Mickey4.20.5Daffy3.91.0Only Minnie clears the top half: rank 0.0 < 0.5, average 4.4.Example 2: three CTE layers, read inside-outPer-user metrics, then a percentile rank, then the top-half filter. The table transforms clause by clause; rows the final clause drops are greyed and struck through, survivors carry a green check. Color key: grey strike = a dropped row, green check = a surviving row.
Notes ↗
M1

SQL Problem Solving: Writing Queries

Most queries you write are one of five shapes: filter to a subset, rank within groups, accumulate over time, compare to a group, or flag outliers.

5 figures
sql-writing-queries · Master These 5 Patterns

The Funnel pattern: keep listeners, subtract sharers

The Funnel pattern: keep listeners, subtract sharersA funnel narrowing from played 1000 to playlist 450 to shared 87, paired with a SELECT ... WHERE user_id IN (listens) AND user_id NOT IN (shares) query.The Funnel: who did A but not BEach stage keeps fewer rows. The shape narrows.Played1,0001,000Made a playlist450450dropped 550Shared8787dropped 363SELECTuser_id,nameFROMusersWHEREuser_idIN(SELECTuser_idFROMlistens)ANDuser_idNOTIN(SELECTuser_idFROMshares)
Notes ↗
sql-writing-queries · Master These 5 Patterns

The Ladder pattern: top 3 songs per genre via ROW_NUMBER

The Ladder pattern: top 3 songs per genre via ROW_NUMBERA ladder of Pop songs numbered by plays; ranks 1 to 3 kept on the top rungs, rank 4 cut below the line, paired with ROW_NUMBER() OVER (PARTITION BY genre ORDER BY plays DESC) <= 3.The Ladder: top N within each groupNumber the rows per group; climb from rank 1, keep the top rungs.1Levitating9802Blinding Lights9003Stay8704Peaches410keep rn ≤ 3SELECT*FROM(SELECTsong,genre,plays,ROW_NUMBER()OVER(PARTITIONBYgenreORDERBYplaysDESC)ASrnFROMsongs)WHERErn<=3
Notes ↗
sql-writing-queries · Master These 5 Patterns

The Timeline pattern: running total with SUM OVER ORDER BY

The Timeline pattern: running total with SUM OVER ORDER BYA line climbing over four days as the running total accumulates 3, 8, 10, 14, paired with SUM(hours) OVER (ORDER BY date).The Timeline: a running total over timeSUM over an ordered window. Add each day's hours; the line only climbs.cumulative hours3Mon+38Tue+510Wed+214Thu+4SELECTdate,hours,SUM(hours)OVER(ORDERBYdate)AScumulativeFROMdaily_listening
Notes ↗
sql-writing-queries · Master These 5 Patterns

The Comparison pattern: plays minus the genre average

The Comparison pattern: plays minus the genre averageBars for three Pop artists against a dashed genre-average line, two above and one below, paired with plays - AVG(plays) OVER (PARTITION BY genre).The Comparison: a row against its group averageSubtract the group's AVG, computed over the partition, from each row.Dua Lipa+700Doja Cat+100Sia-800genre avgSELECTartist_name,genre,plays,plays-AVG(plays)OVER(PARTITIONBYgenre)ASvs_avgFROMartist_stats
Notes ↗
sql-writing-queries · Master These 5 Patterns

The Exception pattern: flag the listen spike with LAG

The Exception pattern: flag the listen spike with LAGA scatter of daily listens sitting near a normal band, with Thursday spiking to 310, a 138 percent jump, circled in red, paired with LAG(listens) and a percent-change > 50 filter.The Exception: find the outlierLAG the previous value, compute the jump, keep only the spikes.daily listensnormalMon100+20%Tue120+8%Wed130+138% spikeThu310WITHchangesAS(SELECTsong_id,date,listens,LAG(listens)OVER(PARTITIONBYsong_idORDERBYdate)ASprevFROMdaily_listens)SELECT*FROMchangesWHERE(listens-prev)*100/prev>50
Notes ↗
end of lecture 2~47 min of figures
M1 SQL 1 of 79
Notes ↗ Video ▶ Why ◎ Schedule ↗ □ keys

M1 SQL

The language, then reading and writing it against a real schema · 79 figures · ~117 min · 2 lectures at this pace