M1

Spotify Database

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

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.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. No two usersshare 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 foreignkeys. Each must match a real id,so a listen can't point at nobody.
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.
Notes ↗
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.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 ↗
Welcome to Modern Data Systems: CS145

Teaching Team

Shiva

ShivaInstructor · CS145 '94 · Teaching since 2018

Jason Hu

Jason HuHead CA · CS145 '24

Ananya GanapathiAnanya Ganapathi

Aryan KatiyarAryan Katiyar

Ganesh Venu

Ganesh VenuCS145 '25

Jason ShinJason Shin

Owen Jung

Owen JungCS145 '25

Qirui ZhengQirui Zheng

Yixiao Zhang

Yixiao ZhangCS145 '25

Yufei LiuYufei Liu

Notes ↗
M1

SELECT-FROM-WHERE: The SQL Foundation

A basic query has three clauses.

SELECT-FROM-WHERE: The SQL Foundation

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.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 ↗

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.Keep a row only when BOTH holdSELECT user_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, 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 ↗
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.AS 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

NULL means "unknown," not 0 or empty.

NULL Values

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. 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.expressionresultBinary logic: every test is TRUE or FALSE4.5 > 4.0✓TRUE4.5 > 5.0✗FALSEAdd 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.FALSE and UNKNOWN rows drop silently.Use rating IS NULL to find NULL rows.
SQL uses three-valued logic. Comparisons with NULL evaluate to UNKNOWN. WHERE keeps only TRUE rows. FALSE and UNKNOWN rows drop silently.
Notes ↗

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.a AND babANDTTTTFFFTFFFFa OR babORTTTTFTFTTFFFNOT aaNOTTFFT
AND requires TRUE on both sides. OR requires TRUE on at least one side. NOT flips the value.
Notes ↗
What Changes When NULL Enters

How AND, OR, and NOT handle UNKNOWN

How AND, OR, and NOT handle UNKNOWNFALSE 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.Apply the complete expressionexpressionresultFALSE AND NULLFALSEfalse winsTRUE AND NULLNULLTRUE OR NULLTRUEtrue winsFALSE OR NULLNULLNOT NULLNULLNULL staysTest missingness explicitlyexpressionresultrating IS NULLTRUE / FALSEthe explicit test
FALSE decides an AND expression, and TRUE decides an OR expression. Otherwise, UNKNOWN remains. Use IS NULL to test whether a value is missing.
Notes ↗
NULL with AND/OR Logic

AND keeps one row; OR keeps seven rows, including the NULL row

AND keeps one row; OR keeps seven rows, including the NULL rowAll 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.AND: both tests must passSELECT*FROMListensWHEREuser_id=3ANDrating>4OR: either test can passSELECT*FROMListensWHEREuser_id=3ORrating>4listen_iduser_idratingpredicate114.5214.2313.9424.7524.6623.9732.9834.993NULLlisten_iduser_idratingpredicate114.5214.2313.9424.7524.6623.9732.9834.993NULLFALSEFALSEFALSEFALSEFALSEFALSEFALSETRUEUNKNOWNWHERE keeps 1 of 9 rowsTRUETRUEFALSETRUETRUEFALSETRUETRUETRUEWHERE keeps 7 of 9 rowsColor key purple bars = Daffy · strikethrough = WHERE drops the row
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.
Notes ↗

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.Query 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 returns TRUE; > and <= return UNKNOWN, which WHERE drops.
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.
Notes ↗
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.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 puts rows with the same values in the named columns into one group and produces one output row per group.

GROUP BY: Grouping and Aggregating Data

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 collapse to three rows keyed by user_id, each carrying that group's aggregate values.
Notes ↗
Simple GROUP BY

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

GROUP BY user_id: nine listens collapse into three groups, one row eachGROUP 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).-- Count each user's listensSELECTuser_id,COUNT(*)ASlisten_countFROMListensGROUPBYuser_id-- one group per unique user_idListens (9 rows, grouped by user)the inputlisten_iduser_idsong_idrating1114.52124.23163.94224.75274.66283.97312.98324.9936NULLGROUP BYGrouped by user_id (3 rows)one row per groupuser_idlisten_count132333Pluto: 0 listens, so no groupand no row.
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.
Notes ↗

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 rowsuser_id123COUNT333AVG4.24.43.9MAX4.54.74.9MIN3.93.92.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.
Notes ↗
GROUP BY Multiple Columns

Multi-column GROUP BY splits one group into finer groups

Multi-column GROUP BY splits one group into finer groupsBy 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.GROUP BY user_idMickeyuser_id = 1song_idrating14.524.263.91 groupCOUNT(*) = 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 groups
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 ↗
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.-- Unsafe: song_id is in SELECT but not in GROUP BYSELECTuser_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.
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.
Notes ↗
M1

HAVING: Filtering Group Aggregates

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)

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.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 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.
Notes ↗

HAVING COUNT(*) >= 2 on the groups from GROUP BY song_id

HAVING COUNT(*) >= 2 on the groups from GROUP BY song_idA 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.-- Find songs played at least twiceSELECT song_id, COUNT(*), AVG(rating)FROM ListensGROUP BY song_id -- Group by each songHAVING COUNT(*) >= 2 -- Keep only groups with 2+ rowsGROUP 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 with AND: a group must pass both conditions

HAVING with AND: a group must pass both conditionsA 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.-- Find active users (3+ listens) with high average ratings (>4.0)SELECT user_id, COUNT(*), AVG(rating), MIN(rating)FROM ListensGROUP BY user_idHAVING COUNT(*) >= 3 -- One HAVING clause, two conditions joined by AND AND AVG(rating) > 4.0 -- Both must be true for group to surviveGROUP 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 ↗

WHERE filters rows, then HAVING filters groups

