SQL Window Functions: No PARTITION BY Crashes 50M Rows
Unpartitioned SUM(revenue) OVER(ORDER BY date) on 50M rows hit 100% CPU and blocked all dashboards for 12 minutes.
- Window functions compute values across related rows without collapsing them — unlike GROUP BY which removes individual rows
- OVER() defines the window: PARTITION BY groups, ORDER BY defines ordering within the group, frame clause controls which rows are included
- ROW_NUMBER() = unique sequential rank, RANK() = gaps on ties, DENSE_RANK() = no gaps on ties
- LAG(col, n) / LEAD(col, n) access n rows before/after the current row — essential for period-over-period comparisons
- Window functions cannot be filtered in the same SELECT where they are computed — wrap in a CTE or subquery
- Biggest trap: large PARTITION BY with no supporting index causes filesort on every execution
Imagine you're in a running race and you want to know your position — but you also want to see everyone else's position on the same leaderboard. GROUP BY would give you one row per runner (just the counts). A window function gives you each runner's row plus a computed position alongside it, without collapsing the data. You can see 'Alice: 1st place', 'Bob: 2nd place', 'Clara: 2nd place (tied)' all at once.
Every senior data engineer has hit the same wall: you need running totals, percentile ranks, or 'compare this row to its neighbours' logic, and suddenly GROUP BY feels like trying to paint a wall with a toothbrush. Window functions are the feature that separates analysts who write five nested subqueries from those who write one clean, efficient query.
The key mental model: a window function computes a value for each row using a defined set of related rows (the 'window') without removing any rows from the result. GROUP BY collapses rows into summaries. Window functions add derived columns to each row while leaving all rows intact.
The OVER Clause — How Windows Are Defined
Every window function requires an OVER clause — this is what makes it a window function rather than a regular aggregate. The OVER clause defines the window: the set of rows the function can 'see' when computing its value for the current row.
OVER() with nothing inside it means the window is the entire result set. PARTITION BY inside OVER divides the result set into groups — the function computes separately within each partition. ORDER BY inside OVER defines the ordering within each partition, which is required for ranking and running total functions.
The critical difference from GROUP BY: GROUP BY collapses multiple rows into one summary row. OVER keeps all rows intact and adds a computed column alongside each row. You can see the individual transactions AND the running total on every row simultaneously.
- GROUP BY: N rows become 1 summary row — individual rows disappear
OVER(): N rows stay N rows — a computed column is added to each- PARTITION BY: divides the window into groups (like GROUP BY but without collapsing)
- ORDER BY inside OVER: controls accumulation order for running totals and rankings
- No PARTITION BY: window is the entire result set — dangerous on large tables
OVER() with no PARTITION BY = the entire table is one window = full table sort required.OVER() is what makes a function a window function — defines the rows it can see.Ranking Functions — ROW_NUMBER, RANK, and DENSE_RANK
Three ranking functions, three subtly different behaviours when ties exist.
ROW_NUMBER() assigns a unique sequential integer to every row, even when rows are tied. The order assigned to tied rows is arbitrary (unless you add a tiebreaker to ORDER BY). Use ROW_NUMBER() when you need to pick exactly one row per group — for example, the most recent order per customer.
RANK() assigns the same number to tied rows, then skips numbers. If two rows tie for position 2, both get rank 2 and the next row gets rank 4. The gap represents the number of tied rows.
DENSE_RANK() also assigns the same number to ties, but does not skip. Two rows tied at position 2 both get 2, and the next row gets 3. Use DENSE_RANK() when you want contiguous rank values regardless of ties — 'what is the 3rd highest salary' works with DENSE_RANK, not RANK.
RANK() OVER (...) AS r FROM table WHERE r = 1 throws an error — window functions are computed after WHERE filtering, so the alias is not yet available. Wrap in a CTE: WITH ranked AS (SELECT , RANK() OVER (...) AS r FROM table) SELECT * FROM ranked WHERE r = 1.ROW_NUMBER() is the correct function for 'get latest record per group' — use ORDER BY created_at DESC and filter WHERE rn = 1.ROW_NUMBER() can cause non-deterministic query results — always add a tiebreaker (usually the PK) to ORDER BY.ROW_NUMBER() in a CTE, filter WHERE rn = 1.ROW_NUMBER() — arbitrary tie-breaking produces inconsistent results.LAG and LEAD — Comparing Rows to Their Neighbours
LAG(column, n, default) returns the value from n rows before the current row within the window. LEAD(column, n, default) returns the value from n rows ahead. Both default n to 1 if omitted. The third argument sets what to return when there is no previous or next row (otherwise NULL).
These are indispensable for period-over-period analysis: month-over-month revenue change, day-over-day user growth, previous order date for a customer. Before LAG/LEAD existed, this required a self-join — and self-joins on large tables for row comparison are notoriously slow.
Window Frames — ROWS vs RANGE and the Subtle Default
The frame clause in an OVER definition controls exactly which rows are included in the window for each row's computation. This is the least understood part of window functions and the source of subtle bugs.
The default frame when you specify ORDER BY inside OVER is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — not ROWS. The RANGE default includes all rows that are tied on the ORDER BY value up to and including the current row. For dates with no duplicates this makes no visible difference. For financial data where multiple transactions share the same timestamp, the RANGE default groups all tied rows together — your running total will jump by the combined amount of all tied rows simultaneously, which is often not what you want.
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW processes each physical row individually, regardless of ties. For running totals and moving averages, ROWS is almost always the correct choice.
A Window Function Over an Unpartitioned 50M-Row Table Brought the Reporting Database Down
- Window functions without PARTITION BY operate over the entire table as one window — on large tables this is a full table sort
- Add a composite index on (PARTITION_BY_col, ORDER_BY_col) to support window function execution
- Test window function queries on production-scale row counts before deploying — performance does not extrapolate from sample data
ROW_NUMBER() OVER (...) AS rn FROM table) SELECT FROM ranked WHERE rn = 1.ROW_NUMBER() results are inconsistent between runs on tied valuesROW_NUMBER() assigns arbitrary order to ties. Add a tiebreaker column to the ORDER BY: ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC). The id column (PK) guarantees a deterministic result.Key takeaways
Common mistakes to avoid
3 patternsTrying to filter on a window function result in the same SELECT's WHERE clause
ROW_NUMBER() OVER (...) AS rn FROM table) SELECT FROM ranked WHERE rn = 1.Using OVER() without PARTITION BY on a large table
Relying on RANGE default for running totals when data has duplicate ORDER BY values
Interview Questions on This Topic
What is the difference between GROUP BY and a window function with PARTITION BY?
Frequently Asked Questions
That's SQL Advanced. Mark it forged?
3 min read · try the examples if you haven't