SQL window functions
A window function does the arithmetic of an aggregate without throwing the rows away. SUM(cost) collapses twelve treatments into one number; SUM(cost) OVER () puts that same number beside all twelve.
That difference is the whole feature. If you have ever written a GROUP BY and then needed a detail column it made you discard, this is the tool you were missing.
The problem it solves
Say you want each treatment listed with what it cost, alongside what the whole year cost, so you can see the share. With GROUP BY you cannot: grouping to get the total destroys the individual rows, and not grouping means no total. The usual workaround is to run the query twice, or to join the table to a summary of itself.
OVER () is the shortcut. It says: work this aggregate out across a set of rows, but give me the answer on every row rather than instead of them.
SELECT object_title, cost,
SUM(cost) OVER () AS grand_total
FROM treatments
ORDER BY cost DESC;PARTITION BY — one window per group
An empty OVER () means the window is the whole result. PARTITION BY narrows it: the aggregate restarts for each distinct value, so every row gets its own group's total rather than the grand total.
This is GROUP BY's job description with the rows left intact, and it is what makes percentage-of-group a single query. Note the Overmantel Mirror at 44.1% of the East wing — you have the detail row and the context in the same result, which no GROUP BY can give you.
SELECT wing, object_title, cost,
SUM(cost) OVER (PARTITION BY wing) AS wing_total,
ROUND(100.0 * cost / SUM(cost) OVER (PARTITION BY wing), 1) AS pct_of_wing
FROM treatments
ORDER BY wing, cost DESC;Try it
ROW_NUMBER, RANK and DENSE_RANK — and why they differ
All three number rows in an order you choose. They only disagree when there are ties, which is exactly why most examples of them are useless: on a column with no repeated values they return three identical columns.
This table has ties on purpose. Three treatments took 6 hours. Run the query and read those three rows across.
ROW_NUMBER gives them 9, 10 and 11 — it always produces consecutive integers, so it has to break the tie somehow, and *how* is undefined. Run it twice and those three could swap. RANK gives all three 9 and then jumps to 12, leaving a gap the size of the tie. DENSE_RANK gives all three 6 and continues at 7, no gaps.
Pick by what you are doing. ROW_NUMBER when you need exactly one row per group and do not care which. RANK for a leaderboard, where two silver medals mean no bronze. DENSE_RANK when the numbers are labels for distinct levels rather than positions.
SELECT object_title, hours,
ROW_NUMBER() OVER (ORDER BY hours DESC) AS row_number,
RANK() OVER (ORDER BY hours DESC) AS rank,
DENSE_RANK() OVER (ORDER BY hours DESC) AS dense_rank
FROM treatments
ORDER BY hours DESC, object_title;Top N per group — the pattern worth memorising
"The most expensive treatment in each wing" is a question GROUP BY cannot answer. MAX(cost) gives you the number but not which object it belongs to, and adding object_title to the SELECT list breaks the query.
ROW_NUMBER partitioned by the group and ordered by what you are ranking on, then filtered to 1, answers it directly. Change the 1 to <= 3 and you have the top three per wing.
The subquery is required, and this is the one rule about window functions that catches everyone: **you cannot use a window function in WHERE**. WHERE runs before the window is computed, so the column does not exist yet. Compute it in an inner query, filter in the outer one.
SELECT wing, object_title, cost
FROM (
SELECT wing, object_title, cost,
ROW_NUMBER() OVER (PARTITION BY wing ORDER BY cost DESC) AS rn
FROM treatments
) ranked
WHERE rn = 1
ORDER BY wing;Running totals
Add ORDER BY inside the OVER clause and the window stops being the whole partition. It becomes everything up to and including the current row, in that order — which turns SUM into a running total for free.
This is the second thing OVER's ORDER BY does, and it surprises people: outside the parentheses ORDER BY sorts the output, inside them it defines what the window can see. Two different jobs, same keyword.
SELECT started_on, object_title, cost,
SUM(cost) OVER (ORDER BY started_on) AS running_total
FROM treatments
ORDER BY started_on;LAG and LEAD — comparing a row with its neighbours
LAG reaches back to the previous row in the window, LEAD reaches forward. Subtract and you have change over time in one expression, without joining the table to itself on a date offset.
The first row's LAG is NULL — there is nothing before it — so the change column is NULL too. That is correct and it is also the thing that quietly breaks a chart. COALESCE(LAG(cost) OVER (...), 0) if you want a zero there, but decide it rather than discovering it.
SELECT started_on, cost,
LAG(cost) OVER (ORDER BY started_on) AS previous,
cost - LAG(cost) OVER (ORDER BY started_on) AS change
FROM treatments
ORDER BY started_on;Where window functions run, and why it matters
The clause order is FROM, WHERE, GROUP BY, HAVING, **window functions**, SELECT, ORDER BY. Almost every error message you will get from this feature follows from that one line.
It is why WHERE cannot see a window column — WHERE has already run. It is why HAVING cannot either. It is why a window function can sit happily in ORDER BY, which runs later. And it is why a window function operates on rows that survived WHERE: filter first, and the running total runs over what is left.
You can also aggregate and window in the same query, because windows run after grouping. SUM(cost) OVER () in a grouped query gives you the total of the group totals — occasionally exactly what you want, and confusing the first time you see it.
Practice
Same table, same box above. Work each one out there before opening the answer.
1. Show every treatment with its cost and what percentage of its conservator's total that represents.
Show one answer
SELECT conservator, object_title, cost, ROUND(100.0 * cost / SUM(cost) OVER (PARTITION BY conservator), 1) AS pct_of_theirs FROM treatments ORDER BY conservator, pct_of_theirs DESC;2. Return the two longest treatments in each wing by hours.
Show one answer
SELECT wing, object_title, hours FROM ( SELECT wing, object_title, hours, ROW_NUMBER() OVER (PARTITION BY wing ORDER BY hours DESC) AS rn FROM treatments ) ranked WHERE rn <= 2 ORDER BY wing, hours DESC;3. Show a running total of hours per conservator, in date order — each conservator's total restarting at their first job.
Show one answer
SELECT conservator, started_on, hours, SUM(hours) OVER (PARTITION BY conservator ORDER BY started_on) AS running_hours FROM treatments ORDER BY conservator, started_on;4. Rank the treatments by hours with RANK, then with DENSE_RANK, and find the number where they first disagree. Then say which you would use for a leaderboard.
Show one answer
SELECT object_title, hours, RANK() OVER (ORDER BY hours DESC) AS rank, DENSE_RANK() OVER (ORDER BY hours DESC) AS dense_rank FROM treatments ORDER BY hours DESC; -- They first disagree at 14 hours: RANK says 4, DENSE_RANK says 3, -- because RANK left a gap for the two rows tied at 22. -- A leaderboard wants RANK: two in second place means nobody is third.
Common questions
- What is a window function in SQL?
- A function that computes a value across a set of related rows without collapsing them. Unlike an aggregate with GROUP BY, every input row still appears in the output.
- What is the difference between GROUP BY and OVER (PARTITION BY)?
- They define the same groups. GROUP BY returns one row per group; PARTITION BY returns every row with its group's value attached. Use PARTITION BY when you need the detail and the summary together.
- What is the difference between ROW_NUMBER, RANK and DENSE_RANK?
- They only differ on ties. ROW_NUMBER always gives consecutive integers and breaks ties arbitrarily. RANK gives tied rows the same number and then skips. DENSE_RANK gives tied rows the same number and does not skip.
- Why can't I use a window function in a WHERE clause?
- WHERE runs before window functions are computed, so the column does not exist yet. Compute it in a subquery or CTE and filter in the outer query.
- How do I get the top N rows per group?
- ROW_NUMBER() OVER (PARTITION BY the_group ORDER BY the_measure DESC) in a subquery, then filter to rn <= N in the outer query.
- What does ORDER BY inside OVER() do?
- It defines the window rather than sorting the output: the frame becomes every row up to and including the current one, which is what turns SUM into a running total.
Keep going
Reading about SQL and writing it are different skills. Accession is a museum records puzzle that makes you write it — six levels so far, nothing to install and no account.