WHERE filters rows, then HAVING filters groupsWHERE 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.-- Find users with 2+ ratings above 3.5SELECT user_id, COUNT(*), AVG(rating)FROM ListensWHERE rating > 3.5 -- 1st: WHERE filters individual rowsGROUP BY user_id -- 2nd: GROUP BY forms groups from survivorsHAVING COUNT(*) >= 2 -- 3rd: HAVING filters entire groupsWHERE rating > 3.59 -> 7 rowsuser_idrating14.514.213.924.724.623.932.934.93NULLGROUPGROUP BY -> HAVING COUNT(*) >= 23 -> 2user_idCOUNT(*)AVG(rating)134.2234.4314.93 >= 2 keep3 >= 2 keep1 >= 2 failsOutput: users 1 and 2 (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.

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.Query Ausers averaging above 4.0nameMickeyMinnieQuery Busers who played EvermorenameMickeyDaffycombine the two results four ways:UNIONeither side, dedupednameMickeyMinnieDaffyUNION ALLeither side, keep dupsnameMickeyMinnieMickeyDaffyMickey appears twiceINTERSECTin BOTH A and BnameMickeyEXCEPTin A, not in BnameMinnie
Each operator combines the two user sets differently: UNION keeps either side, INTERSECT keeps the overlap, EXCEPT keeps the left side minus the overlap.
Notes ↗
M1

JOINs: Combining Tables

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

INNER JOIN

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.-- Keep matched pairs; users without listens drop.SELECT u.user_id, u.name, l.song_id, l.ratingFROM Users uINNER 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 ⋈ Listens9 rowsuser_idnamesong_idrating1Mickey14.51Mickey24.21Mickey63.92Minnie24.72Minnie74.62Minnie83.93Daffy12.93Daffy24.93Daffy6NULLJOINON user_idINNER JOIN → matched pairs only · 9 rows
Mickey, Minnie, and Daffy each match three listens: nine output rows. Pluto has no match.
Notes ↗

Multiple JOINs: users, listens, and songs

-- Get user + song details for high-rated listens (rating > 4.0)
SELECT u.name, s.title, s.artist, l.rating
FROM Users u
INNER JOIN Listens l ON u.user_id = l.user_id
INNER JOIN Songs   s ON l.song_id = s.song_id
WHERE l.rating > 4.0
ORDER BY u.name, l.rating DESC;
Notes ↗
JOINs: Combining Tables

Multiple JOINs: five listens rated above 4.0

nametitleartistrating
DaffyWillowTaylor Swift4.9
MickeyEvermoreTaylor Swift4.5
MickeyWillowTaylor Swift4.2
MinnieWillowTaylor Swift4.7
MinnieYellow SubBeatles4.6
Notes ↗
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.-- Keep every user; missing listen columns are NULL.SELECT u.user_id, 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
The nine matching listens remain. Pluto adds one row with NULL listen columns, giving ten rows in total.
Notes ↗
Outer Joins: LEFT and RIGHT

Listens RIGHT JOIN Songs keeps the five unplayed songs

Listens RIGHT JOIN Songs keeps the five unplayed songsListens 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.-- Keep every song; missing listen columns are NULL.SELECT s.song_id, s.title, l.ratingFROM Listens lRIGHT JOIN Songs s ON l.song_id = s.song_idSongs10 rowssong_idtitle1Evermore2Willow3Shape of You4Photograph5Shivers6Yesterday7Yellow Submarine8Hey Jude9Bad Blood10DJ MixListens9 rowssong_idrating14.512.924.224.724.963.96NULL74.683.9onno matchno matchno matchno matchno matchListens RIGHT JOIN SongsINNER 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
Nine listens produce nine matched rows. Five unplayed songs each add one row with NULL listen columns, giving fourteen rows.
Notes ↗
Foreign Keys

A foreign key guarantees one direction

Every listen has a user. A user can have no listens.Listens.user_id is a non-NULL foreign key to Users.user_id, so every listen matches a user. The reverse is optional: Pluto is a user with no listens.Listens.user_id is a non-NULL foreign key to Users.user_id.Every listen has a user.The foreign key guarantees a match.A user can have no listens.Pluto exists, but has never listened.
The foreign key guarantees a user for every listen. It does not require every user to have a listen: Pluto has none.
Notes ↗
M1

Subqueries: Basic Patterns

A subquery is a SELECT nested inside another query.

Subqueries: Basic Patterns

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.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 ↗
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
FROM Users
WHERE user_id IN (
    -- Step 2: distinct users who listened to those songs.
    SELECT DISTINCT user_id
    FROM Listens
    WHERE song_id IN (
        -- Step 1 (FIRST): all Taylor Swift song IDs. Returns {1, 2, 9}.
        SELECT song_id
        FROM Songs
        WHERE artist = 'Taylor Swift'
    )
)
Notes ↗
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.1. Songs WHERE artist = 'Taylor Swift'song_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 ↗

The NULL-in-NOT-IN Trap

The NULL-in-NOT-IN TrapFor 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.Including NULLDaffy's ratings form the listSELECT listen_id, ratingFROM ListensWHERE rating NOT IN (SELECT rating FROM ListensWHERE user_id = 3)Test rating: 4.5 | List: (2.9, 4.9, NULL)4.5 != 2.9TRUE4.5 != 4.9TRUE4.5 != NULLUNKNOWNCombined with AND: UNKNOWNQuery result: 0 rowsFiltering out NULLadd AND rating IS NOT NULLSELECT listen_id, ratingFROM ListensWHERE rating NOT IN (SELECT rating FROM ListensWHERE user_id = 3AND rating IS NOT NULL)Test rating: 4.5 | List: (2.9, 4.9)4.5 != 2.9TRUE4.5 != 4.9TRUECombined with AND: TRUEQuery result: 6 rows
Notes ↗
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. 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.SELECT 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.5✓Mickey4.2✗Mickey3.9✗Minnie4.7✓Minnie4.6✓Minnie3.9✗Daffy2.9✗Daffy4.9✓DaffyNULL? 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 ↗
Correlated Subqueries: Row-by-Row Comparisons

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

Basic 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 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.Listens9 rowsnameratingMickey4.5Mickey4.2Mickey3.9Minnie4.7Minnie4.6Minnie3.9Daffy2.9Daffy4.9DaffyNULLBasic 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 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.
Notes ↗
Mental Model: A Double For-Loop

Intuition: trace the correlation with a double loop

Intuition: trace the correlation with a double loopConceptual 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.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.In this conceptual model, each user supplies the rows for their own average:u = MickeyAVG = 4.2u = MinnieAVG = 4.4u = DaffyAVG = 3.9
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.
Notes ↗
Example: Per-User Averages

The inner query follows the outer user

SELECT DISTINCT u.name
FROM Users u
JOIN Listens l ON l.user_id = u.user_id
WHERE l.rating > (
    SELECT AVG(rating)
    FROM Listens l2
    WHERE l2.user_id = u.user_id
);
Notes ↗
Example: Per-User Averages

User ratings above their average

User ratings above their averageUser 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.userratingMickey4.5Mickey4.2Mickey3.9Minnie4.7Minnie4.6Minnie3.9Daffy2.9Daffy4.9DaffyNULLtheir average4.24.24.24.44.44.43.93.93.9above their average?TRUEFALSEFALSETRUETRUEFALSEFALSETRUEUNKNOWNPluto has no listens: the INNER JOIN excludes him.A user is kept when any of their rows survives.Output: Mickey, Minnie, Daffy
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

NOT EXISTS: users with no rating above 4.5NOT 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.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 has no listens, so NOT EXISTS keeps him.NOT EXISTS resultnameMickeyPluto
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.
Notes ↗
CTEs: Common Table Expressions

CTEs: name the steps of a query

CTEs: name the steps of a query 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. 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 Named, self-documenting steps Test each intermediate result on its own Reuse a CTE transform
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.
Notes ↗
Simple CTE: HighRatedSongs

Name a result, then query it

-- Step 1: WITH names a result you can reuse
WITH HighRatedSongs AS (
    SELECT song_id, AVG(rating) AS avg_rating
    FROM Listens
    GROUP BY song_id
    HAVING AVG(rating) > 4.0
)
-- Step 2: the outer query reads it by name
SELECT s.title, h.avg_rating
FROM HighRatedSongs h
JOIN Songs s ON h.song_id = s.song_id
Notes ↗
CTEs: Common Table Expressions

From song IDs to song titles

HighRatedSongs

song_id avg_rating
2 4.6
7 4.6

Final Output

title avg_rating
Willow 4.6
Yellow Submarine 4.6

The CTE keeps the two songs averaging above 4.0. Joining to Songs adds their titles.

Notes ↗
Multiple CTEs: User Statistics

Combine two named results

-- Step 1: count every user's listens
WITH UserListenCounts AS (
    SELECT user_id, COUNT(*) AS listen_count
    FROM Listens
    GROUP BY user_id
),
-- Step 2: average only their 4+ ratings
UserHighRatings AS (
    SELECT user_id, AVG(rating) AS high_avg, COUNT(*) AS high_count
    FROM Listens
    WHERE rating >= 4.0
    GROUP BY user_id
)
-- Step 3: join both CTEs back to Users
SELECT u.name, lc.listen_count, hr.high_avg, hr.high_count
FROM Users u
LEFT JOIN UserListenCounts lc ON u.user_id = lc.user_id
LEFT JOIN UserHighRatings hr ON u.user_id = hr.user_id
Notes ↗
CTEs: Common Table Expressions

Two CTEs, two intermediate tables

UserListenCounts: all listens

user_idlisten_count
13
23
33

UserHighRatings: ratings ≥ 4.0

user_idhigh_avghigh_count
14.352
24.652
34.91

UserHighRatings filters before averaging. Pluto has no listens, so neither CTE contains his user ID.

Notes ↗
CTEs: Common Table Expressions

LEFT JOIN keeps Pluto in the final output

Final Output

namelisten_counthigh_avghigh_count
Mickey34.352
Minnie34.652
Daffy34.91
PlutoNULLNULLNULL

Both LEFT JOINs preserve every row of Users. Pluto has no matching CTE row, so the joined columns are NULL, including listen_count.

Notes ↗
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.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.
Notes ↗
GROUP BY vs PARTITION BY

GROUP BY collapses rows

-- One row per user; detail rows are gone.
SELECT user_id, AVG(rating) AS avg_rating
FROM Listens
GROUP BY user_id;
Notes ↗
GROUP BY vs PARTITION BY

OVER attaches the average to every listen

-- Keep all 9 rows; attach each user's average.
SELECT user_id, song_id, rating,
       AVG(rating) OVER (PARTITION BY user_id) AS user_avg
FROM Listens
ORDER BY user_id, song_id;
Notes ↗
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 NULLS LAST sorts each lane with NULL last, and the rank column numbers each lane from one.-- Rank each user's listens by rating, highest first.SELECTuser_id,song_id,rating,RANK()OVER(PARTITIONBYuser_idORDERBYratingDESCNULLSLAST)ASrankFROMListensStep 1: PARTITION BY user_iduser_idrating14.514.213.924.724.623.932.934.93NULLWithout PARTITION BY: one global rankingStep 2: ORDER BY rating DESC NULLS LASTuser_idrating14.514.213.924.724.623.934.932.93NULLordered: user 1ordered: user 2ordered: user 3DESC: highest first; NULLS LAST: unrated lastStep 3: assign the rankuser_idratingrank14.5114.2213.9324.7124.6223.9334.9132.923NULL3top = 1, then 2, then 3 per lane
Notes ↗
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).rating4.54.54.23.9ROW_NUMBER1234never tiesRANK1134shares 1, then SKIPSto 3 (a gap)DENSE_RANK1123shares 1, stays PACKEDat 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 ↗
M1

