We use three tables in this course: Users, Songs, and Listens.
The Three Tables
The Spotify schema: Users, Songs, and Listens, joined by two foreign keys
Figure: The Spotify schema: Users, Songs, and Listens, joined by two foreign keys
Three 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.
Users, 4 rows. Primary key user_id.
user_id
name
email
1
Mickey
mickey@example.com
2
Minnie
minnie@example.com
3
Daffy
daffy@example.com
4
Pluto
pluto@example.com
Songs, 10 rows. Primary key song_id.
song_id
title
artist
genre
1
Evermore
Taylor Swift
Pop
2
Willow
Taylor Swift
Pop
3
Shape of You
Ed Sheeran
Rock
4
Photograph
Ed Sheeran
Rock
5
Shivers
Ed Sheeran
Rock
6
Yesterday
Beatles
Classic
7
Yellow Submarine
Beatles
Classic
8
Hey Jude
Beatles
Classic
9
Bad Blood
Taylor Swift
Rock
10
DJ Mix
DJ
NULL
Listens, 9 rows. Primary key listen_id; user_id refers to Users, song_id refers to Songs.
listen_id
user_id
song_id
rating
listen_time
1
1
1
4.5
2024-08-30 14:35:00
2
1
2
4.2
NULL
3
1
6
3.9
2024-08-29 10:15:00
4
2
2
4.7
NULL
5
2
7
4.6
2024-08-28 09:20:00
6
2
8
3.9
2024-08-27 16:45:00
7
3
1
2.9
NULL
8
3
2
4.9
2024-08-26 12:30:00
9
3
6
NULL
NULL
All three tables, and the keys that tie them together: each has a primary key (user_id, song_id, listen_id), and Listens carries two foreign keys 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.
Figure: Reading SQL DDL: how the Listens table is defined
The 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.
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.
SELECT-FROM-WHERE keeps some rows, then some columns
Figure: SELECT-FROM-WHERE keeps some rows, then some columns
FROM 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.
Input: FROM Listens, 9 rows. WHERE rating > 4.0 drops listen_id 3, 6, 7 and 9 (ratings 3.9, 3.9, 2.9 and NULL, struck out in the figure); the other 5 survive.
listen_id
user_id
song_id
rating
1
1
1
4.5
2
1
2
4.2
3
1
6
3.9
4
2
2
4.7
5
2
7
4.6
6
2
8
3.9
7
3
1
2.9
8
3
2
4.9
9
3
6
NULL
Result: SELECT user_id, song_id, rating, 5 rows
user_id
song_id
rating
1
1
4.5
1
2
4.2
2
2
4.7
2
7
4.6
3
2
4.9
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.
AND keeps a row only when both conditions hold; OR when either does
Figure: AND keeps a row only when both conditions hold; OR when either does
Two 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 user_id = 1 AND rating > 4.0, 9 rows checked. AND keeps 2 rows, user 1 with 4.5 and user 1 with 4.2; the other 7 fail WHERE.
user_id
rating
1
4.5
1
4.2
1
3.9
2
4.7
2
4.6
2
3.9
3
2.9
3
4.9
3
NULL
WHERE user_id = 1 OR rating > 4.0, the same 9 rows. OR keeps 6 rows; 3 fail WHERE: user 2 with 3.9, user 3 with 2.9, and user 3 with NULL.
user_id
rating
1
4.5
1
4.2
1
3.9
2
4.7
2
4.6
2
3.9
3
2.9
3
4.9
3
NULL
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.
An alias renames the output column, but WHERE cannot use it
Figure: An alias renames the output column, but WHERE cannot use it
AS 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.
AS renames the output column: rating * 2 is named double_rating, 3 rows
rating
rating * 2
4.5
9.0
4.2
8.4
3.9
7.8
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.
Binary 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. FALSE and UNKNOWN rows drop silently; rating IS NULL is the test that returns a real TRUE or FALSE and finds NULL rows. Color key: green TRUE, red FALSE, grey question mark UNKNOWN.
Comparisons and their results, 5 rows. Any comparison with NULL is UNKNOWN; WHERE keeps only TRUE rows, and rating IS NULL is the only test that finds a NULL.
expression
result
4.5 > 4.0
TRUE
4.5 > 5.0
FALSE
4.5 > NULL
UNKNOWN
NULL = NULL
UNKNOWN
rating = NULL
UNKNOWN
SQL uses three-valued logic. Comparisons with NULL evaluate to UNKNOWN. WHERE keeps only TRUE rows. FALSE and UNKNOWN rows drop silently.
FALSE AND UNKNOWN is FALSE; TRUE AND UNKNOWN stays UNKNOWN. TRUE OR UNKNOWN is TRUE; FALSE OR UNKNOWN stays UNKNOWN. NOT UNKNOWN stays UNKNOWN. Use rating IS NULL to test whether a value is missing.
FALSE decides AND, TRUE decides OR, and otherwise UNKNOWN remains.
expression
result
FALSE AND NULL
FALSE
TRUE AND NULL
NULL
TRUE OR NULL
TRUE
FALSE OR NULL
NULL
NOT NULL
NULL
Test missingness explicitly.
expression
result
rating IS NULL
TRUE / FALSE
FALSE decides an AND expression, and TRUE decides an OR expression. Otherwise, UNKNOWN remains. Use IS NULL to test whether a value is missing.
AND keeps one row; OR keeps seven rows, including the NULL row
Figure: AND keeps one row; OR keeps seven rows, including the NULL row
All nine Listens rows pass through user_id = 3 AND rating > 4 and through the OR version. The AND predicate keeps only listen 8; listen 9 becomes UNKNOWN and drops. The OR predicate keeps seven rows, including listen 9 because user_id = 3 makes the whole predicate TRUE. Color key: purple bars mark Daffy's rows; strikethrough marks rows WHERE drops; green is TRUE, red is FALSE, grey is UNKNOWN.
WHERE applies the complete predicate to every row. The AND query keeps only listen 8; listen 9 evaluates to UNKNOWN and drops. The OR query keeps seven rows, including listen 9, because user_id = 3 makes the complete predicate TRUE. A NULL value does not drop a row by itself.
Figure: The same NULL row across three WHERE filters
The same NULL row across three WHERE filters Color key: green TRUE, red FALSE, grey UNKNOWN/NULL.
Query 1, WHERE rating IS NULL: 1 row. IS NULL is TRUE only for listen 9.
listen_id
rating
9
NULL
Query 2, WHERE rating > 4.0: 5 rows. Listen 9's NULL makes the test UNKNOWN, so it is dropped.
listen_id
rating
1
4.5
2
4.2
4
4.7
5
4.6
8
4.9
Query 3, WHERE rating <= 4.0: 3 rows. The NULL is dropped again.
listen_id
rating
3
3.9
6
3.9
7
2.9
The three filters account for every row: 5 ratings above 4, 3 ratings at or below 4, and 1 NULL. The comparison filters drop row 9 because they evaluate to UNKNOWN. IS NULL finds it.
COUNT(*)=9 counts all; COUNT(rating)=8 and AVG=4.2 skip the NULL
Figure: 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 NULL Color key: green TRUE, red FALSE, grey NULL/UNKNOWN.
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.
GROUP BY user_id: nine listens collapse into three groups, one row each
Figure: GROUP BY user_id: nine listens collapse into three groups, one row each
GROUP BY user_id collapses nine Listens rows into three groups, one per user, each with COUNT of three. Pluto has no listens, so Pluto gets no group and no output row. Color key: per-user identity bars (Mickey blue, Minnie orange, Daffy purple, Pluto cyan).
Input: Listens, 9 rows, bucketed by user_id
listen_id
user_id
song_id
rating
1
1
1
4.5
2
1
2
4.2
3
1
6
3.9
4
2
2
4.7
5
2
7
4.6
6
2
8
3.9
7
3
1
2.9
8
3
2
4.9
9
3
6
NULL
Result: grouped by user_id, 3 rows, one per bucket. Pluto has 0 listens, so no bucket and no row.
user_id
listen_count
1
3
2
3
3
3
Nine input listens collapse into three output rows, one per group, COUNT(*) = 3 each. Pluto has zero listens, so Pluto forms no group and appears in no output row.
GROUP BY user_id with four aggregates per group: COUNT, AVG, MAX, MIN
Figure: GROUP BY user_id with four aggregates per group: COUNT, AVG, MAX, MIN
Nine 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.
Input: Listens, 9 rows, in user_id order
user_id
song_id
rating
1
1
4.5
1
2
4.2
1
6
3.9
2
2
4.7
2
7
4.6
2
8
3.9
3
1
2.9
3
2
4.9
3
6
NULL
Result: grouped by user_id, 3 rows. COUNT counts every row; AVG, MAX and MIN ignore the NULL rating.
user_id
COUNT
AVG
MAX
MIN
1
3
4.2
4.5
3.9
2
3
4.4
4.7
3.9
3
3
3.9
4.9
2.9
One pass computes all four aggregates, and each sees only its own group's rows. COUNT(*) counts every row; AVG, MAX, and MIN skip the NULL.
Multi-column GROUP BY splits one group into finer groups
Figure: Multi-column GROUP BY splits one group into finer groups
By user_id, Mickey is one group of three rows with COUNT three. Adding song_id splits it into three groups of one row each, COUNT one.
Mickey's three listens (user_id 1). GROUP BY user_id puts all three in 1 bucket, COUNT(*) = 3. GROUP BY user_id, song_id splits them into 3 buckets, (1, 1), (1, 2) and (1, 6), each COUNT(*) = 1.
song_id
rating
1
4.5
2
4.2
6
3.9
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.
A grouped row cannot show a column you did not group by
Figure: A grouped row cannot show a column you did not group by
GROUP 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.
After GROUP BY user_id, 3 rows. song_id was not grouped, so no row has a single song_id to show (drawn as ?): user 1 collapsed songs 1, 2 and 6; user 2 songs 2, 7 and 8; user 3 songs 1, 2 and 6. Every column you SELECT must be grouped or aggregated.
user_id
COUNT(*)
song_id
1
3
?
2
3
?
3
3
?
GROUP BY user_id leaves one row per user, but song_id was never grouped. Mickey's group holds songs 1, 2, and 6, so the database cannot pick one song_id for his row: it errors or silently guesses.
HAVING filters groups after GROUP BY by evaluating aggregate expressions such as COUNT and AVG.
HAVING: Filtering Group Aggregates
HAVING filters groups by their AVG(rating)
Figure: HAVING filters groups by their AVG(rating)
An 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.
GROUP BY user_id, 3 groups. HAVING AVG(rating) > 4.0 keeps users 1 and 2 (4.2 and 4.4); user 3 fails (3.9 is not above 4.0), so the whole group drops. Output: Mickey, Minnie.
user_id
COUNT(*)
AVG(rating)
1
3
4.2
2
3
4.4
3
3
3.9
HAVING tests each group by its aggregate after grouping; a group passes or drops whole. Of three user groups, two pass AVG(rating) > 4.0; Daffy's 3.9 average drops his whole group.
HAVING COUNT(*) >= 2 on the groups from GROUP BY song_id
Figure: HAVING COUNT(*) >= 2 on the groups from GROUP BY song_id
A dark SQL card above the five groups GROUP BY song_id produces, drawn as a table of song_id, COUNT(*) and AVG(rating). To the right of each row a margin note gives the HAVING test that row faces, and every row that fails is struck through. A line under the table gives the output. Color key: a green margin note keeps the group, a struck row is filtered out.
The 5 groups GROUP BY song_id produces. HAVING COUNT(*) >= 2 tests the COUNT(*) column; the groups that fail it are struck through in the figure.
song_id
COUNT(*)
AVG(rating)
1
2
3.7
2
3
4.6
6
2
3.9
7
1
4.6
8
1
3.9
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.
HAVING with AND: a group must pass both conditions
Figure: HAVING with AND: a group must pass both conditions
A dark SQL card above the three groups GROUP BY user_id produces, drawn as a table of user_id, COUNT(*), AVG(rating) and MIN(rating), one row per user with a colored identity bar. To the right of each row a margin note gives the verdict from the two conditions, and the row that fails is struck through. A line under the table gives the output. Color key: Mickey blue, Minnie orange, Daffy purple; a struck row is filtered out.
The 3 groups GROUP BY user_id produces, one per user. HAVING tests COUNT(*) >= 3 AND AVG(rating) > 4.0; the group that fails is struck through in the figure.
user_id
COUNT(*)
AVG(rating)
MIN(rating)
1
3
4.2
3.9
2
3
4.4
3.9
3
3
3.9
2.9
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.
Figure: WHERE filters rows, then HAVING filters groups
WHERE rating > 3.5 strikes Daffy's 2.9 and NULL first; the survivors group into Mickey 3 rows (AVG 4.2), Minnie 3 (AVG 4.4), Daffy 1 (AVG 4.9); HAVING COUNT(*) >= 2 then drops Daffy's one-row group, leaving users 1 and 2. Color key: Mickey blue, Minnie orange, Daffy purple; struck rows/groups are filtered out.
Step 1, WHERE rating > 3.5 on the 9 Listens rows: 7 kept. It drops user 3's 2.9 (below 3.5) and user 3's NULL (the test is UNKNOWN).
user_id
rating
1
4.5
1
4.2
1
3.9
2
4.7
2
4.6
2
3.9
3
2.9
3
4.9
3
NULL
Step 2, GROUP BY user_id on the 7 survivors: 3 groups. HAVING COUNT(*) >= 2 keeps users 1 and 2; user 3's one-row group fails (1 is not >= 2). Output: users 1 and 2 (Mickey, Minnie).
user_id
COUNT(*)
AVG(rating)
1
3
4.2
2
3
4.4
3
1
4.9
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.
Two 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.
Query A, users averaging above 4.0: 2 rows
name
Mickey
Minnie
Query B, users who played Evermore: 2 rows
name
Mickey
Daffy
A UNION B, either side with duplicates removed: 3 rows
name
Mickey
Minnie
Daffy
A UNION ALL B, either side with duplicates kept: 4 rows. Mickey appears twice.
name
Mickey
Minnie
Mickey
Daffy
A INTERSECT B, in both A and B: 1 row
name
Mickey
A EXCEPT B, in A but not in B: 1 row
name
Minnie
Each operator combines the two user sets differently: UNION keeps either side, INTERSECT keeps the overlap, EXCEPT keeps the left side minus the overlap.
A JOIN pairs rows from two tables when a predicate in ON evaluates to true.
INNER JOIN
Users joined to Listens on user_id
Figure: Users joined to Listens on user_id
A 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.
Input: Users, 4 rows. Pluto has no listens, so nothing matches him.
user_id
name
1
Mickey
2
Minnie
3
Daffy
4
Pluto
Input: Listens, 9 rows
user_id
song_id
rating
1
1
4.5
1
2
4.2
1
6
3.9
2
2
4.7
2
7
4.6
2
8
3.9
3
1
2.9
3
2
4.9
3
6
NULL
Result: Users JOIN Listens ON user_id, 9 rows, matched pairs only. Pluto is dropped.
user_id
name
song_id
rating
1
Mickey
1
4.5
1
Mickey
2
4.2
1
Mickey
6
3.9
2
Minnie
2
4.7
2
Minnie
7
4.6
2
Minnie
8
3.9
3
Daffy
1
2.9
3
Daffy
2
4.9
3
Daffy
6
NULL
-- Keep matched pairs; users without listens drop.
SELECT u.user_id, u.name, l.song_id, l.rating
FROM Users u
INNER JOIN Listens l ON u.user_id = l.user_id
ORDER BY u.user_id
Mickey, Minnie, and Daffy each match three listens: nine output rows. Pluto has no match.
-- Get user + song details for high-rated listens (rating > 4.0)SELECT u.name, s.title, s.artist, l.rating
FROMUsers u
INNER JOINListens l ON u.user_id = l.user_id
INNER JOINSongs s ON l.song_id = s.song_id
WHERE l.rating > 4.0ORDER BY u.name, l.rating DESC;
Users joined to Listens on user_id; LEFT JOIN keeps the unmatched Pluto
Figure: Users joined to Listens on user_id; LEFT JOIN keeps the unmatched Pluto
A 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.
Input: Users, 4 rows. Pluto has no match.
user_id
name
1
Mickey
2
Minnie
3
Daffy
4
Pluto
Input: Listens, 9 rows
user_id
song_id
rating
1
1
4.5
1
2
4.2
1
6
3.9
2
2
4.7
2
7
4.6
2
8
3.9
3
1
2.9
3
2
4.9
3
6
NULL
Result: Users joined to Listens on user_id, 10 rows. INNER JOIN returns the first 9; LEFT JOIN also keeps Pluto, with song_id and rating NULL.
user_id
name
song_id
rating
1
Mickey
1
4.5
1
Mickey
2
4.2
1
Mickey
6
3.9
2
Minnie
2
4.7
2
Minnie
7
4.6
2
Minnie
8
3.9
3
Daffy
1
2.9
3
Daffy
2
4.9
3
Daffy
6
NULL
4
Pluto
NULL
NULL
-- Keep every user; missing listen columns are NULL.
SELECT u.user_id, u.name, l.song_id, l.rating
FROM Users u
LEFT JOIN Listens l ON u.user_id = l.user_id
ORDER BY u.user_id
The nine matching listens remain. Pluto adds one row with NULL listen columns, giving ten rows in total.
Listens RIGHT JOIN Songs keeps the five unplayed songs
Figure: Listens RIGHT JOIN Songs keeps the five unplayed songs
Listens on the left and Songs on the right 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.
Input: Listens, 9 rows, in song_id order
song_id
rating
1
4.5
1
2.9
2
4.2
2
4.7
2
4.9
6
3.9
6
NULL
7
4.6
8
3.9
Input: Songs, 10 rows. Songs 3, 4, 5, 9 and 10 have no match.
song_id
title
1
Evermore
2
Willow
3
Shape of You
4
Photograph
5
Shivers
6
Yesterday
7
Yellow Submarine
8
Hey Jude
9
Bad Blood
10
DJ Mix
Result: Listens RIGHT JOIN Songs on song_id, 14 rows. INNER JOIN returns the first 9, only songs with a listen; RIGHT JOIN keeps the last 5, the songs nobody played, with rating NULL.
song_id
title
rating
1
Evermore
4.5
1
Evermore
2.9
2
Willow
4.2
2
Willow
4.7
2
Willow
4.9
6
Yesterday
3.9
6
Yesterday
NULL
7
Yellow Submarine
4.6
8
Hey Jude
3.9
3
Shape of You
NULL
4
Photograph
NULL
5
Shivers
NULL
9
Bad Blood
NULL
10
DJ Mix
NULL
-- Keep every song; missing listen columns are NULL.
SELECT s.song_id, s.title, l.rating
FROM Listens l
RIGHT JOIN Songs s ON l.song_id = s.song_id
Nine listens produce nine matched rows. Five unplayed songs each add one row with NULL listen columns, giving fourteen rows.
A subquery is a SELECT nested inside another query.
Subqueries: Basic Patterns
A subquery nests queries inside queries, read inside-out
Figure: A subquery nests queries inside queries, read inside-out
Three 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.
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 ↗IN Subquery: Find Taylor Swift Listeners
Read the query inside-out
-- Step 3 (LAST): pick names for the users found in Step 2.SELECT name
FROMUsersWHERE user_id IN (
-- Step 2: distinct users who listened to those songs.SELECTDISTINCT user_id
FROMListensWHERE song_id IN (
-- Step 1 (FIRST): all Taylor Swift song IDs. Returns {1, 2, 9}.SELECT song_id
FROMSongsWHERE artist ='Taylor Swift'
)
)
Notes ↗IN Subquery: Find Taylor Swift Listeners
Walking an IN subquery inside-out across three steps
Figure: Walking an IN subquery inside-out across three steps
Step 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.
Step 1: Songs WHERE artist = 'Taylor Swift', 10 rows to 3. Kept: song_id 1, 2 and 9; the other 7 rows are struck out.
For rating 4.5, comparisons with 2.9 and 4.9 are TRUE; comparison with NULL is UNKNOWN. AND combines these to UNKNOWN, so WHERE drops this row. With NULL in the list, no row passes NOT IN: the query returns zero rows. Filtering NULL out of the list makes both comparisons for 4.5 TRUE, so this row passes. The filtered query returns six rows.
The query as written, 6 lines. Daffy's ratings are 2.9, 4.9 and a NULL, so the list holds a NULL. Testing rating 4.5: 4.5 != 2.9 is TRUE and 4.5 != 4.9 is TRUE, but 4.5 != NULL is UNKNOWN, and TRUE AND TRUE AND UNKNOWN is UNKNOWN. WHERE keeps only TRUE, so it drops every row: 0 rows.
Run this query
SELECT listen_id, rating
FROM Listens
WHERE rating NOT IN (
SELECT rating FROM Listens
WHERE user_id = 3
)
The fix, 7 lines: add AND rating IS NOT NULL inside the subquery, so the NULL never enters the list and it becomes (2.9, 4.9). Now 4.5 != 2.9 is TRUE and 4.5 != 4.9 is TRUE, TRUE AND TRUE is TRUE, and every matching row is kept: 6 rows.
A scalar subquery returns one value that the outer query reuses for every row
Figure: A scalar subquery returns one value that the outer query reuses for every row
The 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. Of the 9 joined rows, Mickey 4.2 is not strictly greater, the two 3.9s and Daffy's 2.9 are below, and Daffy's NULL is UNKNOWN; those 5 are struck out, leaving 4 rows. Color key: blue = the one scalar value; the per-user bars are identity; grey strike = a dropped row.
The inner SELECT AVG(rating) FROM Listens runs once and returns 4.2 (the one NULL is skipped, 8 ratings). The outer query tests each of the 9 joined rows for rating > 4.2. Kept, 4 rows: Mickey 4.5, Minnie 4.7, Minnie 4.6, Daffy 4.9. Dropped: Mickey 4.2 (not strictly greater), Mickey 3.9, Minnie 3.9, Daffy 2.9, and Daffy's NULL (the test is UNKNOWN).
name
rating
Mickey
4.5
Mickey
4.2
Mickey
3.9
Minnie
4.7
Minnie
4.6
Minnie
3.9
Daffy
2.9
Daffy
4.9
Daffy
NULL
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.
Basic gives one global average; correlated gives each user their own
Figure: Basic gives one global average; correlated gives each user their own
A Listens table grouped by user (Mickey blue, Minnie orange, Daffy purple) feeds two aggregations. A basic 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.
Input: Listens, 9 rows, grouped by user
name
rating
Mickey
4.5
Mickey
4.2
Mickey
3.9
Minnie
4.7
Minnie
4.6
Minnie
3.9
Daffy
2.9
Daffy
4.9
Daffy
NULL
Basic subquery: one global average, the same 4.2 for every user
AVG over all ratings
4.2
Correlated subquery: each user's own average
user
AVG of THEIR ratings
Mickey
4.2
Minnie
4.4
Daffy
3.9
A basic subquery produces one global value. A correlated subquery uses a value from the current outer row, so Mickey compares to 4.2, Minnie to 4.4, and Daffy to 3.9. This is the logical model; the optimizer may use an equivalent physical plan.
Intuition: trace the correlation with a double loop
Figure: Intuition: trace the correlation with a double loop
Conceptual pseudocode, not a required execution plan: 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 uses each user's ratings in this model: Mickey 4.2, Minnie 4.4, Daffy 3.9. The optimizer may choose an equivalent plan.
This double loop is how to trace the result by hand, not necessarily how the database executes it. It walks one user u at a time. The pink link marks the correlation: l2.user_id = u.user_id reads u from the outer scope, so the inner average uses only that user's rows.
SELECTDISTINCT u.name
FROMUsers u
JOINListens l ON l.user_id = u.user_id
WHERE l.rating > (
SELECTAVG(rating)
FROMListens l2
WHERE l2.user_id = u.user_id
);
User ratings above their average. One row per rating, grouped by user. The conceptual trace uses each user's ratings (the correlation). Rows that fail the comparison are struck out; the survivors give the output.
Each rating against that user's own average, 10 rows. The rows marked no are struck out in the figure; the rows marked yes survive.
user
rating
their average
above their average?
Mickey
4.5
4.2
✓ yes
Mickey
4.2
4.2
✗ no
Mickey
3.9
4.2
✗ no
Minnie
4.7
4.4
✓ yes
Minnie
4.6
4.4
✓ yes
Minnie
3.9
4.4
✗ no
Daffy
2.9
3.9
✗ no
Daffy
4.9
3.9
✓ yes
Daffy
NULL
3.9
✗ no
Pluto
(no listens)
NULL
✗ no
The outer query joins each listen to its user. For each joined row, the inner query computes that user's average: Mickey gets 4.2, Minnie gets 4.4, and Daffy gets 3.9. The comparison keeps above-average ratings, and DISTINCT returns each surviving user once. Pluto has no listens, so the inner join excludes him before the comparison.
Notes ↗Example: Existence Checks (EXISTS and NOT EXISTS)
NOT EXISTS: users with no rating above 4.5
Figure: NOT EXISTS: users with no rating above 4.5
NOT EXISTS: users with no rating above 4.5. One row per rating, grouped by user. The conceptual trace uses each user's ratings (the correlation). Rows that fail the comparison are struck out; the survivors exclude their users from the NOT EXISTS output.
Inner WHERE: ratings above 4.5 survive; crossed-out ratings do not.
user
rating
Mickey
4.5
Mickey
4.2
Mickey
3.9
Minnie
4.7
Minnie
4.6
Minnie
3.9
Daffy
2.9
Daffy
4.9
Daffy
NULL
NOT EXISTS keeps the users with none, 2 rows.
name
Mickey
Pluto
Cross out ratings that fail the inner WHERE. NOT EXISTS keeps users with no surviving rating: Mickey and Pluto. EXISTS would return Minnie and Daffy.
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 {6,7,8}, 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.
The same query, written with nested subqueries or named CTEs. Read the nested form inside out and the CTE definitions top to bottom. Both return Mickey, Minnie, and Daffy.
-- Step 1: WITH names a result you can reuseWITH HighRatedSongs AS (SELECT song_id, AVG(rating) AS avg_rating
FROMListensGROUP BY song_id
HAVINGAVG(rating) > 4.0
)
-- Step 2: the outer query reads it by nameSELECT s.title, h.avg_rating
FROM HighRatedSongs h
JOINSongs s ON h.song_id = s.song_id
-- Step 1: count every user's listensWITH UserListenCounts AS (SELECT user_id, COUNT(*) AS listen_count
FROMListensGROUP BY user_id
),
-- Step 2: average only their 4+ ratings
UserHighRatings AS (
SELECT user_id, AVG(rating) AS high_avg, COUNT(*) AS high_count
FROMListensWHERE rating >=4.0GROUP BY user_id
)
-- Step 3: join both CTEs back to UsersSELECT u.name, lc.listen_count, hr.high_avg, hr.high_count
FROMUsers u
LEFT JOIN UserListenCounts lc ON u.user_id = lc.user_id
LEFT JOIN UserHighRatings hr ON u.user_id = hr.user_id
GROUP BY collapses; a window keeps the rows in a new table plus a column
Figure: GROUP BY collapses; a window keeps the rows in a new table plus a column
The 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.
Input: Listens, 9 rows
user_id
song_id
rating
1
1
4.5
1
2
4.2
1
6
3.9
2
2
4.7
2
7
4.6
2
8
3.9
3
1
2.9
3
2
4.9
3
6
NULL
Result of GROUP BY user_id: 9 rows collapse to 3, the detail is gone
user_id
avg_rating
1
4.2
2
4.4
3
3.9
Result of AVG(rating) OVER (PARTITION BY user_id): the same 9 Listens rows, plus a user_avg column
user_id
song_id
rating
user_avg
1
1
4.5
4.2
1
2
4.2
4.2
1
6
3.9
4.2
2
2
4.7
4.4
2
7
4.6
4.4
2
8
3.9
4.4
3
1
2.9
3.9
3
2
4.9
3.9
3
6
NULL
3.9
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.
-- Keep all 9 rows; attach each user's average.SELECT user_id, song_id, rating,
AVG(rating) OVER (PARTITION BY user_id) AS user_avgFROMListensORDER BY user_id, song_id;
Figure: RANK in three steps: partition, order, assign
The RANK query, then three side-by-side tables: PARTITION BY makes three lanes, ORDER BY rating DESC NULLS LAST sorts each lane with NULL last, and the rank column numbers each lane from one.
Step 1: PARTITION BY user_id. Three lanes, rows in their original order.
user_id
rating
1
4.5
1
4.2
1
3.9
2
4.7
2
4.6
2
3.9
3
2.9
3
4.9
3
NULL
Step 2: ORDER BY rating DESC NULLS LAST. Each user's lane sorted on its own, NULL last.
user_id
rating
1
4.5
1
4.2
1
3.9
2
4.7
2
4.6
2
3.9
3
4.9
3
2.9
3
NULL
Step 3: assign the rank. The top of each lane is 1, then 2, then 3.
Four 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).
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.
-- Goal: For each user, find genres with 2+ listens.-- Read in logical order: FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BYSELECT u.name, s.genre, -- Step 5: project columnsCOUNT(*) AS song_count,
AVG(l.rating) AS avg_rating
FROMListens l -- Step 1a: read ListensJOINSongs s ON l.song_id = s.song_id -- Step 1b: join SongsJOINUsers u ON u.user_id = l.user_id -- Step 1c: join Users for the nameWHERE s.genre ISNOTNULL-- Step 2: drop NULL genresGROUP BY u.user_id, u.name, s.genre -- Step 3: form groupsHAVINGCOUNT(*) >=2-- Step 4: keep groups with 2+ listensORDER BY u.user_id, avg_rating DESC; -- Step 6: sort output
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.
Five Query-Writing Patterns
The Funnel: who did A but not B
Figure: The Funnel pattern: keep listeners, subtract sharers
A 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.
SELECT user_id, name
FROM users
-- Keep users who have listened.
WHERE user_id IN
(SELECT user_id FROM listens)
-- Exclude users who have shared.
-- Assumes shares.user_id contains no NULLs.
AND user_id NOT IN
(SELECT user_id FROM shares)
Figure: The Comparison pattern: plays minus the genre average
Bars for three Pop artists against a dashed genre-average line, two above and one below, paired with plays - AVG(plays) OVER (PARTITION BY genre). Dua Lipa is 700 plays above the genre average, Doja Cat 100 above, and Sia 800 below.
SELECT artist_name, genre, plays,
-- Subtract this genre's average.
plays - AVG(plays) OVER (
PARTITION BY genre
) AS vs_avg
-- Positive: above average; negative: below.
FROM artist_stats
Figure: The Ladder pattern: top 3 songs per genre via ROW_NUMBER
A 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. Kept: 1 Levitating, 980 plays; 2 Blinding Lights, 900; 3 Stay, 870. Cut: 4 Peaches, 410.
SELECT * FROM (
SELECT song, genre, plays,
ROW_NUMBER() OVER (
-- Number songs separately in each genre.
PARTITION BY genre
-- Most plays first.
ORDER BY plays DESC
) AS rn
FROM songs
) ranked
-- Keep up to three songs per genre.
WHERE rn <= 3
Figure: The Timeline pattern: running total with SUM OVER ORDER BY
A line climbing over four days as the running total accumulates 3, 8, 10, 14, paired with SUM(hours) OVER (ORDER BY date).
Hours added each day, four days. The line plots the running total after each day: 3 on Monday, 8 on Tuesday, 10 on Wednesday, 14 on Thursday. It only climbs.
Mon
Tue
Wed
Thu
+3
+5
+2
+4
SELECT date, hours,
SUM(hours) OVER (
-- Add hours from earlier dates and today.
ORDER BY date
) AS cumulative_hours
FROM daily_listening
A 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.
Daily listens for four days, one value per day. The figure labels each day's jump from the day before: Tuesday +20%, Wednesday +8%, Thursday +138%. Monday, Tuesday and Wednesday sit near the normal band; Thursday is circled in red as the spike, the only jump above 50 percent.
Mon
Tue
Wed
Thu
100
120
130
310
WITH changes AS (
SELECT song_id, date, listens,
-- Previous count for this song.
LAG(listens) OVER (
PARTITION BY song_id ORDER BY date
) AS prev_listens
FROM daily_listens
),
with_pct AS (
SELECT *,
-- Percentage change; NULL for a zero baseline.
(listens - prev_listens) * 100.0
/ NULLIF(prev_listens, 0) AS pct_change
FROM changes
)
SELECT * FROM with_pct
-- Keep increases over 50%.
WHERE pct_change > 50
Query equivalence: the same result for every valid input, like WHERE x BETWEEN 1 AND 5 versus x >= 1 AND x <= 5. Output equivalence: the same result on this data only, like WHERE rating > 4.7 versus rating >= 4.9, which agree until a row rates 4.8.
rating > 4.7 and rating >= 4.9 agree on today's ratings but disagree on 4.8, a valid DECIMAL(2,1) rating. One such input disproves query equivalence.
One valid counterexample disproves equivalence. To prove it instead, explain why both queries return the same result on every valid input, because the table can hold any of them tomorrow.
To find a counterexample, compare Query A and Query B in the three places below. Test them on the inputs where two versions of a query most often disagree: a tie, a NULL, a repeated value, or a missing match.
Row values: What does each row represent, and do both compute the same values? Follow joins, groups, counts, and averages.
Filters: Do both keep the same rows or groups? Compare the conditions, including NULLs and thresholds.
Output: Do both return the same columns, values, and duplicate counts? Check DISTINCT and any required ordering.
Notes ↗Example 1: Users Who Listen to Multiple Genres
Query-equivalent: users who span at least 2 genres
Figure: Query-equivalent: users who span at least 2 genres
Both count distinct genres per user, then keep counts of at least two. They are query-equivalent: the same result for every input allowed by the schema.
Result of both queries (they return the same rows), 3 rows
name
Mickey
Minnie
Daffy
Both queries count each user's distinct non-NULL genres and keep counts of at least two, so they agree on every valid input, not just today's data.
Query-equivalent: ratings above each user's personal average
Figure: Query-equivalent: ratings above each user's personal average
Both return name-rating pairs where the rating exceeds that user's average. They are query-equivalent: the same result for every input allowed by the schema.
Result of both queries (they return the same rows), 3 rows
name
Mickey
Minnie
Daffy
Both return distinct name-rating pairs where the rating exceeds that user's average. The correlated subquery and CTE compute the same average, so the queries agree on every valid input. NULL ratings never pass >; users with no rated listens never appear.
Top 3: output-equivalent today, not query-equivalent
Figure: Top 3: output-equivalent today, not query-equivalent
Two 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. Ignoring the extra rn column, compare selected listens. Add a tied 3.9: ROW_NUMBER keeps 3 rows (either tied listen can be omitted); the count query keeps 4. The tie at the cutoff is where they diverge.
Mickey's songs by rating, 4 rows. The first 3 are his real songs; the 4th is the extra tuple, one more song rated 3.9 that ties for 3rd. A check mark means the query keeps the row, a cross means it drops it.
rating
ROW_NUMBER
songs rated higher
A: rn <= 3
B: # higher < 3
4.5
1
0
✓
✓
4.2
2
1
✓
✓
3.9
3
2
✓
✓
3.9
4
2
✗
✓
On Mickey's three distinct ratings, both queries keep all three. Add a second 3.9 and they split: the window query keeps three, the count query keeps four. Compare only the Listens columns; the window query also returns rn.
Output-equivalent: same result on this database. Query-equivalent: same result on every database allowed by the schema. One valid input with different results disproves query equivalence.
Schema design decides which tables hold which columns.
A Franken-table (or wide table) repeats itself
Figure: A Franken-table (or wide 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.
Listens, one wide table, 5 rows. Mickey's email is copied on all 3 of his rows; the artist Taylor Swift is copied on 4 rows.
listen_id
name
email
title
artist
rating
1
Mickey
mickey@ex.com
Evermore
Taylor Swift
4.5
2
Mickey
mickey@ex.com
Willow
Taylor Swift
4.2
3
Mickey
mickey@ex.com
Yesterday
Beatles
3.9
7
Daffy
daffy@ex.com
Evermore
Taylor Swift
2.9
8
Daffy
daffy@ex.com
Willow
Taylor Swift
4.9
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 erase a fact you meant to keep: delete Mickey's last remaining listen and his email disappears with it, because no other row stored it.
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.
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.
A database engine turns SQL text into a physical plan of operators, such as scans, joins, aggregates, and sorts, and runs it, often split across many workers.
The Journey: From Text to Results
How one query runs: five stages
Figure: How one query runs: five stages
The top-10 most-played songs query moves through five stages, left to right. 1, parse and analyze the SQL. 2, choose a plan of operators: scan, join, group, sort. 3, the coordinator splits the scan into tasks. 4, workers run the tasks in parallel and compute partial counts. 5, merge the partial counts, sort, and return the top 10.
The top-10 songs query, stage by stage. The exact plan depends on the engine, its statistics, the data layout, and the available resources.
A library packages the SQL you repeat as named, parameterized queries, so you define a term once and compose it everywhere.
What a Library Spans
A small vocabulary spans a large answerable domain
Figure: A small vocabulary spans a large answerable domain
Three white panels. 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.
Composing verified terms keeps the answer verified: active_since(7) ∩ listener_of('Beatles') answers "active Beatles listeners." A gap like "power users" closes with one new definition; off the table questions ("which songs are good," "will Daffy churn") no definition can reach, because the answer is not in the data.
Test SQL as you test other code: run the query on a test suite and compare its output with expected rows.
Unit test: listener_of
-- Test: who listens to Taylor Swift, on the course data?SELECT name FROMUsersWHERE user_id IN (SELECT user_id FROM listener_of('Taylor Swift'))
ORDER BY name;
-- Expected: Daffy, Mickey, Minnie
Figure: One test today, a suite that holds over time
A left-to-right timeline of validation. First, one unit test checks that non-Taylor listeners return Pluto on a fixed fixture. Next, the eval set grows into a stack of tests. Then every change reruns the suite, and an unexpected definition change makes one test fail. Color key: blue marks the stages and time axis, green marks a passing test, and red marks a regression.
Validation over time. One test checks a query on a fixed fixture. Keep it, add more cases, and rerun the suite after each change. Tests provide regression evidence for the behavior they specify; they do not prove correctness for every possible dataset.
Two tested library definitions, listener_of(artist) and active_since(days), compose into a tested query, active_since(7) EXCEPT listener_of('Taylor Swift'): active users who skip Taylor. When listener_of changes, its test and the composed test re-run; the active_since test is unaffected.
Change listener_of and its test re-runs, along with every test built on it; active_since's test is unaffected.
One English question hides sixteen valid SQL queries, and different queries return different answers
Figure: 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.
Three independent choices produce sixteen executable queries. One returns only Pluto. Another returns all four users. The SQL defines which interpretation the model chooses. The English request is vague about what the user intends.
Figure: BIRD and BEAVER text-to-SQL benchmark settings
BIRD runs 12,751 questions over 95 real databases, 33 GB of schemas, across 37 domains. Its leading systems reach about 80 percent with evidence supplied. BEAVER uses private enterprise warehouses. Its best agent frameworks reach 10.8 percent in the standard setting and 30.1 percent when all subtask hints are supplied.
BIRD has 12,751 questions over 95 real databases (33 GB, 37 domains), and each question comes with an evidence string that supplies the definitions it needs: the best systems reach about 80%, humans 93%. BEAVER has 9,128 queries over 812 tables from three private enterprise warehouses, with SQL from production query logs, and withholds those definitions: the best agent frameworks on a frontier model reach 10.8%. Give the same model perfect knowledge of every subtask (the right tables, join keys, column mappings, and domain knowledge) and it reaches 30.1%. The model is unchanged; the missing structure is the gap.
Case Study 1.1: Anthropic's Self-Service Analytics Stack
Anthropic raised Claude's accuracy on its internal analytics evals from at most 21% to >95% by using skills that route it to governed definitions and verification.
What They Built
Anthropic's analytics stack
Figure: Anthropic's analytics stack
A skill routes Claude to roughly thirty governed domain files and the semantic layer before Claude writes and retries SQL against the warehouse. Evals gate each domain at about ninety percent. Governed sources are maintained across questions, and eval failures drive fixes to them. Without skills accuracy was at most twenty-one percent; raw query history moved it under one point; with skills it rose above ninety-five percent, and about ninety-nine percent in some domains.
A domain skill grounds Claude in domain-specific curated definitions: it must use the semantic layer first, then about 30 reference files on the domain's tables, columns, joins, and gotchas, and evals gate each domain before launch (initially at about 90%). With only the warehouse, Claude scored at most 21%. Raw retrieval access to thousands of prior queries moved that by less than a point. With domain skills, accuracy rose above 95%, and around 99% in some domains. These are Anthropic's own evals on its own warehouse (source), a narrower test than public benchmarks like BIRD and BEAVER.
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.
Both approaches make the current question precise. The function preserves the decisions for reuse. Define the term, call it by name, and test each decision it encodes.
An illustrative timeline. The example library grows from three verified
functions and seven tests to nine functions and twenty-one tests, then twenty-two functions
and fifty-four tests. Three model versions appear above it. The database, specifications,
tests, and named functions carry across versions, and each replacement model must pass the
existing test suite before serving questions.
The library grows one tested definition at a time: active_since(days) defines what counts as recent, and top_genre_of(user) defines ties. Swap the model and the database, definitions, and tests stay; the suite checks the new model before it answers questions.
ChatGPT runs on PostgreSQL with one primary and nearly 50 read replicas.
One chat request, two paths through Postgres
Figure: One chat request, two paths through Postgres
A user sends a message or opens a chat. The ChatGPT app server calls the database and the model; the model runs on GPUs, gets the context, and returns a reply in seconds. Writes, orange, go to one Postgres primary with ACID guarantees. The primary sends an update stream, its write-ahead log, to every read replica. Reads, blue, go to the replicas, nearly 50 of them with several in each region, and take milliseconds each.
The post does not say which ChatGPT data lives in PostgreSQL, so the figure labels the traffic only as reads and writes. The model box is how chat apps generally work, not a detail from the post. Some reads stay on the primary because they are part of a write transaction.