Window functions
In one line
A window function computes a value for each row from a set of related rows (its window), such as a rank, a running total or the previous row's value, without collapsing rows the way GROUP BY does.
What it is
A window function is written as function() OVER (PARTITION BY ... ORDER BY ... frame). PARTITION BY splits rows into groups, ORDER BY sorts rows inside each group, and the optional frame says which nearby rows count (for example the 7 previous rows). Every input row stays in the output and gets its own computed value.
Ranking functions: ROW_NUMBER() numbers rows 1, 2, 3 with no ties; RANK() gives ties the same rank and then skips (1, 2, 2, 4); DENSE_RANK() gives ties the same rank without gaps (1, 2, 2, 3); NTILE(n) splits rows into n buckets.
Offset functions: LAG(col) reads a previous row, LEAD(col) a following row, and FIRST_VALUE / LAST_VALUE / NTH_VALUE read positions in the frame. Any aggregate (SUM, AVG, COUNT, MIN, MAX) can also be used as a window function, which gives running totals and moving averages. PostgreSQL has had window functions since 8.4; MySQL since 8.0.
Why interviewers ask
Window functions are the most common 'advanced SQL' interview topic: top N per group, running totals, month-over-month change, deduplicating to the latest row, and gaps-and-islands problems. They show you can solve per-row-with-context problems in one pass instead of self joins or correlated subqueries.
How it works
- 1Window functions are computed after FROM, WHERE, GROUP BY and HAVING, and before the final ORDER BY and LIMIT. That is why you cannot use them in WHERE; wrap the query in a CTE or subquery and filter outside (some warehouses like Snowflake and BigQuery add a QUALIFY clause for this, PostgreSQL and MySQL do not have it).
- 2PARTITION BY splits rows into independent groups. Without it, the whole result is one partition.
- 3ORDER BY inside OVER sorts rows within each partition. Ranking and offset functions need it to mean anything.
- 4The frame decides which rows an aggregate sees. With ORDER BY and no explicit frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: from the start of the partition to the current row, including all rows that tie with it (its peers). Without ORDER BY, the frame is the whole partition.
- 5For a precise moving window, write the frame yourself: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is the current row plus the 6 before it (a 7-row moving window).
- 6The engine typically sorts rows by (partition keys, order keys) once, then streams through them computing each row's value.
Worked example · Ranks with ties, and a running total
- salaries in Eng: Asha 120, Farah 110, Chen 110, Gita 95 (in thousands). ORDER BY salary DESC.
- ROW_NUMBER() with the tie-breaker the code uses (ORDER BY salary DESC, name): Asha 1, Chen 2, Farah 3, Gita 4. Without a tie-breaker, whether Chen or Farah gets 2 is arbitrary and can change between runs.
- RANK(): Asha 1, Farah 2, Chen 2, Gita 4. Two people share 2nd place, so 3rd is skipped.
- DENSE_RANK(): Asha 1, Farah 2, Chen 2, Gita 3. No gap after the tie.
- daily_sales(day, amount): (Mon, 100), (Tue, 50), (Wed, 70). SUM(amount) OVER (ORDER BY day): Mon 100, Tue 100 + 50 = 150, Wed 150 + 70 = 220. All 3 rows stay in the result.
- LAG(amount) OVER (ORDER BY day): Mon NULL (no previous row), Tue 100, Wed 50. So amount - LAG(amount) gives NULL, -50, +20: the day-over-day change.
- Top 2 earners per department: compute ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn in a CTE, then SELECT ... WHERE rn <= 2. Use RANK() instead if tied people should all be included.
Picture it
Picture a class photo where each student holds a card. GROUP BY would replace the whole class with one card saying 'average height 150 cm'. A window function lets every student keep standing in the photo, each holding a card with their own rank in the class, or the height of the person to their left.
Reference code
-- employees(id, name, dept, salary)
-- Top 2 earners per department, ties broken by name
WITH ranked AS (
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC, name) AS rn,
RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS drnk
FROM employees
)
SELECT name, dept, salary, rn, rnk, drnk
FROM ranked
WHERE rn <= 2; -- window results can only be filtered outside
-- daily_sales(day, amount): running total and change vs previous day
SELECT day, amount,
SUM(amount) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING) AS running_total,
amount - LAG(amount) OVER (ORDER BY day) AS change
FROM daily_sales
ORDER BY day;For Eng with Asha 120, Farah 110, Chen 110, Gita 95, the first query returns Asha (rn 1) and Chen (rn 2, because ties are broken by name and Chen sorts before Farah), both with rnk and drnk as in the walkthrough. The second returns running totals 100, 150, 220 and changes NULL, -50, 20 (with day stored as a date so the order is chronological).
Key patterns
Top N per group
When: Top 3 products per category, latest order per customer
How: ROW_NUMBER() (or RANK() to keep ties) OVER (PARTITION BY group ORDER BY metric DESC) in a CTE, then filter rn <= N outside.
Running total / moving average
When: Cumulative revenue, 7-day average
How: SUM(x) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING); AVG(x) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW). Use ROWS, not the default RANGE, if days can repeat.
Compare to previous row
When: Day-over-day change, time between events, detecting status changes
How: x - LAG(x) OVER (PARTITION BY id ORDER BY t). LAG(x, 1, 0) supplies a default instead of NULL for the first row.
Gaps and islands
When: Longest login streak, consecutive days with activity
How: day - ROW_NUMBER() OVER (PARTITION BY user ORDER BY day) is constant within a run of consecutive days; group by that value to measure each streak.
Share of total
When: Each row's percentage of its group
How: amount * 100.0 / SUM(amount) OVER (PARTITION BY group): an aggregate window without ORDER BY covers the whole partition.
Real-world use
Leaderboards
Games and learning apps compute player positions with RANK() or DENSE_RANK() OVER (PARTITION BY league ORDER BY score DESC), so tied scores share a position.
Financial running balances
Banking and accounting apps show a running balance next to each transaction with SUM(amount) OVER (PARTITION BY account_id ORDER BY posted_at, id ROWS UNBOUNDED PRECEDING).
Deduplicating event data
Data pipelines in Snowflake and BigQuery keep only the latest version of each record with ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) = 1, often written with QUALIFY.
Product metrics
Growth dashboards compute week-over-week change with LAG and 7-day moving averages of daily active users with AVG(...) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW).
In a project
A subscription app stores every plan change in a subscription_events table (user_id, plan, price, changed_at). Product wants each user's current plan, how many times they upgraded, and the monthly revenue trend.
- Current plan: ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY changed_at DESC, id DESC) in a CTE, then keep rn = 1.
- Upgrades: LAG(price) OVER (PARTITION BY user_id ORDER BY changed_at) gives the previous price; count rows where price > previous price.
- Monthly trend: aggregate revenue per month with GROUP BY, then apply LAG over the aggregated rows to get month-over-month change, and AVG over ROWS BETWEEN 2 PRECEDING AND CURRENT ROW for a 3-month moving average.
- Add an index on (user_id, changed_at) so the per-user ordering can come from the index instead of a big sort.
Impact: Three questions that previously needed self joins and an export to a spreadsheet are answered by one maintainable SQL view. The latest-plan query went from a correlated subquery taking 40 seconds to a single sorted pass taking about 2 seconds on 20 million events.
Trade-offs
Use it when
- You need each row plus a value computed from related rows (rank, running total, previous value, group total)
- Top N per group and 'latest row per key' deduplication
- Replacing self joins or correlated subqueries that compare a row to its neighbours
Avoid it when
- You only need one row per group: plain GROUP BY is simpler and cheaper
- Filtering directly in WHERE on a window result (not allowed); use a CTE or subquery
- Huge partitions with no supporting index when a simple aggregate would answer the question
Cost: Usually one sort of the input by partition and order keys, O(n log n), then a linear pass. An index on (partition key, order key) can remove the sort. Aggregates over sliding ROWS frames are computed incrementally in most engines.
Common mistakes
- Putting a window function in WHERE or HAVING. They are evaluated after both; filter in an outer query.
- Using ROW_NUMBER for 'top 3' when ties should be included (use RANK or DENSE_RANK), or not adding a tie-breaker so results change between runs.
- Using LAST_VALUE with the default frame: it ends at the current row (and its peers), so LAST_VALUE returns the current row's value. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
- Forgetting that the default frame with ORDER BY is RANGE, so rows with the same ORDER BY value share one running total. Use ROWS for a strict row-by-row total.
- Forgetting PARTITION BY, so the ranking runs across the whole table instead of within each group.
Interview questions
Quick revision
- f() OVER (PARTITION BY group ORDER BY sort frame): one value per row, rows are not collapsed.
- 100, 90, 90, 80: ROW_NUMBER 1,2,3,4; RANK 1,2,2,4; DENSE_RANK 1,2,2,3.
- LAG / LEAD read previous / next rows; NULL at the edges unless a default is given.
- Default frame with ORDER BY: RANGE UNBOUNDED PRECEDING to CURRENT ROW (peers included).
- Use ROWS BETWEEN n PRECEDING AND CURRENT ROW for moving windows.
- Cannot filter windows in WHERE: wrap in a CTE (QUALIFY exists in Snowflake and BigQuery only).
- Top N per group = ROW_NUMBER in CTE + WHERE rn <= N.
- MySQL supports window functions from 8.0.
Check yourself
1. Salaries ordered descending are 120, 110, 110, 95. What RANK() does 95 get?
2. Why does WHERE ROW_NUMBER() OVER (ORDER BY salary DESC) <= 3 fail?
3. What is the key difference between SUM(amount) OVER (PARTITION BY dept) and SUM(amount) ... GROUP BY dept?
4. What does LAG(amount) return on the first row of a partition?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.