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

1
GRAIN
What is one source row? What must one answer row be?
2
FILTER
Which rows survive — raw-row test → WHERE; aggregate test → HAVING.
3
COLLAPSE or LOOK SIDEWAYS?  — the fork
Fewer rows out (one per group) → GROUP BY + aggregate. Same rows + a neighbor-aware column → WINDOW OVER(PARTITION/ORDER).
4
OUTPUT
Which columns, what order (ORDER BY), how many (LIMIT).
The cruxBox 3 is the fixed-vs-variable-window fork of SQL. Ask: does my answer have fewer rows than my input, or the same rows plus a new column?

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 partwayWhat it does NOT hand you
Tricky joins — non-equi, anti-join, semi-join, self-join pairsBox 1 says "watch the grain," not how to build the join condition
Correlated subqueries / EXISTSSits loosely under FILTER; row-by-row correlation is its own model
Window frame design — ROWS vs RANGE, multiple framesBox 3 says "look sideways," not which frame
Reshaping nested data — JSON, arrays, unnest, LATERALTransforming structure — only half-modeled
Bottom lineUse the four-box on every analytical prompt to get the skeleton fast; reach for raw SQL experience for the tricky join, frame, or correlation that bolts into it.

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
Four-box: GRAIN = one row per (region, sku) → FILTER = delivered (raw → WHERE) → COLLAPSE then SIDEWAYS (sku totals, then rank within region) → OUTPUT.
What "group" means — the word is overloadedThe group you take the top-N within is the partition = region (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;
WatchThe window column 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
Four-box: GRAIN = one row per day → FILTER = wh 10 + delivered (raw → WHERE) → COLLAPSE to daily then SIDEWAYS-backward (window SUM ordered by day) → OUTPUT.

"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;
Same query, unfoldedSolution 1's inner 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.
WatchOVER(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 vs RANGE — ROWS = position, RANGE = value (bundles ties)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.)
Frame vocabulary + reading SUM(SUM())A frame is always 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
Four-box: GRAIN = one row per warehouse-month → FILTER none → COLLAPSE to monthly then SIDEWAYS-back-one (LAG) → OUTPUT with a computed % in SELECT.

"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;
WatchFirst month's prev is NULL → mom_pct NULL by design. 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…PostgreSQLSnowflake
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_DATEsame
6 · FORMAT"label as 'YYYY-MM'"TO_CHAR(d, 'YYYY-MM')TO_CHAR(...)
The #1 pattern + interview recovery~70% of date prompts are "do X per period" = 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
Four-box (GRAIN does the work): answer GRAIN = one row per warehouse×day including empties. The source is too sparse → build the grain with a CROSS JOIN scaffold before touching shipments.

"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;
Watch · the trapThe zero rows do not exist in shipments — build them with CROSS JOIN first. The status filter lives in 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
Four-box: GRAIN = answer is one row per warehouse — so watch each join's effect on the grain. FILTER → COLLAPSE (sum) → OUTPUT.

"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;
WatchThe bug is invisible in the finished query but visible in the trace — after the second join ask "is my row count still one per shipment?" If not, every SUM/COUNT over the fanned-out grain double-counts.

Compact patterns

Three common patterns distilled to four-box + watch notes. No full trace needed.

6
Nth-highest collapse, rank, filter the rank
GRAIN one row per warehouse (collapse) → SIDEWAYS DENSE_RANK() OVER (ORDER BY rev DESC) → FILTER dr = 2.
WatchUse DENSE_RANK (1,2,2,3) not RANK (1,2,2,4) — with a tie for 1st, RANK's "2" vanishes and you return nothing.
7
Average by group, rounded one collapse, one stage
GRAIN one row per category (collapse) → ROUND(AVG(revenue), 2) → OUTPUT. No window.
WatchAVG silently skips NULL revenue (overstates if NULL means $0); COUNT(*) counts rows, COUNT(revenue) skips NULLs — pick the one the question means.
8
Deduplicate rank within key, keep rn = 1
GRAIN one row per shipment_id → SIDEWAYS ROW_NUMBER() OVER (PARTITION BY shipment_id ORDER BY ship_date DESC) → FILTER rn = 1.
WatchWithout ORDER BY inside the window, which duplicate survives is non-deterministic — always justify the tiebreak. DISTINCT only works if the whole row is identical.

Common mistakes

The error catalog. Key distinction → what you get if you pick wrong.

Key distinctionWhat you get if wrong
col = NULL vs col IS NULL= NULL is never true → 0 rows, silently
NOT IN (subquery w/ NULL) vs NOT EXISTSa 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 CTEwindow-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_RANKRANK leaves gaps after ties → "Nth-highest" can vanish
GROUP BY then WINDOWcan't rank raw rows — aggregate first, rank second
OVER(ORDER BY) vs no ORDER BYwith 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_RANKties: 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 joinjoin-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"

GRAIN
FILTER
BOX 3
OUTPUT
trace
subtlety

Worksheet B — "First and last ship date per SKU"

GRAIN
FILTER
BOX 3
OUTPUT
trace
subtlety

Worksheet C — "Each SKU's % of its category's total revenue"

GRAIN
FILTER
BOX 3
OUTPUT
trace
subtlety
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 BY grouping 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.