Window Functions
A window function computes a value for each row from a set of related rows — rank, running total, previous value — without collapsing the rows the way GROUP BY does.
OVER: the window
f(...) OVER (PARTITION BY p ORDER BY o) evaluates f for each row using the rows in its window: the rows with the same partition values, ordered by o, and — by default — from the start of the partition up to the current row and its peers. The row itself is kept; the function just adds a column. That is the whole difference from GROUP BY, which produces one row per group.
PARTITION BY is optional (one window for everything). ORDER BY is optional for ranking-free aggregates (sum(x) OVER () gives the grand total on every row). Several window functions in one query may share or differ in their windows; each is computed after WHERE, GROUP BY and HAVING, and before ORDER BY and LIMIT.
The functions
Ranking: row_number() numbers 1, 2, 3 with no ties; rank() gives ties the same number and skips (1, 2, 2, 4); dense_rank() gives ties the same number and does not skip (1, 2, 2, 3); ntile(n) splits into n buckets. Offset: lag(x, k) and lead(x, k) read the value k rows behind or ahead; first_value, last_value, nth_value read from the frame. Aggregates: any aggregate over the window — sum(x) OVER (ORDER BY date) is a running total, avg(x) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) is a 7-row moving average.
Frames and the LAST_VALUE surprise
When ORDER BY is present the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — everything up to the current row, including its peers. That is why sum() OVER (ORDER BY …) is a running total. It is also why last_value() with the default frame returns the *current* row: the frame ends there. Say ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING when you mean the whole partition.
ROWS counts physical rows; RANGE groups peers by value. Prefer ROWS unless you specifically want ties handled together; it is also cheaper.
Top-N per group, and where to filter
You cannot filter on a window function in the same query’s WHERE — it does not exist yet. Wrap the query in a subquery or CTE and filter there. row_number() OVER (PARTITION BY group ORDER BY score DESC) followed by WHERE rn <= N is the standard top-N-per-group and replaces a correlated subquery that would run once per group.
Cost: a window with PARTITION BY and ORDER BY needs the input sorted on (partition, order) columns — a Sort node, or an index that already provides that order. Several windows with different orderings mean several sorts.
Key points
- Window functions add a column computed over related rows; the rows are kept.
- row_number never ties; rank skips after ties; dense_rank does not.
- Default frame with ORDER BY ends at the current row — running totals work, last_value does not.
- Filter on a window result in an outer query; top-N-per-group is row_number + WHERE rn <= N.
- A window costs a sort on (partition, order) unless an index provides it.
Window functions on real rows
SELECT u.country, o.id, o.total,
row_number() OVER (PARTITION BY u.country ORDER BY o.total DESC) AS rn
FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid' AND u.country IN ('DE','NL','FR')
ORDER BY u.country, rn
LIMIT 18| # | country | id | total | rn |
|---|---|---|---|---|
| 1 | DE | 384 | 21548.61 | 1 |
| 2 | DE | 1429 | 20962.77 | 2 |
| 3 | DE | 2218 | 19742.88 | 3 |
| 4 | DE | 3119 | 19147.2 | 4 |
| 5 | DE | 2105 | 19029.66 | 5 |
| 6 | DE | 636 | 18589.72 | 6 |
| 7 | DE | 258 | 18243.83 | 7 |
| 8 | DE | 1935 | 17637.87 | 8 |
| 9 | DE | 1014 | 17110.83 | 9 |
| 10 | DE | 188 | 16743.96 | 10 |
| 11 | DE | 176 | 16280.26 | 11 |
| 12 | DE | 1914 | 16103.01 | 12 |
| 13 | DE | 1982 | 15566.25 | 13 |
| 14 | DE | 2791 | 15537.88 | 14 |
| 15 | DE | 407 | 15402.85 | 15 |
| 16 | DE | 2746 | 14853.76 | 16 |
| 17 | DE | 1177 | 14809.51 | 17 |
| 18 | DE | 201 | 14517.74 | 18 |
Try it in the playground
When to use — and when not
- Rankings, running totals, moving averages, period-over-period deltas.
- Top-N per group.
- Per-row percentage of a group total.
- A simple per-group summary with no per-row detail needed — GROUP BY is cheaper and clearer.
Failure modes
- last_value with the default frame.
- Filtering on the window function in the same WHERE.
- Nondeterministic row_number over ties, giving different results run to run.
See how this works internally →
Descend one layer: the same topic explained from the machinery up.