SQL Problem Solving: Reading and Debugging Queries

Read and debug a query by tracing its input rows through each clause.

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 user-genre pairs.-- Find user-genre pairs with at least two listens 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 surviveusergenrecount1Pop2✓1Classic12Pop12Classic2✓3Pop2✓3Classic1Result: 3 user-genre pairs.
Notes ↗
SQL Problem Solving: Reading Complex Queries

Read the Query as a Sequence of Tables

Start with the inputs, not the first line of SQL.

  1. Name one row. After FROM and JOIN, is it a listen, a user, or a user–genre pair?

  2. Read each named result. For each CTE, identify its columns and what one output row represents before reading the query that uses it.

  3. Track what changes. WHERE and HAVING remove rows or groups. GROUP BY combines rows; a window adds a value while keeping the rows.

  4. Describe the answer. Check SELECT, DISTINCT, ORDER BY, and LIMIT: which columns, which duplicates, which order, and how many rows?

Next example: join listens to users and songs → group by user and genre → keep groups with at least two listens.

Notes ↗

Reading Example 1: Finding Top Genres Per User

-- Goal: For each user, find genres with 2+ listens.
-- Read in logical order: FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY

SELECT u.name, s.genre,                   -- Step 5: project columns
       COUNT(*)      AS song_count,
       AVG(l.rating) AS avg_rating
