Guide · 10 min read

Window Functions Without the Hand-Waving

A practical tour of SQL window functions. Learn PARTITION BY, ROW_NUMBER, RANK, DENSE_RANK, LEAD, LAG, and running totals.

  • OVER and PARTITION BY
  • ROW_NUMBER
  • RANK vs DENSE_RANK
  • LEAD and LAG
  • Running totals

A window function computes a value for each row based on a set of other rows related to it. GROUP BY collapses rows into one row per group; a window function leaves every row in place and attaches a calculation to it. That single difference is why windowing is so useful: you keep the detail and still get the summary.

The anatomy of a window

function(...) OVER (
  PARTITION BY col1, col2   -- optional: defines the groups
  ORDER BY col3             -- optional: defines ordering within each group
  ROWS BETWEEN ... AND ...  -- optional: defines the frame
)

OVER is what turns an aggregate into a window function. PARTITION BYis “group by, but do not collapse rows”. ORDER BY orders the rows inside each partition. The frame clause is how far forward and backward the function can see; most of the time the default (current row and everything before, for ordered windows) is what you want.

ROW_NUMBER, RANK, DENSE_RANK

These three assign a number to each row inside a partition. They differ in how they handle ties.

-- sales table: (salesperson, region, amount)
SELECT
  salesperson,
  region,
  amount,
  ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC)  AS row_num,
  RANK()       OVER (PARTITION BY region ORDER BY amount DESC)  AS rnk,
  DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC)  AS dense
FROM sales;

Three salespeople tied at $100 in a region:

  • ROW_NUMBER: 1, 2, 3 (arbitrary order among ties).
  • RANK: 1, 1, 1, then the next rank skips to 4.
  • DENSE_RANK: 1, 1, 1, then the next rank is 2 (no gaps).

ROW_NUMBER is what you want when you must pick exactly one row per group (top-N-per-group queries). RANK and DENSE_RANK are for leaderboards.

Top-N per group

The canonical pattern: the top three highest-paid employees per department.

SELECT *
FROM (
  SELECT
    employee,
    department,
    salary,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
  FROM employees
) ranked
WHERE rn <= 3;

Without windowing this forces an ugly correlated subquery. With windowing, it is three lines.

LEAD and LAG

LAG(col, n) reads the value of col from the row n positions earlier in the partition.LEAD looks forward. Use them for period-over-period comparisons without self-joining.

-- daily revenue, % change vs the previous day
SELECT
  day,
  revenue,
  LAG(revenue) OVER (ORDER BY day) AS revenue_prev,
  (revenue - LAG(revenue) OVER (ORDER BY day)) * 100.0
    / NULLIF(LAG(revenue) OVER (ORDER BY day), 0) AS pct_change
FROM daily_revenue;

Running totals

A running total is the default frame: “this row and everything before it in the partition order”.

SELECT
  day,
  revenue,
  SUM(revenue) OVER (ORDER BY day) AS running_total
FROM daily_revenue;

Add PARTITION BY month to reset the running total at the start of each month.

Common mistakes

  • Using a window function inside a WHERE clause. Window functions are evaluated after WHERE, so it will not compile. Wrap the query in a subquery or CTE and filter on the outer query.
  • Forgetting ORDER BY inside OVER. Ranks and running totals require ordering; without it the result is nondeterministic.
  • Using RANK where ROW_NUMBER was meant. Top-N per group needs ROW_NUMBER so you break ties.

Practice this live

The War Room chapter in SQL Protocol is dedicated to windowing. Interview Mode Floor 3 leans heavily on window functions, which is a fair reflection of how senior SQL interviews go.

Drill this in the game

These chapters practice exactly what this guide covers:

Ready to write real SQL?

Sign in with Google. Free, no credit card, no installation. Desktop only.

Play SQL Protocol