Ranking & Running Totals
Window functions shine at problems that are painful with plain SQL: numbering and ranking rows, picking the top n per group, comparing each row with the previous one, and calculating running totals and moving averages. This lesson covers the dedicated ranking and offset functions and window frames.
ROW_NUMBER, RANK and DENSE_RANK
SQL
SELECT first_name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank
FROM employees
WHERE salary BETWEEN 60000 AND 90000;
Result 6 rows
| first_name | salary | row_num | rank | dense_rank |
| Jonas | 85000 | 1 | 1 | 1 |
| Rafael | 82000 | 2 | 2 | 2 |
| Daniel | 71000 | 3 | 3 | 3 |
| Amara | 68000 | 4 | 4 | 4 |
| Tomás | 68000 | 5 | 4 | 4 |
| Ethan | 60000 | 6 | 6 | 5 |
| Function | Ties | Example sequence |
ROW_NUMBER() | Tied rows get different numbers (arbitrary order) | 1, 2, 3, 4 |
RANK() | Same rank; skips the next number | 1, 2, 2, 4 |
DENSE_RANK() | Same rank; no gaps | 1, 2, 2, 3 |
Top n per group
SQL
WITH ranked AS (
SELECT category, name, price,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS pos
FROM products
)
SELECT category, name, price
FROM ranked
WHERE pos <= 2
ORDER BY category, pos;
Result 8 rows
| category | name | price |
| Books | Night Sky Atlas | 45 |
| Books | Learning SQL Step by Step | 32.5 |
| Electronics | Mechanical Keyboard | 89 |
| Electronics | Wireless Earbuds | 59.99 |
| Home | Linen Throw | 49 |
| Home | Desk Lamp | 34 |
| Toys | Wooden Train Set | 39.9 |
| Toys | Jigsaw Puzzle 1000 | 18 |
The two most expensive products in each category. Number rows within each partition, then filter. This is one of the most frequently needed SQL patterns.
Latest row per group
SQL
WITH latest AS (
SELECT customer_id, id, order_date, status,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS n
FROM orders
)
SELECT c.name, l.id AS last_order, l.order_date, l.status
FROM latest AS l
JOIN customers AS c ON c.id = l.customer_id
WHERE l.n = 1
ORDER BY l.order_date DESC;
Result 9 rows
| name | last_order | order_date | status |
| George Brown | 15 | 2026-03-05 | pending |
| David Müller | 14 | 2026-03-01 | delivered |
| Chloé Martin | 13 | 2026-02-20 | pending |
| Isaac Levi | 12 | 2026-02-14 | pending |
| Ana Pereira | 11 | 2026-02-03 | shipped |
| Hana Novak | 10 | 2026-01-22 | delivered |
| Farah Haddad | 9 | 2026-01-07 | shipped |
| Ben Carter | 7 | 2025-12-01 | delivered |
| Elena García | 6 | 2025-11-15 | delivered |
LAG and LEAD: neighbouring rows
SQL
SELECT id, customer_id, order_date,
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS previous_order,
julianday(order_date) - julianday(LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS days_since
FROM orders
WHERE customer_id IN (1, 2)
ORDER BY customer_id, order_date;
Result 5 rows
| id | customer_id | order_date | previous_order | days_since |
| 1 | 1 | 2025-09-03 | NULL | NULL |
| 3 | 1 | 2025-10-01 | 2025-09-03 | 28 |
| 11 | 1 | 2026-02-03 | 2025-10-01 | 125 |
| 2 | 2 | 2025-09-14 | NULL | NULL |
| 7 | 2 | 2025-12-01 | 2025-09-14 | 78 |
LAG(column) reads a value from the previous row in the window; LEAD reads from the next one. An optional second argument looks further (LAG(x, 2)), and a third gives a default instead of NULL.
Running totals and window frames
SQL
SELECT order_id, product_id, quantity,
SUM(quantity) OVER (ORDER BY order_id, product_id) AS running_units
FROM order_items
WHERE order_id <= 4;
Result 7 rows
| order_id | product_id | quantity | running_units |
| 1 | 1 | 1 | 1 |
| 1 | 7 | 2 | 3 |
| 2 | 4 | 1 | 4 |
| 3 | 2 | 1 | 5 |
| 3 | 5 | 2 | 7 |
| 4 | 7 | 4 | 11 |
| 4 | 9 | 1 | 12 |
When a window has an ORDER BY, its default frame is "from the first row up to the current row" — giving a running total. You can set the frame explicitly:
SQL
SELECT month, orders,
round(AVG(orders) OVER (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3
FROM (
SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders
FROM orders
GROUP BY month
);
Result 7 rows
| month | orders | moving_avg_3 |
| 2025-09 | 2 | 2 |
| 2025-10 | 2 | 2 |
| 2025-11 | 2 | 2 |
| 2025-12 | 2 | 2 |
| 2026-01 | 2 | 2 |
| 2026-02 | 3 | 2.33 |
| 2026-03 | 2 | 2.33 |
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW averages the current month and the two before it — a three-month moving average.
| Frame | Meaning |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | Running total |
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | Last three rows |
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING | Previous, current and next |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | The whole partition |
With the default RANGE frame, rows with equal ORDER BY values are treated as one step; ROWS counts physical rows. When in doubt, specify ROWS.
FIRST_VALUE, LAST_VALUE and NTILE
SQL
SELECT first_name, salary,
FIRST_VALUE(first_name) OVER (ORDER BY salary DESC) AS top_earner,
NTILE(3) OVER (ORDER BY salary DESC) AS pay_band
FROM employees
WHERE department_id IS NOT NULL;
Result 11 rows
| first_name | salary | top_earner | pay_band |
| Mei | 98000 | Mei | 1 |
| Jonas | 85000 | Mei | 1 |
| Rafael | 82000 | Mei | 1 |
| Daniel | 71000 | Mei | 1 |
| Amara | 68000 | Mei | 2 |
| Tomás | 68000 | Mei | 2 |
| Ethan | 60000 | Mei | 2 |
| Sofia | 54000 | Mei | 2 |
| Lucas | 52000 | Mei | 3 |
| Hannah | 48000 | Mei | 3 |
| Priya | 42000 | Mei | 3 |
NTILE(n) splits the rows into n roughly equal buckets — handy for quartiles and percentiles. LAST_VALUE usually needs an explicit frame ending at UNBOUNDED FOLLOWING, because the default frame stops at the current row.
Try it yourself
Rank customers by total spend, and show each one's gap to the customer ranked just above.