FROM Listens l                            -- Step 1a: read Listens
JOIN Songs   s ON l.song_id = s.song_id   -- Step 1b: join Songs
JOIN Users   u ON u.user_id = l.user_id   -- Step 1c: join Users for the name
WHERE s.genre IS NOT NULL                 -- Step 2: drop NULL genres
GROUP BY u.user_id, u.name, s.genre       -- Step 3: form groups
HAVING COUNT(*) >= 2                      -- Step 4: keep groups with 2+ listens
ORDER BY u.user_id, avg_rating DESC;      -- Step 6: sort output
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.

Five Query-Writing Patterns

The Funnel: who did A but not B

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.Keep listeners outside the shares set; NOT IN assumes no NULL share user IDs.Played1,0001,000Made a playlist450450dropped 550Shared8787dropped 363SELECTuser_id,nameFROMusers-- Keep users who have listened.WHEREuser_idIN(SELECTuser_idFROMlistens)-- Exclude users who have shared.-- Assumes shares.user_id contains no NULLs.ANDuser_idNOTIN(SELECTuser_idFROMshares)the query returns the 913 who played but never shared
Notes ↗
Five Query-Writing Patterns

The Comparison: a row against its group 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). Dua Lipa is 700 plays above the genre average, Doja Cat 100 above, and Sia 800 below.Subtract the group's AVG, computed over the partition, from each row.Dua Lipa+700Doja Cat+100Sia-800genre avgSELECTartist_name,genre,plays,-- Subtract this genre's average.plays-AVG(plays)OVER(PARTITIONBYgenre)ASvs_avg-- Positive: above average; negative: below.FROMartist_stats
Notes ↗
Five Query-Writing Patterns

