Window functions
Aggregates that keep every row — ranking, running totals and comparing a row with its neighbour — and the default frame that surprises people.
After this lesson you can
- Explain what OVER, PARTITION BY and ORDER BY each do in a window
- Choose between ROW_NUMBER, RANK and DENSE_RANK when there are ties
- Write a running total and say why the default frame can double-count
- Filter on a window function's result
GROUP BY collapses many rows into one. A window function computes
something across a set of related rows — the window — and still returns
every row. The OVER clause defines the window:
SUM(amount) OVER (PARTITION BY customer_id ORDER BY day)
PARTITION BYsplits the rows into independent groups (no partition means the whole result is one window).ORDER BYorders the rows inside each partition, which ranking and running totals need.
Window functions are evaluated after WHERE, GROUP BY and HAVING, and
before the final ORDER BY.
Ranking and ties
Three functions number rows, and they differ only on ties:
CREATE TABLE scores (player text NOT NULL, points int NOT NULL);INSERT INTO scores VALUES ('Nino', 90), ('Ana', 85), ('Luka', 85), ('Mari', 70), ('Giorgi', 60);SELECT player, points, ROW_NUMBER() OVER (ORDER BY points DESC, player) AS row_number, RANK() OVER (ORDER BY points DESC) AS rank, DENSE_RANK() OVER (ORDER BY points DESC) AS dense_rankFROM scoresORDER BY points DESC, player;ROW_NUMBER()gives every row a different number; between tied rows the choice is arbitrary unless the window'sORDER BYbreaks the tie.RANK()gives tied rows the same number and then skips: 1, 2, 2, 4.DENSE_RANK()gives tied rows the same number and does not skip: 1, 2, 2, 3.
"Top earner per department" therefore depends on what should happen to a
tie. ROW_NUMBER() = 1 returns exactly one person per department and
silently drops the other; RANK() = 1 returns both.
Running totals and the default frame
With an ORDER BY in the window, an aggregate becomes cumulative. What it
adds up is the frame, and when you write no frame the default is
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — which includes every
row that ties with the current one on the ORDER BY:
CREATE TABLE payments ( id int PRIMARY KEY, day date NOT NULL, amount int NOT NULL);INSERT INTO payments VALUES (1, '2024-03-01', 100), (2, '2024-03-02', 50), (3, '2024-03-02', 30), (4, '2024-03-03', 20);SELECT id, day, amount, SUM(amount) OVER (ORDER BY day) AS default_frame, SUM(amount) OVER (ORDER BY day, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_frame, amount - LAG(amount) OVER (ORDER BY day, id) AS changeFROM paymentsORDER BY day, id;Payments 2 and 3 share a day, so with the default frame both see 180, the
total including each other. Spell the frame out with ROWS, and order by
something unique, to get a true row-by-row running total.
Neighbours, and filtering
LAG(x) and LEAD(x) return x from the previous or next row in the
window — the tool for "change since last time". They return NULL where
there is no such row.
A window function cannot appear in WHERE — PostgreSQL says "window
functions are not allowed in WHERE" — because WHERE runs first. Compute
it in a subquery or CTE and filter outside:
WITH ranked AS (
SELECT department, name, salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r
FROM employees
)
SELECT department, name, salary FROM ranked WHERE r = 1;
Common mistakes
ROW_NUMBERfor a question that allows ties. One of the tied rows disappears, and which one can change between runs.- A running total without a unique order or a
ROWSframe. Tied rows get the same, larger total, as payments 2 and 3 show. - Filtering on the window in
WHEREorHAVING. Neither can see it; wrap the query. - Forgetting . The ranking runs across the whole table instead of restarting for each department.
Exercise · Pro
Payments bigger than the one before
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade