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_namesalaryrow_numrankdense_rank
Jonas85000111
Rafael82000222
Daniel71000333
Amara68000444
Tomás68000544
Ethan60000665
FunctionTiesExample sequence
ROW_NUMBER()Tied rows get different numbers (arbitrary order)1, 2, 3, 4
RANK()Same rank; skips the next number1, 2, 2, 4
DENSE_RANK()Same rank; no gaps1, 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
categorynameprice
BooksNight Sky Atlas45
BooksLearning SQL Step by Step32.5
ElectronicsMechanical Keyboard89
ElectronicsWireless Earbuds59.99
HomeLinen Throw49
HomeDesk Lamp34
ToysWooden Train Set39.9
ToysJigsaw Puzzle 100018

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
namelast_orderorder_datestatus
George Brown152026-03-05pending
David Müller142026-03-01delivered
Chloé Martin132026-02-20pending
Isaac Levi122026-02-14pending
Ana Pereira112026-02-03shipped
Hana Novak102026-01-22delivered
Farah Haddad92026-01-07shipped
Ben Carter72025-12-01delivered
Elena García62025-11-15delivered

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
idcustomer_idorder_dateprevious_orderdays_since
112025-09-03NULLNULL
312025-10-012025-09-0328
1112026-02-032025-10-01125
222025-09-14NULLNULL
722025-12-012025-09-1478

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_idproduct_idquantityrunning_units
1111
1723
2414
3215
3527
47411
49112

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
monthordersmoving_avg_3
2025-0922
2025-1022
2025-1122
2025-1222
2026-0122
2026-0232.33
2026-0322.33

ROWS BETWEEN 2 PRECEDING AND CURRENT ROW averages the current month and the two before it — a three-month moving average.

FrameMeaning
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWRunning total
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWLast three rows
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGPrevious, current and next
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGThe 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_namesalarytop_earnerpay_band
Mei98000Mei1
Jonas85000Mei1
Rafael82000Mei1
Daniel71000Mei1
Amara68000Mei2
Tomás68000Mei2
Ethan60000Mei2
Sofia54000Mei2
Lucas52000Mei3
Hannah48000Mei3
Priya42000Mei3

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.

Try it yourself
Result