The Ladder: top N within each group

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. Kept: 1 Levitating, 980 plays; 2 Blinding Lights, 900; 3 Stay, 870. Cut: 4 Peaches, 410.ROW_NUMBER restarts per genre; the outer WHERE keeps rows 1 through 3.1Levitating9802Blinding Lights9003Stay8704Peaches410keep rn ≤ 3SELECT*FROM(SELECTsong,genre,plays,ROW_NUMBER()OVER(-- Number songs separately in each genre.PARTITIONBYgenre-- Most plays first.ORDERBYplaysDESC)ASrnFROMsongs)ranked-- Keep up to three songs per genre.WHERErn<=3
Notes ↗
Five Query-Writing Patterns

The Timeline: a running total over time

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).Keep one row per day; SUM adds the hours up through that date.cumulative hours3Mon+38Tue+510Wed+214Thu+4SELECTdate,hours,SUM(hours)OVER(-- Add hours from earlier dates and today.ORDERBYdate)AScumulative_hoursFROMdaily_listening
Notes ↗
Five Query-Writing Patterns

Optional: find the outlier with LAG

Optional: 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.LAG reads the previous row for each song, in date order; the first has no previous value.daily listensnormalMon100+20%Tue120+8%Wed130+138% spikeThu310WITHchangesAS(SELECTsong_id,date,listens,-- Previous count for this song.LAG(listens)OVER(PARTITIONBYsong_idORDERBYdate)ASprev_listensFROMdaily_listens),with_pctAS(SELECT*,-- Percentage change; NULL for a zero baseline.(listens-prev_listens)*100.0/NULLIF(prev_listens,0)ASpct_changeFROMchanges)SELECT*FROMwith_pct-- Keep increases over 50%.WHEREpct_change>50
Notes ↗

Query equivalence vs output equivalence

Query equivalenceSame result for every valid input.WHERE x BETWEEN 1 AND 5WHERE x >= 1 AND x <= 5Output equivalenceSame result for this data only.WHERE rating > 4.7WHERE rating >= 4.9Tomorrow a row rates 4.8:> 4.7 keeps it, >= 4.9 drops it.Any x, any table: the two are identical.Query equivalence vs output equivalenceQuery 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.
Notes ↗
Query Equivalence

Do These Two Queries Mean the Same Thing?

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.

  1. Row values: What does each row represent, and do both compute the same values? Follow joins, groups, counts, and averages.

  2. Filters: Do both keep the same rows or groups? Compare the conditions, including NULLs and thresholds.

  3. 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

