SQL Window Functions: The Query Feature You're Not Using
Every developer hits the same wall eventually: you need "the latest order per customer," or "the running total," or "the rank of each product in its category," and the query turns into a pile of self-joins, correlated subqueries, and muttered curses. Window functions exist precisely to make these queries trivial — and yet they're the most under-taught feature in SQL. This is the deep dive I wish I'd had: what they are, how OVER() works, and the half-dozen patterns that cover 90% of real uses.
The mental model: GROUP BY collapses, windows don't
GROUP BY takes many rows and returns one row per group. That's its job — and it's why "top N per group" is so awkward: you need the group's rows and the group-level computation together, and GROUP BY throws the rows away.
A window function keeps every row and computes a value across a related set of rows — the window. The syntax is always the same shape:
SELECT order_id, customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id) AS customer_totalFROM orders;Every row stays, and customer_total repeats the customer's sum on each of their rows. PARTITION BY splits the table into windows; ORDER BY inside OVER() orders rows within each window; SUM(amount) runs over that window instead of the whole table.
Ranking: ROW_NUMBER, RANK, DENSE_RANK
The most famous family. The differences matter:
SELECT name, score, ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num, -- 1,2,3,4 (unique) RANK() OVER (ORDER BY score DESC) AS rnk, -- 1,1,3,4 (gaps) DENSE_RANK() OVER (ORDER BY score DESC) AS d_rnk -- 1,1,2,3 (no gaps)FROM leaderboard;ROW_NUMBER()— a unique sequential number per row, ties broken arbitrarily (or by yourORDER BY).RANK()— ties share a rank, and the next rank skips: two people at #1 means the next is #3.DENSE_RANK()— ties share a rank but no gaps: #1, #1, #2.
Pick RANK() for leaderboards ("we're tied for first"), DENSE_RANK() for paginated category lists, ROW_NUMBER() for deduplication.
The dedup pattern everyone needs
De-duplicating a table with duplicates is the single most common real-world window use. Keep one row per entity, prefer the newest:
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY email ORDER BY created_at DESC ) AS rn FROM users)DELETE FROM usersWHERE id IN (SELECT id FROM ranked WHERE rn > 1);One pass, no self-join, and it reads like English: "number the rows per email, newest first, then keep only the first."
Top-N-per-group: the pattern that replaces five self-joins
The classic "three most recent orders per customer":
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC ) AS rn FROM orders)SELECT * FROM ranked WHERE rn <= 3;Without window functions this is a correlated subquery with a LIMIT per customer, or a lateral join, or — in the bad old days — a cursor. The window version is faster to write, faster to read, and usually faster to run, because the planner can execute the partition + sort once.
LAG and LEAD: look at the neighbors
LAG(col) fetches the previous row's value within the window; LEAD(col) fetches the next. This is the engine of every "change over time" query — month-over-month growth, diff detection, session boundaries:
SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_revenue, revenue - LAG(revenue) OVER (ORDER BY month) AS monthly_delta, ROUND( 100.0 * (revenue - LAG(revenue) OVER (ORDER BY month)) / NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1 ) AS pct_changeFROM monthly_revenue;The NULLIF matters: the first row has no prev_revenue, so the delta is NULL and dividing by it would blow up. LAG(col, 2) skips two back, and the third argument sets a default: LAG(col, 1, 0).
Running totals and moving averages
Give SUM an ORDER BY inside OVER() and the window becomes a running frame: from the partition start to the current row. That's a running total:
SELECT day, amount, SUM(amount) OVER (ORDER BY day) AS running_totalFROM daily_sales;Moving averages use the frame clause explicitly — ROWS BETWEEN N PRECEDING AND CURRENT ROW:
SELECT day, amount, ROUND(AVG(amount) OVER ( ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ), 2) AS seven_day_avgFROM daily_sales;That ROWS BETWEEN is the frame definition: which rows, relative to the current one, participate. RANGE is the alternative that works on values ("all rows within ±3 of my value") but ROWS is what you want 95% of the time. Frames are where window functions go from "a feature" to "a superpower" — they let you compute exactly the moving window you mean, with no off-by-one.
NTILE: buckets, quartiles, and percentiles
NTILE(n) splits the window into n buckets as evenly as possible and labels each row with its bucket. Instant percentiles:
SELECT customer_id, lifetime_value, NTILE(4) OVER (ORDER BY lifetime_value) AS quartileFROM customers;A/B test assignment, tiering, "top 10% of users" — all one line. For exact percentiles, most databases offer PERCENTILE_CONT / PERCENTILE_DISC as aggregate functions instead.
Window functions vs GROUP BY: when to use which
| Need | Tool |
|---|---|
| One row per group | GROUP BY |
| All rows, with per-group computed columns | window with PARTITION BY |
| Top-N per group | window + ROW_NUMBER, filter rn <= N |
| Running / cumulative values | window with ORDER BY |
| Compare row to neighbors | LAG / LEAD |
| Buckets / quartiles | NTILE |
A good heuristic: if your query needs a subquery in the SELECT list or a GROUP BY with a MAX-join dance, a window function is probably the cleaner tool. They don't replace GROUP BY — they complement it, and the planner is good at combining both in one pass.
Performance notes
Window functions are not free — the planner usually needs a sort per PARTITION BY/ORDER BY pair, and a bad index can turn a sweet query into a temp-table swap. The wins:
- An index on
(customer_id, created_at)makes the top-N-per-group and running-total queries read in order instead of sorting. ROW_NUMBERdedup runs in a single pass; the old self-join approach did N passes.- Frames bounded by
ROWS BETWEENlet engines use a sliding window in memory; unbounded frames accumulate.
Where to go from here
Window functions are the rare feature where learning six keywords (OVER, PARTITION BY, ROW_NUMBER, LAG, NTILE, ROWS BETWEEN) unlocks a whole class of queries. Next time you reach for a correlated subquery, pause and ask: "is this just a window?" Half the time, it is — and the query that results will be shorter, clearer, and faster than the one you were about to write.