SQL Coding · Cheat Sheet
Advanced Reading
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 BY | Window function (OVER) | |
|---|---|---|
| Row count | Collapses — one row per group | Unchanged — every row survives |
| Result | One aggregate per group | The aggregate repeated on each row of its partition |
| First audit check | Output rows = number of groups? | Output rows = input rows? |
Inside OVER (...)
| Piece | Meaning |
|---|---|
PARTITION BY x | Restart 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 () empty | The 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:
| Function | Output | Tie behavior |
|---|---|---|
ROW_NUMBER() | 1, 2, 3 | Breaks ties arbitrarily unless ORDER BY has a tiebreaker — rerun, different "winner" |
RANK() | 1, 1, 3 | Ties share a rank, then numbers skip |
DENSE_RANK() | 1, 1, 2 | Ties 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
| Structure | How to read | One row per what? |
|---|---|---|
Subquery in WHERE (IN, EXISTS) | Inner query first, as if it ran alone; it's a filter whose values come from data | Outer query's grain unchanged |
| Subquery in FROM | Inside-out: what table does the inner query produce? | Whatever the inner query groups by |
CTEs (WITH) | Top-to-bottom, as a pipeline | State 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.
| Goal | SQLite | PostgreSQL equivalent |
|---|---|---|
| Month bucket | strftime('%Y-%m', created_at) | DATE_TRUNC('month', created_at) |
| Year bucket | strftime('%Y', created_at) | DATE_TRUNC('year', created_at) |
| Day bucket | strftime('%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) |
| Hour | strftime('%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)
| Pattern | What it keeps | Verdict |
|---|---|---|
>= '06-01' AND < '07-01' | Every June timestamp, exactly once | Correct for any period length |
BETWEEN '06-01' AND '06-30' | Through midnight at the start of June 30 — excludes essentially all of June 30 | Silently 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 metric | Cohort metric | |
|---|---|---|
| "June revenue" | Money that arrived in June, from every customer vintage | Everything ever spent by customers acquired in June |
| Used for | P&L (profit and loss), dashboards | Retention 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()whereROW_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.