Skip to main content
Back to SQL Coding

SQL Coding · Cheat Sheet

Advanced Reading

Tip: Use your browser's print function (Ctrl+P / Cmd+P) to save as PDF for offline study.

Cheatsheet: Advanced Reading

One page for Topic 3 — window functions, nested logic, and date traps.


Window functions: calculation without collapse

A window function computes across a set of rows and writes the answer onto every row — the row count never changes.

GROUP BYWindow function (OVER)
Row countCollapses — one row per groupUnchanged — every row survives
ResultOne aggregate per groupThe aggregate repeated on each row of its partition
First audit checkOutput rows = number of groups?Output rows = input rows?

Inside OVER (...)

PieceMeaning
PARTITION BY xRestart the calculation for each value of x (grouping without collapse)
ORDER BY y (inside the window)Processing sequence for running calculations — unrelated to output sort order
OVER () emptyThe window is the entire result set (e.g. company total for %-of-total)
Frame (implicit)With ORDER BY, defaults to "start through current row" — that's what makes SUM(...) OVER (ORDER BY date) a running total

Ranking and ties

Ana 500, Ben 500, Cara 450:

FunctionOutputTie behavior
ROW_NUMBER()1, 2, 3Breaks ties arbitrarily unless ORDER BY has a tiebreaker — rerun, different "winner"
RANK()1, 1, 3Ties share a rank, then numbers skip
DENSE_RANK()1, 1, 2Ties share a rank, no skipping

"Top 3" via RANK() <= 3 can return 4+ rows on a tie; via ROW_NUMBER() <= 3 returns exactly 3 but may drop a tied performer. Neither is wrong — know which choice the author made.

GROUP BY + window in one query

SELECT region,
  SUM(revenue) AS region_revenue,
  ROUND(100.0 * SUM(revenue) / SUM(SUM(revenue)) OVER (), 1) AS pct_of_company
FROM sales
GROUP BY region

Grouping runs first; windows run on the grouped result. SUM(SUM(revenue)) OVER () looks like a typo — it's the standard %-of-total pattern.


The ROW_NUMBER-in-CTE pattern

You cannot filter a window function in WHERE — WHERE runs before windows are computed (WHERE rn = 1 errors). The legitimate pattern:

WITH ranked AS (
  SELECT customer_id, order_total,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
  FROM orders
)
SELECT * FROM ranked WHERE rn = 1   -- most recent order per customer

Some warehouses (Snowflake, BigQuery, DuckDB) offer QUALIFY as a shortcut; PostgreSQL and SQLite do not. AI-generated SQL sometimes emits the broken WHERE version or QUALIFY against a database that lacks it.


Reading nested logic

StructureHow to readOne row per what?
Subquery in WHERE (IN, EXISTS)Inner query first, as if it ran alone; it's a filter whose values come from dataOuter query's grain unchanged
Subquery in FROMInside-out: what table does the inner query produce?Whatever the inner query groups by
CTEs (WITH)Top-to-bottom, as a pipelineState the grain of each step — the step whose grain you can't state in one sentence is where the bug is
Scalar subquery (= (SELECT ...))Must return exactly one row — guaranteed by structure (aggregate, no GROUP BY) or just by luck?n/a — a single value
Correlated subquery (inner references outer alias)Re-runs per outer row — "the max" becomes "the max per something"Grain of the result flips

NOT IN + NULL: the detonation

WHERE customer_id NOT IN (SELECT customer_id FROM orders)

If even one row of the subquery is NULL, this returns zero rows — not wrong rows, zero. NOT IN (1, 2, NULL) requires "not equal to NULL," which is unknown, so no row ever passes. "Zero customers without orders" sounds plausible, so nobody questions it.

Fixes: NOT EXISTS (handles NULLs correctly) or WHERE customer_id IS NOT NULL inside the subquery. Plain IN degrades gently — it's specifically the negated version that detonates. Treat any NOT IN (SELECT ...) as suspect unless the subquery column is guaranteed NULL-free.