GROUP BY + HAVING-- 3. Output: one name per qualifying userSELECTu.nameFROMUsersu-- Attach each listen to its user and songJOINListenslONu.user_id=l.user_idJOINSongssONl.song_id=s.song_id-- Form a group of listen rows for each userGROUPBYu.user_id,u.name-- 1. Row values: COUNT-- distinct non-NULL genres-- 2. Filters: keep groups-- with that count >= 2HAVINGCOUNT(DISTINCTs.genre)>=2CTE + WHERE-- Listens has user_id; no Users needed yetWITHUserGenreCountsAS(SELECTl.user_id,-- 1. Row values: count-- distinct non-NULL genresCOUNT(DISTINCTs.genre)ASgenre_countFROMListensl-- Attach each listen's song and genreJOINSongssONl.song_id=s.song_id-- Form a group for each user_idGROUPBYl.user_id)-- 3. Output: join Users for each nameSELECTu.nameFROMUsersuJOINUserGenreCountsugcONugc.user_id=u.user_id-- 2. Filters: keep groups-- with that count >= 2WHEREugc.genre_count>=2same distinct-genre countsame thresholdOutput on today's datanameMickeyMinnieDaffyWhy they are query-equivalent1. Row values: count distinct non-NULL genres for the same user.2. Filters: HAVING and outer WHERE both keep counts >= 2.3. Output: one name per qualifying user; shared names can repeat.Query-equivalent: users who span at least 2 genresBoth 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.
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.
Notes ↗

Query-equivalent: ratings above each user's personal average

Correlated subquery-- 3. Output: each name-rating pair onceSELECTDISTINCTu.name,l.ratingFROMUsersuJOINListenslONu.user_id=l.user_id-- 2. Filters: rating above-- the user's averageWHEREl.rating>(-- 1. Row values: average-- the selected ratingsSELECTAVG(l2.rating)FROMListensl2-- Select only this user's listensWHEREl2.user_id=u.user_id)CTE + JOINWITHUserAvgsAS(-- 1. Row values: average-- ratings for each userSELECTuser_id,AVG(rating)ASavgFROMListensGROUPBYuser_id)-- 3. Output: each name-rating pair onceSELECTDISTINCTu.name,l.ratingFROMUsersuJOINListenslONu.user_id=l.user_id-- Match each listen to its user's averageJOINUserAvgsuaONua.user_id=u.user_id-- 2. Filters: rating above-- the user's averageWHEREl.rating>ua.avgsame user averagesame comparisonOutput on today's datanameratingMickey4.5Minnie4.7Minnie4.6Daffy4.9Why they are query-equivalent1. Row values: same user ratings and average; AVG ignores NULLs.2. Filters: both test rating > that user average.3. Output: DISTINCT name-rating pairs; each pair appears once.Query-equivalent: ratings above each user's personal averageBoth 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.
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.
Notes ↗
Example 3: Top 3 Songs per User

Top 3: output-equivalent today, not query-equivalent

Window query-- Number each user's listens 1, 2, 3, ...-- A tie still gets two different numbersWITHrankedAS(SELECT*,ROW_NUMBER()OVER(-- Restart the numbering for each userPARTITIONBYuser_idORDERBYratingDESC)ASrnFROMListensWHEREratingISNOTNULL)-- Keep positions 1 to 3SELECT*FROMrankedWHERErn<=3Count querySELECT*FROMListenslWHEREl.ratingISNOTNULLAND(-- Count this user's listens rated higherSELECTCOUNT(*)FROMListensl2WHEREl2.user_id=l.user_id-- A tie does not count as higherANDl2.rating>l.rating-- Keep it when fewer than 3 rate higher)<3Mickey's songs by rating:ratingROW_NUMBERsongs rated higherWindow query keeps?Count query keeps?4.510keepkeep4.221keepkeep3.932keepkeep3.942dropkeepnew: a second 3.9Top 3: output-equivalent today, not query-equivalentTwo 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.
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.
Notes ↗
Takeaway

Output equivalence and query equivalence

Output-equivalentSame result on this database.Query-equivalentSame result on every database allowed by the schema.One valid input with different results disproves query equivalence.Output equivalence and query equivalenceOutput-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.
Notes ↗
M1

Schema Design: One Fact, One Place

Schema design decides which tables hold which columns.

A Franken-table (or wide table) repeats itself

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 as one Franken-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 1. Update Mickey changes his email. Fix every Mickey row, miss one, and now one person has two emails. 2. Insert Add a brand-new song nobody has played yet. There is no listen row to hang it on. 3. Delete Delete Mickey’s last listen and his email disappears with it. The fact had nowhere else to live.
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.
Notes ↗

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. 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
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 ↗
M1

How a Query Runs

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

