Skip to main content

Window Functions Essentials

Window functions are a core interview skill because they let you compute rankings, running totals, lag/lead comparisons, and “top N per group” without losing row-level detail.

In this lesson, we will cover how window functions work, the difference between windowed aggregates vs. GROUP BY, and when to use ROW_NUMBER, RANK, DENSE_RANK, LAG, and LEAD. You’ll learn the mental model to avoid common pitfalls (like incorrect partitions or ordering) and to recognize when a window function is the cleanest solution.

Example prompts include:

“Find each user’s most recent purchase.”

and

“Compute a 7-day rolling average of daily revenue.”

Top-K per group

Interview prompt: Find the top 2 orders per user.

SQL
SELECT * FROM ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY amount DESC, o_id DESC ) AS rn FROM orders o ) t WHERE rn <= 2 ORDER BY user_id, rn;

Output

o_iduser_idcreated_atamountrn
1112025-01-05701
1012025-01-02502
1322025-01-03201
1222025-01-01152
1432025-01-02601

What this does

  • For each user, takes the highest 2 orders by amount.

Why this works

  • ROW_NUMBER() resets per user and ensures exactly two rows at most.

Gotchas

  • If you want all ties at rank 2, use RANK() instead.

Clarifying questions

  • Top by which metric?
  • If tied, how should we break ties?
  • Exactly K or all ties within K?

Common pitfalls

  • Using RANK() and accidentally returning more than 2 rows.
  • Missing deterministic tiebreaker.

Edge cases

  • Use DENSE_RANK() for no gaps in ranking.

Latest row per key

Interview prompt: Find each user’s latest order.

SQL
SELECT o_id, user_id, created_at, amount FROM ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY created_at DESC, o_id DESC ) AS rn FROM orders o ) t WHERE rn = 1 ORDER BY user_id;

Output

o_iduser_idcreated_atamount
1112025-01-0570
1322025-01-0320
1432025-01-0260

What this does

  • Returns the most recent order per user.

Why this works

  • Descending order + ROW_NUMBER() ensures one unambiguous “latest.”

Gotchas

  • If status matters (e.g., only paid orders), filter inside the subquery.

Clarifying questions

  • Latest by which timestamp?
  • What if multiple rows have identical timestamps?

Common pitfalls

  • Using MAX() then joining back without a unique key.

Edge cases

  • Want latest 3? Use rn <= 3.

Time deltas

Interview prompt: For each user-day, show previous spend and day-over-day change (row-to-row).

SQL
SELECT user_id, dt, spend, LAG(spend) OVER ( PARTITION BY user_id ORDER BY dt ) AS prev_spend, spend - LAG(spend) OVER ( PARTITION BY user_id ORDER BY dt ) AS delta_spend FROM sales ORDER BY user_id, dt;

Output

user_iddtspendprev_spenddelta_spend
12025-01-0120nullnull
12025-01-021520-5
12025-01-05401525
22025-01-0110nullnull
22025-01-03301020
32025-01-0250nullnull

What this does

  • Shows previous row’s spend and the change from it.

Why this works

  • LAG() exposes prior row value without collapsing rows.

Gotchas

  • This is “previous row,” not “previous calendar day.”

Clarifying questions

  • Should missing dates be treated as zero? Or fill forward?
  • Should first row default to NULL or 0?

Common pitfalls

  • Forgetting PARTITION BY.
  • Assuming contiguous dates.

Edge cases

  • Use default: LAG(spend, 1, 0).
  • For true calendar deltas, generate a dense date table.

Running totals

Interview prompt: Compute cumulative spend per user over time.

SQL
SELECT user_id, dt, spend, SUM(spend) OVER ( PARTITION BY user_id ORDER BY dt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cum_spend FROM sales ORDER BY user_id, dt;

Output

user_iddtspendcum_spend
12025-01-012020
12025-01-021535
12025-01-054075
22025-01-011010
22025-01-033040
32025-01-025050

What this does

  • Cumulative spend per user.

Why this works

  • Explicit ROWS frame prevents peer-row inflation (a known issue with RANGE when ordering by timestamps).

Clarifying questions

  • Reset by which dimension?
  • What happens when timestamps tie?

Common pitfalls

  • Relying on implicit RANGE (which groups peer rows).
  • Missing deterministic ordering.

Edge cases

  • Rolling windows require changing the frame clause.

Rolling windows

Last 2 rows per user

Interview prompt: Compute a rolling sum over the current + previous row (per user).

SQL
SELECT user_id, dt, spend, SUM(spend) OVER ( PARTITION BY user_id ORDER BY dt ROWS BETWEEN 1 PRECEDING AND CURRENT ROW ) AS sum_last_2_rows FROM sales ORDER BY user_id, dt;

Output

user_iddtspendsum_last_2_rows
12025-01-012020
12025-01-021535
12025-01-054055
22025-01-011010
22025-01-033040
32025-01-025050

What this does

  • Rolling sum of current + previous row.

Why this works

  • ROWS refers to physical rows, so date gaps are irrelevant.

Gotchas

  • Not equivalent to “last 2 days” if dates skipped.

Last N days

Interview prompt: Compute a time-based rolling sum over the last 2 calendar days (per user).

SQL
SELECT user_id, dt, spend, SUM(spend) OVER ( PARTITION BY user_id ORDER BY dt RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW ) AS sum_last_2_days FROM sales ORDER BY user_id, dt;

What this does

  • Includes rows within the last 2 calendar days.

Why this works

  • RANGE INTERVAL creates a time-based frame.

Clarifying questions

  • What if multiple rows share a timestamp?
  • Does SQL engine support INTERVAL windows? (not all do)

Common pitfalls

  • Using RANGE INTERVAL in Postgres versions that don’t support it.
  • Ties may include peer rows without secondary sort order.

Edge cases

  • If multiple rows exist for same date, pre-aggregate or add tiebreaker.

Example data

We used these tables in the examples above to demonstrate window functions.

sales (daily spend per user)

user_iddtspend
12025-01-0120
12025-01-0215
12025-01-0540
22025-01-0110
22025-01-0330
32025-01-0250

orders (one row per order)

o_iduser_idcreated_atamount
1012025-01-0250
1112025-01-0570
1222025-01-0115
1322025-01-0320
1432025-01-0260

Questions

Warm up

Practice

Go deeper

Want to dive deeper into window functions? Check out our more detailed lesson here: Window Functions