SQLIntermediate

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.

A table of sales rows split into two partitions, with a running total calculated down each one
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#

SQL
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:

SQL
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:

SQL
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.

repamountregion totalrunning total PARTITION BY regionPARTITION BY regionORDER BY amount DESC, rep North Ana 900 2700 900 Ben 700 2700 1600 Cleo 700 2700 2300 Dev 400 2700 2700 South Eve 1200 2150 1200 Finn 650 2150 1850 Gus 300 2150 2150 Every row stays: the window only adds a value to each one
PARTITION BY splits the rows into a North window and a South window; every row keeps its own values and gains its window's total, and with ORDER BY the sum becomes a running total

The anatomy of OVER#

SQL
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.

SQL
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_NUMBER always 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 as ORDER BY amount DESC, rep.
  • RANK gives ties the same number, then skips: 3, 3, 5. Like a race where two runners share bronze.
  • DENSE_RANK gives 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:

SQL
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:

SQL
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.

SQL
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:

SQL
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:

SQL
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:

SQL
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 BY splits rows into groups, ORDER BY orders them inside each group, and the frame (ROWS BETWEEN ...) picks the rows used for each calculation.
  • ROW_NUMBER, RANK and DENSE_RANK differ only in how they handle ties.
  • Window functions run after WHERE and GROUP BY, so filter on them in a CTE or subquery.
  • Watch the default frame: use ROWS for strict running totals, and give LAST_VALUE the whole partition.

Once your queries summarise millions of rows, speed starts to matter; SQL Indexes and Query Plans is the natural next step.