Module 14 · SQL for Data Analysis
Window functions
Rank rows, number them within groups and calculate running totals without losing detail.
About 35 minutes
The problem
Two requests land on your desk the same morning:
- The sales director wants customers ranked by containers shipped in 2026, with their rank shown next to their name.
- Finance wants each month's revenue alongside a running total for the year so far.
GROUP BY can total things, but it collapses rows. Here you need a calculation across rows and to keep every row.
The concept
A window function calculates a value for each row using a set of related rows (its "window"), without collapsing them.
You recognise one by OVER (…):
SQL
function() OVER (PARTITION BY … ORDER BY …)PARTITION BYsplits rows into groups; the function restarts in each group. Leave it out and the whole result is one group.ORDER BYinsideOVERsets the order the function works through the rows.
Common window functions:
| Function | What it does |
|---|---|
ROW_NUMBER() | 1, 2, 3, … with no ties |
RANK() | ranks with ties sharing a number, then skipping (1, 2, 2, 4) |
DENSE_RANK() | ties share a number, no gaps (1, 2, 2, 3) |
SUM(x) OVER (ORDER BY …) | running total |
LAG(x) / LEAD(x) | the value from the previous / next row |
Example
WITH totals AS (
SELECT c.company_name, SUM(s.containers) AS containers
FROM shipments AS s
JOIN customers AS c ON c.customer_id = s.customer_id
WHERE s.booking_date >= '2026-01-01'
GROUP BY c.customer_id, c.company_name
)
SELECT
company_name,
containers,
RANK() OVER (ORDER BY containers DESC) AS container_rank
FROM totals
ORDER BY container_rank
LIMIT 15;Walkthrough
- The CTE totals 2026 containers per customer, using what you learned in the last few lessons.
RANK() OVER (ORDER BY containers DESC)looks at all rows, orders them by containers, and gives each one its position.- Every customer row is still there. The rank is simply a new column.
Now a running total. First total each month, then add SUM(…) OVER (ORDER BY month):
WITH monthly AS (
SELECT strftime('%Y-%m', payment_date) AS month, SUM(amount) AS received
FROM payments
WHERE payment_date >= '2026-01-01'
GROUP BY month
)
SELECT
month,
received,
SUM(received) OVER (ORDER BY month) AS received_year_to_date
FROM monthly
ORDER BY month;For each month, the running total adds up every month up to and including that one.
PARTITION BY restarts the calculation per group. This numbers each customer's shipments from newest to oldest, so rn = 1 is their most recent booking:
SELECT customer_id, shipment_id, booking_date
FROM (
SELECT
customer_id,
shipment_id,
booking_date,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY booking_date DESC, shipment_id DESC) AS rn
FROM shipments
) AS numbered
WHERE rn = 1
ORDER BY booking_date;The customers at the top of this list are the ones who haven't booked for longest. Sales teams use exactly this kind of query to spot customers drifting away.
Practice
Practice
Rank the routes by how many shipments they carried. Show route_id, shipments and route_rank using RANK(), busiest first.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Challenge
Challenge · optional
For each month of 2025, show month ('YYYY-MM'), the number of shipments booked, and the previous month's number using LAG. Order by month.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Check your understanding
Answer every question to check.