Window functions — ranking, LAG/LEAD, running totals and moving averages
Compute values across related rows without collapsing them — the most powerful analytical feature of SQL.
This lesson builds on: Date, string and numeric functions.
- Explain how OVER, PARTITION BY and ORDER BY define a window
- Use ROW_NUMBER, RANK and DENSE_RANK and explain how they differ on ties
- Compare periods with LAG and LEAD
- Build running totals and moving averages with frame clauses
- Solve "top N per group" and "latest record per entity" problems
Aggregates that do not collapse rows
GROUP BY collapses rows into one per group. A window function computes an aggregate or ranking across a set of related rows while keeping every row. Syntax:
function(...) OVER (
PARTITION BY ... -- split into independent groups (optional)
ORDER BY ... -- order within each group (needed for ranking, LAG, running totals)
ROWS BETWEEN ... -- frame: which rows around the current one (optional)
)
Start with a small monthly summary we will reuse. Each store's monthly net revenue in 2025, plus the store's annual total next to every month:
WITH monthly AS (
SELECT o.store_id, strftime('%Y-%m', o.order_date) AS month,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01'
GROUP BY 1, 2
)
SELECT store_id, month, ROUND(revenue) AS revenue,
ROUND(SUM(revenue) OVER (PARTITION BY store_id)) AS store_year_total,
ROUND(100 * revenue / SUM(revenue) OVER (PARTITION BY store_id), 1) AS pct_of_year
FROM monthly
ORDER BY store_id, month
LIMIT 6;| store_id | month | revenue | store_year_total | pct_of_year |
|---|---|---|---|---|
| 1 | 2025-01 | 10685 | 178119 | 6 |
| 1 | 2025-02 | 8579 | 178119 | 4.8 |
| 1 | 2025-03 | 14741 | 178119 | 8.3 |
| 1 | 2025-04 | 13302 | 178119 | 7.5 |
| 1 | 2025-05 | 15413 | 178119 | 8.7 |
| 1 | 2025-06 | 12165 | 178119 | 6.8 |
SUM(revenue) OVER (PARTITION BY store_id) repeats the store's annual total on each of its rows, so each month can be expressed as a share of the year — impossible with plain GROUP BY in one step.
ROW_NUMBER, RANK and DENSE_RANK
All three number rows in a defined order; they differ only on ties:
| value | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 100 | 1 | 1 | 1 |
| 90 | 2 | 2 | 2 |
| 90 | 3 | 2 | 2 |
| 80 | 4 | 4 | 3 |
ROW_NUMBER— always unique (ties broken arbitrarily unless you add a tie-breaker).RANK— ties share a rank, then gaps.DENSE_RANK— ties share a rank, no gaps.
Top N per group
The top 3 products by 2025 revenue within each category:
WITH product_rev AS (
SELECT p.category, p.product_name,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p ON p.product_id = oi.product_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01'
GROUP BY p.category, p.product_name
),
ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM product_rev
)
SELECT category, rn, product_name, ROUND(revenue) AS revenue
FROM ranked
WHERE rn <= 3
ORDER BY category, rn
LIMIT 9;| category | rn | product_name | revenue |
|---|---|---|---|
| Apparel | 1 | Sneakers Basic | 18888 |
| Apparel | 2 | Hoodie Basic | 11461 |
| Apparel | 3 | Jeans Pro | 11379 |
| Beauty | 1 | Face Serum Basic | 32491 |
| Beauty | 2 | Sunscreen Pro | 22653 |
| Beauty | 3 | Face Serum Plus | 14651 |
| Electronics | 1 | Tablet Basic | 322258 |
| Electronics | 2 | Tablet Plus | 79492 |
| Electronics | 3 | Bluetooth Speaker Pro | 51351 |
Window functions are computed after WHERE, so you cannot filter on rn in the same query level — wrap it in a CTE and filter outside.
Latest record per entity
Each customer's most recent completed order — a pattern used daily (latest status, latest price, last contact):
WITH ordered AS (
SELECT customer_id, order_id, order_date,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC) AS rn
FROM orders WHERE status = 'completed'
)
SELECT customer_id, order_id AS last_order_id, order_date AS last_order_date
FROM ordered WHERE rn = 1
ORDER BY customer_id
LIMIT 5;| customer_id | last_order_id | last_order_date |
|---|---|---|
| 1 | 31446 | 2025-08-04 |
| 3 | 11267 | 2023-12-31 |
| 4 | 36829 | 2025-12-17 |
| 5 | 31757 | 2025-08-13 |
| 6 | 22052 | 2024-11-28 |
The tie-breaker order_id DESC makes the result deterministic when a customer ordered twice on the same day.
LAG and LEAD: comparing with previous and next rows
LAG(x, n) returns the value of x from n rows before the current row within the window; LEAD looks ahead. Month-over-month growth for total revenue:
WITH monthly AS (
SELECT strftime('%Y-%m', o.order_date) AS month,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01'
GROUP BY 1
)
SELECT month,
ROUND(revenue) AS revenue,
ROUND(LAG(revenue) OVER (ORDER BY month)) AS prev_month,
ROUND(100 * (revenue / LAG(revenue) OVER (ORDER BY month) - 1), 1) AS mom_growth_pct
FROM monthly
ORDER BY month;| month | revenue | prev_month | mom_growth_pct |
|---|---|---|---|
| 2025-01 | 79079 | NULL | NULL |
| 2025-02 | 66487 | 79079 | -15.9 |
| 2025-03 | 91231 | 66487 | 37.2 |
| 2025-04 | 85694 | 91231 | -6.1 |
| 2025-05 | 91737 | 85694 | 7.1 |
| 2025-06 | 95463 | 91737 | 4.1 |
| 2025-07 | 99539 | 95463 | 4.3 |
| 2025-08 | 96914 | 99539 | -2.6 |
| 2025-09 | 90775 | 96914 | -6.3 |
| 2025-10 | 79657 | 90775 | -12.2 |
| 2025-11 | 122499 | 79657 | 53.8 |
| 2025-12 | 145452 | 122499 | 18.7 |
January has no previous month in this window, so LAG returns NULL — correctly, rather than inventing 0. December's jump and January's fall are seasonal; month-over-month comparisons can mislead in seasonal businesses, which is why the next lesson builds year-over-year comparisons with LAG(revenue, 12).
Running totals
A running (cumulative) total sums all rows from the start of the window to the current row:
WITH monthly AS (
SELECT strftime('%Y-%m', o.order_date) AS month,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01'
GROUP BY 1
)
SELECT month, ROUND(revenue) AS revenue,
ROUND(SUM(revenue) OVER (ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS ytd_revenue
FROM monthly;| month | revenue | ytd_revenue |
|---|---|---|
| 2025-01 | 79079 | 79079 |
| 2025-02 | 66487 | 145566 |
| 2025-03 | 91231 | 236797 |
| 2025-04 | 85694 | 322491 |
| 2025-05 | 91737 | 414228 |
| 2025-06 | 95463 | 509691 |
| 2025-07 | 99539 | 609230 |
| 2025-08 | 96914 | 706143 |
| 2025-09 | 90775 | 796919 |
| 2025-10 | 79657 | 876575 |
| 2025-11 | 122499 | 999075 |
| 2025-12 | 145452 | 1144527 |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW is the frame. If you omit it but include ORDER BY, the default frame is RANGE … CURRENT ROW, which treats rows with equal ORDER BY values as one — usually fine for unique months, surprising for duplicate dates. State the frame explicitly.
Moving averages
A 3-month moving average smooths noise:
WITH monthly AS (
SELECT strftime('%Y-%m', o.order_date) AS month,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'completed' AND o.order_date >= '2025-01-01'
GROUP BY 1
)
SELECT month, ROUND(revenue) AS revenue,
ROUND(AVG(revenue) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)) AS ma_3m,
COUNT(*) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS months_in_avg
FROM monthly;| month | revenue | ma_3m | months_in_avg |
|---|---|---|---|
| 2025-01 | 79079 | 79079 | 1 |
| 2025-02 | 66487 | 72783 | 2 |
| 2025-03 | 91231 | 78932 | 3 |
| 2025-04 | 85694 | 81137 | 3 |
| 2025-05 | 91737 | 89554 | 3 |
| 2025-06 | 95463 | 90965 | 3 |
| 2025-07 | 99539 | 95580 | 3 |
| 2025-08 | 96914 | 97305 | 3 |
| 2025-09 | 90775 | 95743 | 3 |
| 2025-10 | 79657 | 89115 | 3 |
| 2025-11 | 122499 | 97644 | 3 |
| 2025-12 | 145452 | 115869 | 3 |
The first two rows average only one and two months (see months_in_avg). Either label them or exclude rows where the window is incomplete — don't present them as true 3-month averages.
ROWS BETWEEN 2 PRECEDING means two rows, not two months. If a store had no sales one month, that month has no row, and the "3-month" window silently spans four months. Join to a complete calendar first (calendar table) so every period has a row.
Practice
Scores 95, 90, 90, 85 are ranked with DENSE_RANK() OVER (ORDER BY score DESC). What rank does 85 get?
For each store_id, return the single most expensive product sold (highest unit_price on any order line; break ties by lowest product_id). Return store_id, product_id, unit_price.
In the finance database, return each year_month of 2025 with actual Revenue and the previous month's actual Revenue (use LAG; January 2025 should show December 2024). Columns: year_month, revenue, prev_revenue.
You compute a 3-month moving average with ROWS BETWEEN 2 PRECEDING AND CURRENT ROW, but some products have months with no sales (no row). What is the risk?
Summary
OVER (PARTITION BY … ORDER BY … ROWS …)computes across related rows without collapsing them.ROW_NUMBER/RANK/DENSE_RANKdiffer on ties; use them for top-N-per-group and latest-record queries (filter in an outer query).LAG/LEADcompare with previous/next rows; NULL at the edges is correct.- Running totals and moving averages use frames; state frames explicitly and fill time gaps with a calendar.