SQL Window Functions: Running Totals, Rankings and Moving Averages
GROUP BY squashes rows together; window functions calculate across rows and keep every one of them. Learn OVER, PARTITION BY, ROW_NUMBER, RANK, LAG and window frames with small, runnable examples.
On this page
Sooner or later a report asks for something GROUP BY can't do. Show each sale next to its region's total. Rank sales reps within each region. Show this month's revenue next to last month's. With GROUP BY you get one row per group and lose the details; people often fall back to self-joins, subqueries or a loop in application code.
Window functions solve all of these in a single query. They compute values across a set of related rows (the window) without collapsing them: every input row is still in the output, with the calculated value added as a new column.
They work in PostgreSQL, MySQL 8+, SQL Server, Oracle and SQLite 3.25+. The examples below run as-is in SQLite. If JOINs still feel shaky, start with SQL JOINs Explained with Simple Examples.
Our example data#
CREATE TABLE sales (rep TEXT, region TEXT, amount INTEGER);
INSERT INTO sales VALUES
('Ana', 'North', 900), ('Ben', 'North', 700), ('Cleo', 'North', 700), ('Dev', 'North', 400),
('Eve', 'South', 1200), ('Finn', 'South', 650), ('Gus', 'South', 300);
CREATE TABLE monthly (month TEXT, revenue INTEGER);
INSERT INTO monthly VALUES
('2026-01', 12000), ('2026-02', 15000), ('2026-03', 11000),
('2026-04', 18000), ('2026-05', 21000), ('2026-06', 19500);GROUP BY vs a window#
Here's the total per region with GROUP BY. Seven rows go in, two come out:
SELECT region, SUM(amount) AS total
FROM sales
GROUP BY region;| region | total |
|---|---|
| North | 2700 |
| South | 2150 |
Now the same SUM, but followed by OVER, which turns it into a window function:
SELECT rep, region, amount,
SUM(amount) OVER (PARTITION BY region) AS region_total,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY region), 1) AS pct_of_region
FROM sales
ORDER BY region, amount DESC;| rep | region | amount | region_total | pct_of_region |
|---|---|---|---|---|
| Ana | North | 900 | 2700 | 33.3 |
| Ben | North | 700 | 2700 | 25.9 |
| Cleo | North | 700 | 2700 | 25.9 |
| Dev | North | 400 | 2700 | 14.8 |
| Eve | South | 1200 | 2150 | 55.8 |
| Finn | South | 650 | 2150 | 30.2 |
| Gus | South | 300 | 2150 | 14.0 |
All seven rows survive. PARTITION BY region splits the rows into groups, like GROUP BY does, but instead of collapsing each group into one row, it gives every row access to its whole group. With an empty OVER (), the window is the entire result.
The anatomy of OVER#
function(...) OVER (
PARTITION BY ... -- which rows belong together (optional)
ORDER BY ... -- the order of rows inside each partition (optional)
ROWS BETWEEN ... -- which of those rows to use for this row (optional)
)Every part is optional, and each one changes the result. We'll go through them one at a time.
Ranking: ROW_NUMBER, RANK and DENSE_RANK#
Ranking functions need an ORDER BY inside the window. They differ only in how they treat ties, and our data has one: Ben and Cleo both sold 700.
SELECT rep, amount,
ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num,
RANK() OVER (ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM sales
ORDER BY amount DESC, rep;| rep | amount | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Eve | 1200 | 1 | 1 | 1 |
| Ana | 900 | 2 | 2 | 2 |
| Ben | 700 | 3 | 3 | 3 |
| Cleo | 700 | 4 | 3 | 3 |
| Finn | 650 | 5 | 5 | 4 |
| Dev | 400 | 6 | 6 | 5 |
| Gus | 300 | 7 | 7 | 6 |
ROW_NUMBERalways gives 1, 2, 3, 4… with no ties. Which of Ben and Cleo gets 3 is up to the database, unless you add a tiebreaker such asORDER BY amount DESC, rep.RANKgives ties the same number, then skips: 3, 3, 5. Like a race where two runners share bronze.DENSE_RANKgives ties the same number and doesn't skip: 3, 3, 4.
Top N per group#
"The two best reps in each region" is one of the most common reporting questions, and window functions make it easy. But you can't filter on a window function directly:
SELECT rep FROM sales WHERE ROW_NUMBER() OVER (ORDER BY amount) <= 2;
-- Error: misuse of window function ROW_NUMBER()Window functions are calculated after WHERE, GROUP BY and HAVING, just before the final ORDER BY and LIMIT. So you compute the rank in a subquery or CTE, then filter the result:
WITH ranked AS (
SELECT rep, region, amount,
ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC, rep) AS pos
FROM sales
)
SELECT region, pos, rep, amount
FROM ranked
WHERE pos <= 2
ORDER BY region, pos;| region | pos | rep | amount |
|---|---|---|---|
| North | 1 | Ana | 900 |
| North | 2 | Ben | 700 |
| South | 1 | Eve | 1200 |
| South | 2 | Finn | 650 |
Use RANK() instead of ROW_NUMBER() if tied reps should both make the list.
Running totals#
Add ORDER BY to an aggregate's window and it becomes cumulative: each row sums everything from the start of the partition up to itself.
SELECT month, revenue,
SUM(revenue) OVER (ORDER BY month) AS running_total
FROM monthly;| month | revenue | running_total |
|---|---|---|
| 2026-01 | 12000 | 12000 |
| 2026-02 | 15000 | 27000 |
| 2026-03 | 11000 | 38000 |
| 2026-04 | 18000 | 56000 |
| 2026-05 | 21000 | 77000 |
| 2026-06 | 19500 | 96500 |
Add PARTITION BY to restart the running total for each group, for example a year-to-date total that resets every January.
Comparing with the previous row: LAG and LEAD#
LAG(column) returns the value from the previous row in the window, and LEAD(column) from the next one. Month-over-month change becomes a simple subtraction:
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly;| month | revenue | prev_month | change |
|---|---|---|---|
| 2026-01 | 12000 | NULL | NULL |
| 2026-02 | 15000 | 12000 | 3000 |
| 2026-03 | 11000 | 15000 | -4000 |
| 2026-04 | 18000 | 11000 | 7000 |
| 2026-05 | 21000 | 18000 | 3000 |
| 2026-06 | 19500 | 21000 | -1500 |
The first month has no previous row, so LAG returns NULL. LAG(revenue, 1, 0) would return 0 instead, and LAG(revenue, 12) reaches back twelve rows, handy for year-over-year comparisons.
Window frames: moving averages#
The frame picks which rows of the partition count for the current row. A three-month moving average uses the current row and the two before it:
SELECT month, revenue,
ROUND(AVG(revenue) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 1) AS avg_3m
FROM monthly;| month | revenue | avg_3m |
|---|---|---|
| 2026-01 | 12000 | 12000.0 |
| 2026-02 | 15000 | 13500.0 |
| 2026-03 | 11000 | 12666.7 |
| 2026-04 | 18000 | 14666.7 |
| 2026-05 | 21000 | 16666.7 |
| 2026-06 | 19500 | 19500.0 |
The first two months average over fewer rows because there's nothing before them. Other useful frames are ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING (a centred window) and ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING (the whole partition).
The default frame has a surprise
With ORDER BY and no frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE treats rows with equal ORDER BY values as one step. Run a running total over amount DESC and Ben and Cleo, both at 700, both show 3500, because each one includes the other. Write ROWS UNBOUNDED PRECEDING when you want a strict row-by-row total.
The same default explains a classic puzzle: LAST_VALUE(revenue) OVER (ORDER BY month) returns the current row's value, not the last month's, because the default frame ends at the current row. Give it the whole partition with ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Windows over grouped results#
Window functions run after GROUP BY, so they can work on the groups themselves. Here, each region's share of the grand total: the inner SUM(amount) is the region total, and the outer SUM(...) OVER () adds up those totals across all regions:
SELECT region,
SUM(amount) AS total,
ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 1) AS pct_of_all
FROM sales
GROUP BY region;| region | total | pct_of_all |
|---|---|---|
| North | 2700 | 55.7 |
| South | 2150 | 44.3 |
Name a window you use more than once
If several columns share the same window, define it once with a WINDOW clause after WHERE/GROUP BY/HAVING: SELECT ..., FIRST_VALUE(revenue) OVER w, LAG(revenue) OVER w FROM monthly WINDOW w AS (ORDER BY month);
Cheat sheet#
| You want | Use |
|---|---|
| A group total next to every row | SUM(x) OVER (PARTITION BY g) |
| A unique row number per group | ROW_NUMBER() OVER (PARTITION BY g ORDER BY x) |
| A ranking with ties | RANK() or DENSE_RANK() |
| A running total | SUM(x) OVER (ORDER BY t ROWS UNBOUNDED PRECEDING) |
| The previous / next row's value | LAG(x) / LEAD(x) |
| A moving average | AVG(x) OVER (ORDER BY t ROWS BETWEEN n PRECEDING AND CURRENT ROW) |
| Top N per group | ROW_NUMBER() in a CTE, then WHERE pos <= N |
Recap#
- A window function is a normal function followed by
OVER (...). It calculates across related rows but keeps every row. PARTITION BYsplits rows into groups,ORDER BYorders them inside each group, and the frame (ROWS BETWEEN ...) picks the rows used for each calculation.ROW_NUMBER,RANKandDENSE_RANKdiffer only in how they handle ties.- Window functions run after
WHEREandGROUP BY, so filter on them in a CTE or subquery. - Watch the default frame: use
ROWSfor strict running totals, and giveLAST_VALUEthe whole partition.
Once your queries summarise millions of rows, speed starts to matter; SQL Indexes and Query Plans is the natural next step.