Skip to content
OrionStats
Module 2 · Advanced Analytical SQL · Window functions

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.

Advanced70 min
Recommended first

This lesson builds on: Date, string and numeric functions.

Learning objectives
  • 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;
Result · 6 rows
store_idmonthrevenuestore_year_totalpct_of_year
12025-01106851781196
12025-0285791781194.8
12025-03147411781198.3
12025-04133021781197.5
12025-05154131781198.7
12025-06121651781196.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;
Result · 9 rows
categoryrnproduct_namerevenue
Apparel1Sneakers Basic18888
Apparel2Hoodie Basic11461
Apparel3Jeans Pro11379
Beauty1Face Serum Basic32491
Beauty2Sunscreen Pro22653
Beauty3Face Serum Plus14651
Electronics1Tablet Basic322258
Electronics2Tablet Plus79492
Electronics3Bluetooth Speaker Pro51351

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;
Result · 5 rows
customer_idlast_order_idlast_order_date
1314462025-08-04
3112672023-12-31
4368292025-12-17
5317572025-08-13
6220522024-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;
Result · 12 rows
monthrevenueprev_monthmom_growth_pct
2025-0179079NULLNULL
2025-026648779079-15.9
2025-03912316648737.2
2025-048569491231-6.1
2025-0591737856947.1
2025-0695463917374.1
2025-0799539954634.3
2025-089691499539-2.6
2025-099077596914-6.3
2025-107965790775-12.2
2025-111224997965753.8
2025-1214545212249918.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;
Result · 12 rows
monthrevenueytd_revenue
2025-017907979079
2025-0266487145566
2025-0391231236797
2025-0485694322491
2025-0591737414228
2025-0695463509691
2025-0799539609230
2025-0896914706143
2025-0990775796919
2025-1079657876575
2025-11122499999075
2025-121454521144527

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;
Result · 12 rows
monthrevenuema_3mmonths_in_avg
2025-0179079790791
2025-0266487727832
2025-0391231789323
2025-0485694811373
2025-0591737895543
2025-0695463909653
2025-0799539955803
2025-0896914973053
2025-0990775957433
2025-1079657891153
2025-11122499976443
2025-121454521158693
Moving averages over incomplete windows

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.

Gaps in the time series

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?

Log in to answer and get feedback

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.

Log in to answer and get feedback

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.

Log in to answer and get feedback

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?

Log in to answer and get feedback

Summary

  • OVER (PARTITION BY … ORDER BY … ROWS …) computes across related rows without collapsing them.
  • ROW_NUMBER / RANK / DENSE_RANK differ on ties; use them for top-N-per-group and latest-record queries (filter in an outer query).
  • LAG/LEAD compare 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.