Window Function Use Cases
Apply window functions to real problems: top-N per group, top-N with ties, finding and deleting duplicates, month-over-month change, pagination and gaps between dates.
You now know all three families. This topic collects the patterns that come up again and again in real projects and interviews — each one is a small recipe you can reuse.
Definition
A window function pattern is a standard way of combining a window function (usually in a subquery or CTE) with an outer filter or calculation to solve a common analytical problem — such as keeping the top N rows per group, detecting duplicate rows or comparing a period with the previous one.
Almost every pattern follows the same two steps:
- Inner query: add a window column (rank, previous value, running total…).
- Outer query: filter or calculate on that column (
WHERE rn = 1,WHERE rn > 1,this - previous…).
Pattern 1 — Top-N per group
"Each player's 2 best innings" → ROW_NUMBER partitioned by player.
sql
SELECT *
FROM (
SELECT id, player, runs,
ROW_NUMBER() OVER (PARTITION BY player ORDER BY runs DESC) AS rn
FROM innings
WHERE runs IS NOT NULL
) t
WHERE rn <= 2;| id | player | runs | rn |
|---|---|---|---|
| 12 | Jos Buttler | 107 | 1 |
| 10 | Jos Buttler | 73 | 2 |
| 9 | MS Dhoni | 45 | 1 |
| 7 | MS Dhoni | 26 | 2 |
| 5 | Rohit Sharma | 76 | 1 |
| 4 | Rohit Sharma | 45 | 2 |
| 1 | Virat Kohli | 82 | 1 |
| 3 | Virat Kohli | 73 | 2 |
Pattern 2 — Top-N including ties
"The top 4 scores of the season" — but 73 is shared by two innings. ROW_NUMBER would cut one 73 off arbitrarily. DENSE_RANK keeps both:
sql
SELECT *
FROM (
SELECT id, player, runs,
DENSE_RANK() OVER (ORDER BY runs DESC) AS dr
FROM innings
) t
WHERE dr <= 4;| id | player | runs | dr |
|---|---|---|---|
| 12 | Jos Buttler | 107 | 1 |
| 1 | Virat Kohli | 82 | 2 |
| 5 | Rohit Sharma | 76 | 3 |
| 3 | Virat Kohli | 73 | 4 |
| 10 | Jos Buttler | 73 | 4 |
5 rows for "top 4" — both 73s are included. Use RANK instead if "top 4" should mean "at most 4 positions, with gaps".
Pattern 3 — Bottom-N
Just flip the sort: ROW_NUMBER() OVER (ORDER BY runs ASC, id) and keep rn <= 3 → the three lowest scores: 0 (Rohit), 26 (Dhoni), 45 (Rohit, id 4).
Pattern 4 — Find and remove duplicates
Imagine the same innings was loaded twice (ids 13 and 14 are copies). Rows are duplicates when player + match_date repeat. Number each copy with ROW_NUMBER, partitioned by the columns that define a duplicate:
sql
-- extra rows (the example below adds them with setup)
INSERT INTO innings VALUES
(13, 'Virat Kohli', 'RCB', '2025-03-22', 82),
(14, 'Jos Buttler', 'RR', '2025-04-08', 107);
SELECT *
FROM (
SELECT id, player, match_date, runs,
ROW_NUMBER() OVER (PARTITION BY player, match_date ORDER BY id) AS rn
FROM innings
) t
WHERE rn > 1;| id | player | match_date | runs | rn |
|---|---|---|---|---|
| 14 | Jos Buttler | 2025-04-08 | 107 | 2 |
| 13 | Virat Kohli | 2025-03-22 | 82 | 2 |
rn = 1 is the original (lowest id) we keep; rn > 1 are the extra copies. To delete them:
sql
DELETE FROM innings
WHERE id IN (
SELECT id
FROM (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY player, match_date ORDER BY id) AS rn
FROM innings
) t
WHERE rn > 1
);
-- 12 rows remainSQL Server shortcut: SQL Server can delete directly through a CTE:
WITH d AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY player, match_date ORDER BY id) AS rn FROM innings) DELETE FROM d WHERE rn > 1;(CTEs are covered in the next chapter.)
A plain GROUP BY player, match_date HAVING COUNT(*) > 1 tells you which combinations are duplicated, but not which row ids to delete — ROW_NUMBER does.
Pattern 5 — Month-over-month change
Aggregate per month with GROUP BY, then LAG the monthly total:
sql
SELECT
MONTH(match_date) AS match_month,
SUM(runs) AS month_runs,
LAG(SUM(runs)) OVER (ORDER BY MONTH(match_date)) AS prev_month_runs,
SUM(runs) - LAG(SUM(runs)) OVER (ORDER BY MONTH(match_date)) AS change
FROM innings
GROUP BY MONTH(match_date);| match_month | month_runs | prev_month_runs | change |
|---|---|---|---|
| 3 | 361 | NULL | NULL |
| 4 | 284 | 361 | -77 |
April produced 77 fewer runs than March. Add player to GROUP BY and PARTITION BY player to the LAG for month-over-month per player.
Pattern 6 — Time gap between events
sql
SELECT id, player, match_date,
DATEDIFF(day,
LAG(match_date) OVER (PARTITION BY player ORDER BY match_date),
match_date) AS days_gap
FROM innings;Every player's matches are exactly 7 days apart (the first match of each player has NULL — there is no previous match). The same pattern measures "days between orders" per customer.
Pattern 7 — Pagination
sql
SELECT *
FROM (
SELECT id, player, runs, ROW_NUMBER() OVER (ORDER BY id) AS rn
FROM innings
) t
WHERE rn BETWEEN 5 AND 8; -- "page 2" with 4 rows per pageReturns ids 5–8. (SQL Server 2012+ also has ORDER BY id OFFSET 4 ROWS FETCH NEXT 4 ROWS ONLY.)
More patterns at a glance
| Problem | Window recipe |
|---|---|
| Running total / season so far | SUM(x) OVER (PARTITION BY p ORDER BY d ROWS UNBOUNDED PRECEDING) |
| Moving average (last 3) | AVG(x) OVER (ORDER BY d ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) |
| % of total / of group | x * 100.0 / SUM(x) OVER ([PARTITION BY g]) |
| Compare with best / average | MAX(x) OVER () - x, CASE WHEN x > AVG(x) OVER () … |
| Segmentation | NTILE(3) OVER (ORDER BY x DESC) + CASE |
| First / last event per entity | FIRST_VALUE / LAST_VALUE with full frame |
| Data-quality check for NULLs | COUNT(*) OVER (…) vs COUNT(col) OVER (…) |
Cheat sheet
| Syntax | Meaning |
|---|---|
COUNT SUM AVG MIN MAX | aggregates (GROUP BY collapses rows) |
fn() OVER (PARTITION BY .. ORDER BY .. ROWS ..) | window syntax — keeps all rows |
SUM(x) OVER (ORDER BY d) | running total |
ROW_NUMBER / RANK / DENSE_RANK | unique / gaps / no gaps |
NTILE(n) CUME_DIST PERCENT_RANK | buckets and relative position |
LAG(x, n, d) / LEAD(x, n, d) | previous / next row value |
FIRST_VALUE / LAST_VALUE | LAST needs the full frame |
WHERE rn = 1 (in a subquery) | filter on a window result |
Common mistakes
- ❌ Deleting duplicates with ROW_NUMBER partitioned by id → every row gets rn = 1 ✅ partition by the columns that define "the same record".
- ❌ "Top 3" with ROW_NUMBER when ties matter → ✅ RANK/DENSE_RANK.
- ❌ Filtering the window column in the same query's WHERE → ✅ subquery or CTE.
Interview tip
Q: Write a query to find the second-highest score per player.
A: SELECT * FROM (SELECT player, runs, DENSE_RANK() OVER (PARTITION BY player ORDER BY runs DESC) AS dr FROM innings) t WHERE dr = 2; — DENSE_RANK makes sure a tie for first place does not hide the real second-highest value.
Key points to remember
- Most patterns = window column in an inner query + filter/calculation in the outer query.
- Top-N per group: ROW_NUMBER() OVER (PARTITION BY group ORDER BY value DESC) … WHERE rn <= N.
- Top-N including ties: use DENSE_RANK (or RANK) instead of ROW_NUMBER.
- Duplicates: ROW_NUMBER() OVER (PARTITION BY the duplicate-defining columns ORDER BY id); rn > 1 are the extra copies to delete.
- Month-over-month: GROUP BY month, then SUM(x) - LAG(SUM(x)) OVER (ORDER BY month).
- LAG on dates + DATEDIFF gives the gap between consecutive events.
- ROW_NUMBER also powers pagination: WHERE rn BETWEEN start AND end.
Try it yourself
Top-N per group vs top-N with ties
Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
Find and delete duplicate innings
This example runs a small setup step first — click to see it
INSERT INTO innings VALUES
(13, 'Virat Kohli', 'RCB', '2025-03-22', 82),
(14, 'Jos Buttler', 'RR', '2025-04-08', 107);Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
SQL Server: delete duplicates through a CTE
-- SQL Server lets you DELETE directly from a CTE that reads one table
WITH numbered AS (
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY player, match_date ORDER BY id) AS rn
FROM innings
)
DELETE FROM numbered
WHERE rn > 1;SQL Server-specific — run this in SQL Server (SSMS / Azure Data Studio).
Practice: month-over-month per player
Runs on a fresh IPL practice database in your browser · edit the query and press Ctrl + Enter
Revision Flashcards
Tap a card to flip it and see the answer.
