How to use: read the method once, then open a pattern card and watch the grain mutate stage by stage. Write the query from the trace. Cards are collapsed — the closed page is your overview.
The one idea: in pointer problems you trace the pointers; in SQL you trace the grain. Rows change shape stage by stage exactly like L/R move cell by cell.
The method
What you'll know: the fixed pipeline rows flow down, and the four questions every query answers.
The pipeline — your "tape"
SQL runs in this order, not the order you type. Amber = grain collapses; teal = rows kept, column added.
What is "grain"?
The grain is what one row represents — e.g. "one shipment line" or "one warehouse-month". Only two stages change it: GROUP BY collapses many rows into one; a one-to-many JOIN multiplies them (fan-out). Tracking the grain is the SQL version of tracking pointer positions.
The four-box — the decision engine
WHERE; aggregate test → HAVING.GROUP BY + aggregate. Same rows + a neighbor-aware column → WINDOW OVER(PARTITION/ORDER).ORDER BY), how many (LIMIT).When the four-box applies (and when it doesn't)
The one test, plus the honest limit — it gives the skeleton, not every hard sub-skill.
The one test: the four-box answers a single question — "how do I assemble an answer set out of rows that already exist?" Read rows → shape a result = it fits. A different kind of statement = it's simply irrelevant; don't force it.
Is the task: READ existing rows → shape them into a result?
│ │
YES NO
▼ ▼
four-box fits a different KIND of statement —
(analytical SELECT) four-box doesn't apply
Fully out — not a query at all (you'd never reach for the model here): writes (INSERT/UPDATE/DELETE/MERGE), DDL (CREATE/ALTER/DROP/TRUNCATE), DCL (GRANT/REVOKE), transactions (BEGIN/COMMIT), procedural (stored procs, triggers, functions, loops), utility/admin (COPY, CREATE INDEX, EXPLAIN, VACUUM, SELECT now()).
Partially in — it's a SELECT, but the four-box gives the skeleton, not the hard piece:
| Still a SELECT — four-box gets you partway | What it does NOT hand you |
|---|---|
| Tricky joins — non-equi, anti-join, semi-join, self-join pairs | Box 1 says "watch the grain," not how to build the join condition |
| Correlated subqueries / EXISTS | Sits loosely under FILTER; row-by-row correlation is its own model |
| Window frame design — ROWS vs RANGE, multiple frames | Box 3 says "look sideways," not which frame |
| Reshaping nested data — JSON, arrays, unnest, LATERAL | Transforming structure — only half-modeled |
Pattern cards
Open a card to watch the grain mutate stage by stage. Each trace builds a real query from scratch.
1
Top-N per group
grain collapses, then ranks
PARTITION BY region). The GROUP BY region, sku is a separate, upstream step — it builds each SKU's total so there's something to rank, not "the group" the pattern is named after. Rule for any "top-N / per X / for each X" prompt: X = the PARTITION BY key, not necessarily the GROUP BY key."Top 2 SKUs by revenue in each region"
SOURCE (grain = one shipment line) region sku revenue North A 100 · North A 50 · North B 120 · North C 30 · South A 80 · South B 200 ── GROUP BY region, sku ──▸ grain COLLAPSES to one row per (region, sku) North A 150 · North B 120 · North C 30 · South B 200 · South A 80 ── WINDOW ROW_NUMBER() OVER (PARTITION BY region ORDER BY rev DESC) ──▸ column ADDED North A 150 rn=1 · North B 120 rn=2 · North C 30 rn=3 · South B 200 rn=1 · South A 80 rn=2 ── WHERE rn <= 2 ──▸ drops rows ★ OUTPUT North A 150 · North B 120 · South B 200 · South A 80
WITH per_sku AS ( SELECT w.region, s.sku_id, SUM(s.revenue) AS rev FROM shipments s JOIN warehouses w ON w.warehouse_id = s.warehouse_id -- region lives on warehouses WHERE s.status = 'delivered' GROUP BY w.region, s.sku_id ), ranked AS ( SELECT region, sku_id, rev, ROW_NUMBER() OVER (PARTITION BY region ORDER BY rev DESC) AS rn FROM per_sku ) SELECT region, sku_id, rev FROM ranked WHERE rn <= 2;
rn is computed after WHERE, so you can't filter it at the same level — "add column, then filter it" forces a CTE.
2
Running total
collapse, then sum across prior rows
"Cumulative delivered revenue by day for warehouse 10"
SOURCE (one shipment line, wh=10, delivered) ── GROUP BY day ──▸ one row per day 06-01 100 · 06-01 50 · 06-02 200 · 06-03 80 06-01 150 · 06-02 200 · 06-03 80 ── WINDOW SUM(daily) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING) ──▸ each row sees PRIOR rows 06-01 150 run=150 · 06-02 200 run=350 (150+200) · 06-03 80 run=430 (350+80)
Solution 1 — nested window aggregate (one statement; SUM(SUM()))
SELECT ship_date, SUM(revenue) AS daily_rev, SUM(SUM(revenue)) OVER (ORDER BY ship_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_rev FROM shipments WHERE warehouse_id = 10 AND status = 'delivered' GROUP BY ship_date ORDER BY ship_date;
Solution 2 — CTE, no nested SUM (collapse first, then a plain window — identical result, what most people write)
WITH daily AS ( -- STEP 1: collapse to one row per day SELECT ship_date, SUM(revenue) AS daily_rev FROM shipments WHERE warehouse_id = 10 AND status = 'delivered' GROUP BY ship_date ) SELECT ship_date, daily_rev, -- STEP 2: run a total across the days SUM(daily_rev) OVER (ORDER BY ship_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_rev FROM daily ORDER BY ship_date;
SUM(revenue) is the CTE's daily_rev; its outer SUM(...) is SUM(daily_rev) OVER(...). The CTE just splits the nesting into two visible steps — reach for it whenever AGG(AGG()) is hard to read.OVER(ORDER BY) is a running frame; without ORDER BY it sees the whole partition. Default frame RANGE lumps tied dates — spell out ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Inner SUM collapses the day, outer runs across days.ROWS counts physical rows one at a time. RANGE counts by the ORDER BY value, so rows that tie share one running total — a flat spot — then it jumps. On 10, 20, 20, 30: ROWS running sum = 10, 30, 50, 80; RANGE = 10, 50, 50, 80 (both 20s = 50). Default is RANGE → write ROWS and ties never surprise you. (Here dates are unique after GROUP BY, so the two match — habit, not necessity.)ROWS|RANGE BETWEEN <start> AND <end>, picking two of: UNBOUNDED PRECEDING (top) · n PRECEDING · CURRENT ROW (here) · n FOLLOWING · UNBOUNDED FOLLOWING (bottom). Running total = "from the top, down to here." And SUM(SUM(x)) = two steps in one line: inner SUM(x)+GROUP BY collapses to a daily total, outer SUM() OVER() runs across those totals — read it running( daily( x ) ). Nest it only when you've collapsed and now want a window; a CTE (group first, then a plain window) is identical and clearer.
3
Month-over-month
collapse, then compare to prior row
"MoM revenue growth % per warehouse"
SOURCE (one shipment line) ── GROUP BY wh, month ──▸ one row per wh-month 1 Jan 100 · 1 Jan 50 1 Jan 150 · 1 Feb 200 · 1 Mar 150 1 Feb 200 · 1 Mar 150 ── WINDOW LAG(rev) OVER (PARTITION BY wh ORDER BY month) ──▸ prior-row col ADDED 1 Jan 150 prev=NULL · 1 Feb 200 prev=150 · 1 Mar 150 prev=200 ── SELECT 100.0*(rev-prev)/NULLIF(prev,0) ──▸ ★ OUTPUT 1 Jan 150 (—) · 1 Feb 200 (+33.33%) · 1 Mar 150 (-25.00%)
WITH monthly AS ( SELECT warehouse_id, DATE_TRUNC('month', ship_date) AS mth, SUM(revenue) AS rev FROM shipments GROUP BY warehouse_id, DATE_TRUNC('month', ship_date) ) SELECT warehouse_id, mth, rev, ROUND(100.0 * (rev - LAG(rev) OVER (PARTITION BY warehouse_id ORDER BY mth)) / NULLIF(LAG(rev) OVER (PARTITION BY warehouse_id ORDER BY mth), 0), 2) AS mom_pct FROM monthly ORDER BY warehouse_id, mth;
NULLIF(prev,0) guards divide-by-zero; 100.0 * forces decimal math — integer division silently returns 0%.Date-function framework — the 6 verbs (write them without googling)
Every date task is one of six jobs. Name the job, the function follows — six verbs, not forty functions. DATE_TRUNC is the workhorse (identical in PostgreSQL / Snowflake / Spark); only ADD and DIFFERENCE differ enough to name your dialect.
| Verb (the job) | Prompt says… | PostgreSQL | Snowflake |
|---|---|---|---|
| 1 · TRUNCATE | "by month / per week / daily" | DATE_TRUNC('month', d) | DATE_TRUNC('month', d) |
| 2 · EXTRACT | "the year / hour / day-of-week" | EXTRACT(YEAR FROM d) · DATE_PART('dow', d) | EXTRACT · DATE_PART |
| 3 · ADD/SUBTRACT | "30 days ago / next month" | d + INTERVAL '30 days' | DATEADD('day', -30, d) |
| 4 · DIFFERENCE | "days between / age" | d2 - d1 (→ int days) | DATEDIFF('day', d1, d2) |
| 5 · LITERAL / NOW | "since Jan 1 / today" | DATE '2026-01-01', CURRENT_DATE | same |
| 6 · FORMAT | "label as 'YYYY-MM'" | TO_CHAR(d, 'YYYY-MM') | TO_CHAR(...) |
DATE_TRUNC('unit', d) in the GROUP BY (this card's move) — just swap the unit. When you blank: (1) name the verb out loud first ("that's a truncate"); (2) lead with DATE_TRUNC (dialect-proof); (3) state your dialect on ADD/DIFF — naming it is a principal-level signal, not a gap.
4
Zero-fill
manufacture the grain with a scaffold
"Daily delivered units per warehouse, including zero-activity days"
STEP A — BUILD the spine (grain = EVERY warehouse × EVERY day, even empty) warehouses {W1, W2} CROSS JOIN days {06-01, 06-02, 06-03} → 6 rows, all exist STEP B — LEFT JOIN shipments onto the spine (grain UNCHANGED: one wh-day) W1 06-01 qty=10 · W1 06-02 qty=NULL · W1 06-03 qty=5 W2 06-01 qty=NULL · W2 06-02 qty=7 · W2 06-03 qty=NULL ── GROUP BY wh, day + COALESCE(SUM(qty),0) ──▸ ★ OUTPUT W1 06-01 10 · W1 06-02 0 · W1 06-03 5 · W2 06-01 0 · W2 06-02 7 · W2 06-03 0
WITH days AS ( SELECT generate_series(DATE '2026-06-01', DATE '2026-06-03', INTERVAL '1 day')::date AS d ), grid AS ( SELECT w.warehouse_id, d.d AS ship_date FROM warehouses w CROSS JOIN days d ) SELECT g.warehouse_id, g.ship_date, COALESCE(SUM(s.qty), 0) AS units FROM grid g LEFT JOIN shipments s ON s.warehouse_id = g.warehouse_id AND s.ship_date = g.ship_date AND s.status = 'delivered' -- filter on the JOIN, not WHERE GROUP BY g.warehouse_id, g.ship_date ORDER BY g.warehouse_id, g.ship_date;
ON: move it to WHERE and the LEFT JOIN collapses to INNER, deleting every zero.
5
Join / fan-out
watch the grain — a join can double your SUM
"Total revenue per warehouse" (and how a join doubles it)
SOURCE shipments (grain = one shipment) ✓ correct SH1 wh1 100 · SH2 wh1 50 SUM per wh = 150 ✓ ── JOIN shipment_events (one shipment has MANY events) ──▸ grain MULTIPLIES SH1 wh1 100 E1 ← revenue 100 repeated... SH1 wh1 100 E2 ← ...once per event SH2 wh1 50 E3 SUM per wh = 250 ✗ SH1's 100 counted TWICE
Fix: aggregate shipments before joining the second one-to-many table, or use COUNT(DISTINCT shipment_id), or join to a pre-aggregated subquery.
SELECT w.warehouse_id, w.warehouse_name, COALESCE(SUM(s.revenue), 0) AS total_rev FROM warehouses w LEFT JOIN shipments s ON s.warehouse_id = w.warehouse_id -- one-to-many is fine ALONE GROUP BY w.warehouse_id, w.warehouse_name ORDER BY total_rev DESC;
Compact patterns
Three common patterns distilled to four-box + watch notes. No full trace needed.
6
Nth-highest
collapse, rank, filter the rank
DENSE_RANK() OVER (ORDER BY rev DESC) → FILTER dr = 2.
7
Average by group, rounded
one collapse, one stage
ROUND(AVG(revenue), 2) → OUTPUT. No window.
8
Deduplicate
rank within key, keep rn = 1
ROW_NUMBER() OVER (PARTITION BY shipment_id ORDER BY ship_date DESC) → FILTER rn = 1.Common mistakes
The error catalog. Key distinction → what you get if you pick wrong.
| Key distinction | What you get if wrong |
|---|---|
| col = NULL vs col IS NULL | = NULL is never true → 0 rows, silently |
| NOT IN (subquery w/ NULL) vs NOT EXISTS | a single NULL makes NOT IN return 0 rows |
| filter in WHERE vs ON (LEFT JOIN) | right-table filter in WHERE collapses LEFT → INNER |
| window fn in WHERE vs in a CTE | window-in-WHERE = syntax error; needs an outer layer |
| 100 * vs 100.0 * | integer division → wrong % (truncates to 0) |
| COUNT(*) vs COUNT(col) | * counts rows; col skips NULLs |
| RANK vs DENSE_RANK | RANK leaves gaps after ties → "Nth-highest" can vanish |
| GROUP BY then WINDOW | can't rank raw rows — aggregate first, rank second |
| OVER(ORDER BY) vs no ORDER BY | with ORDER BY = running frame; without = whole partition |
| ROWS vs RANGE (in a frame) | RANGE bundles rows tied on the sort value → tied rows share one running total; ROWS counts position |
| ROW_NUMBER vs RANK vs DENSE_RANK | ties: ROW_NUMBER splits (1,2), RANK shares+skips (1,1,3), DENSE_RANK shares+no-skip (1,1,2) |
| aggregate before vs after a 1-to-many join | join-then-sum double-counts (fan-out) |
Blank worksheets
Fill the four boxes before writing SQL, then sketch the grain trace. These three prompts force you to choose collapse-vs-sideways on something new. Print this section.
Worksheet A — "Median delivery time per region"
Worksheet B — "First and last ship date per SKU"
Worksheet C — "Each SKU's % of its category's total revenue"
Self-check (don't peek until you've filled them in)
A is a collapse — GROUP BY region + a median/percentile, one row per region. B is a collapse — MIN + MAX with GROUP BY sku, one row per SKU. C is the trick: SIDEWAYS — keep every SKU row but divide by SUM(revenue) OVER (PARTITION BY category), because each row needs its category total without collapsing. If you reached for GROUP BY on C, re-read Box 3.
What good understanding looks like
- Before writing SQL, you state the answer's grain out loud — "one row per region per month."
- You can say which pipeline stage changes the grain and which only adds a column.
- On any prompt you decide Box 3 — collapse vs. look sideways — before typing.
- You spot that filtering a window column needs a CTE, without hitting the error first.
- After a join you reflexively ask "is my row count still one per X?" to catch fan-out.
- You can name the silent-wrong-answer traps (integer division, NULL in AVG/NOT IN, LEFT→INNER).
Glossary
- Grain
- What one row represents (e.g. "one shipment line", "one warehouse-month"). The thing you trace down the pipeline.
- Collapse
- GROUP BY reducing many rows into one per group — the answer has fewer rows than the input.
- Window function
- A function (ROW_NUMBER, LAG, SUM OVER…) that adds a column computed across related rows, while keeping every row.
- Partition
- The
PARTITION BYgrouping inside a window — the set of rows each row is allowed to "see." - Frame
- The slice of the partition a running window reads — e.g.
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW("from the top, down to here"). ROWS = physical position (one row at a time); RANGE = by value (bundles rows tied on the sort column into one step). Default is RANGE — spell out ROWS so ties don't surprise you. - Fan-out
- A one-to-many join multiplying rows so an aggregate double-counts. Visible as a grain change in the trace.
- CTE
- A named subquery (
WITH … AS) — the extra layer you need to filter or reuse a window-function result. - Scaffold / spine
- A CROSS JOIN of all dimension values (every warehouse × every day) used to manufacture rows that don't exist in the source, for zero-filling.