How one query runs: five stagesThe 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.1-- Top 10 most-played songs-- since 2024SELECTs.title,s.artist,COUNT(*)asplaysFROMSongssJOINListenslONs.song_id=l.song_idWHEREl.listen_time>'2024-01-01'GROUPBYs.title,s.artistORDERBYplaysDESCLIMIT10;SQL text2Planscanjoingroupsort3Coordinatorsplit the scaninto tasks4Workerstasktasktasktasktask⋯partial counts5Resultcombinesorttop 101Parse and analyze. Parse the SQL and resolve every table, column, type, and expression.2Choose a plan. Pick algorithms and operator order from statistics and cost estimates.3Partition the work. Divide the scan into tasks; route matching join and group keys to the same worker.4Execute in parallel. Each worker scans its share, joins songs to listens, and computes partial counts.5Merge the result. Combine partial counts, sort them globally, and return 10 rows.
The top-10 songs query, stage by stage. The exact plan depends on the engine, its statistics, the data layout, and the available resources.
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.

What a Library Spans

A small vocabulary spans a large answerable domain

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. 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.
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.
Notes ↗
M1

Validating SQL: Prove the Result

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 FROM Users
WHERE user_id IN (SELECT user_id FROM listener_of('Taylor Swift'))
ORDER BY name;
-- Expected: Daffy, Mickey, Minnie
Notes ↗
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 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. 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
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.
Notes ↗
Verified Queries Compose

Tested definitions compose

Tested definitions composeTwo 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.listener_of(artist)who played an artist✓ testedactive_since(days)who listened recently✓ testedactive_since(7)EXCEPT listener_of('Taylor Swift')active users who skip Taylor✓ testedChange listener_of:re-run its test and the composed test above it. active_since's test stays green.
Change listener_of and its test re-runs, along with every test built on it; active_since's test is unaffected.
Notes ↗
M1

Why Learn SQL When an LLM Can Draft It?

An LLM can draft SQL quickly.

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. "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.
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.
Notes ↗

BIRD and BEAVER text-to-SQL benchmark settings

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. 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 Execution accuracy, best systems ~80% BIRD evidence supplied 10.8% BEAVER no hints (standard) 30.1% BEAVER all hints, same model
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.
Notes ↗
M1

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

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. Claude writes the SQL uses governed context 1 Their warehouse queries and retries against the real thing runs and retries on its own 2 The skill routes to ~30 governed docs semantic layer first 3 Evals one suite per domain gate launch at ~90% 4 Governed sources semantic layer + reference docs human-owned definitions, tables, joins, and filters, updated as data and logic change loaded as context failures fix sources Without skills, accuracy was at most 21%; raw query history moved it under 1 point; with skills it rose above 95%, and about 99% 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.
Notes ↗
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. The question users who don’t listen to Taylor Swift three decisions open · 16 queries Spell it out each time 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.
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.
Notes ↗
How to Start on Your Own Data

What carries across model changes

What carries across model changes 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. ILLUSTRATIVE LIBRARY GROWTH 3 functions 7 tests you start here 9 functions 21 tests each new term brings its own 22 functions 54 tests and still growing MODEL VERSIONS EVALUATED model v1 model v2 model v3 Reusable across versions · the database it runs on · the specs · the tests · the named functions The suite is what holds the meaning still. Every swap above has to pass the same tests.
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.
Notes ↗
M1

Case Study 1.2: How ChatGPT Scales PostgreSQL

ChatGPT runs on PostgreSQL with one primary and nearly 50 read replicas.

One chat request, two paths through Postgres

One chat request, two paths through PostgresA 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.Yousend a messageopen a chatChatGPT app servercalls the databaseand the modelModelruns on GPUsreply takes secondscontextreplyPostgreSQLPRIMARYevery write, ACIDwritesreplicaregion 1replicaregion 2replicaregion 3... nearly 50several per regionupdate stream (WAL)to every replicareads,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.
Notes ↗
M1 SQL 1 of 60
▶ Y Notes □ keys

M1 SQL

The language, then reading and writing it against a real schema · 60 figures · ~108 min