SQLite strftime cookbook

SQLite has no DATE_TRUNC; strftime does the bucketing.

GoalSQLitePostgreSQL equivalent
Month bucketstrftime('%Y-%m', created_at)DATE_TRUNC('month', created_at)
Year bucketstrftime('%Y', created_at)DATE_TRUNC('year', created_at)
Day bucketstrftime('%Y-%m-%d', created_at) or date(created_at)DATE_TRUNC('day', created_at)
Day of week (0=Sun)strftime('%w', created_at)EXTRACT(DOW FROM created_at)
Hourstrftime('%H', created_at)EXTRACT(HOUR FROM created_at)

Note: strftime returns strings — fine for grouping and sorting (ISO format sorts correctly), but they're not dates anymore. Week conventions differ across systems (Postgres 'week' truncates to Monday; many BI tools start Sunday) — same data, different weekly numbers.


Half-open date ranges beat BETWEEN

The professional filter for "all of June" on a timestamp column:

WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01'   -- [start, next_start)
PatternWhat it keepsVerdict
>= '06-01' AND < '07-01'Every June timestamp, exactly onceCorrect for any period length
BETWEEN '06-01' AND '06-30'Through midnight at the start of June 30 — excludes essentially all of June 30Silently loses ~3% of the month, all on the last day

BETWEEN reads like natural English, so AI reaches for it constantly — it doesn't know your column is a timestamp. Treat every BETWEEN on a timestamp as a bug until proven otherwise.

Also: timestamps are usually stored UTC. Bucketing in UTC vs local time (AT TIME ZONE ...) moves late-evening events across day and month boundaries — the usual cause of small, stubborn dashboard mismatches at month-end.


Cohorts: first event, then join back

A cohort groups users by when they started, then follows each group forward:

WITH first_activity AS (
  SELECT customer_id, MIN(created_at) AS first_order_at
  FROM orders
  GROUP BY 1
)
SELECT strftime('%Y-%m', f.first_order_at) AS cohort_month,
       strftime('%Y-%m', o.created_at)     AS activity_month,
       SUM(o.order_total)                  AS revenue
FROM first_activity f
JOIN orders o ON o.customer_id = f.customer_id
GROUP BY 1, 2

The MIN() is the defining move: one cohort per customer, forever. Then join back to the events table to follow the cohort over time.

Calendar metricCohort metric
"June revenue"Money that arrived in June, from every customer vintageEverything ever spent by customers acquired in June
Used forP&L (profit and loss), dashboardsRetention curves, acquisition quality

Presenting one as the other is how a company convinces itself acquisition is improving when it's just counting old customers' repeat purchases.


Traps

  • Filtering a window function in WHERE — it doesn't exist yet; wrap in a CTE and filter the outer query.
  • RANK() where ROW_NUMBER() was needed (or vice versa) — ties silently duplicate or drop rows.
  • Summing a windowed column downstream — the partition total repeats on every row; you're adding it once per row.
  • NOT IN (SELECT ...) where the subquery can contain NULL — zero rows, no error. Use NOT EXISTS.
  • = (SELECT ...) that returns one row by luck, not by structure — passes testing, blows up in production.
  • Misreading a correlated subquery as independent — the result grain flips from "one row" to "one per group."
  • BETWEEN with a date-string endpoint on a timestamp — the last day's afternoon vanishes.
  • Mixed timezone bucketing across queries — self-consistent, mutually irreconcilable.
  • Month-over-month comparison that includes the current partial month — the in-progress month always "crashes."
  • Cohort defined on the wrong event — signup vs first purchase are different populations; the defining event must be stated.

Aligned to the PostgreSQL documentation and standard SQL semantics.

Make this cheat sheet yours

Personalize this sheet — focus it however you study, or build one from the exact questions you keep getting wrong.

Sign up free to create a personalized cheat sheet.