Exercises — Window functions — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD
This page assumes you have read the parent, Window functions. If any word ("partition", "frame", "offset") feels unfamiliar, go back there first — here we only drill.
We will keep re-using one tiny dataset so you can trace every answer by hand. Meet emp:
| id | name | dept | salary |
|---|---|---|---|
| 1 | Ada | Eng | 900 |
| 2 | Bo | Eng | 900 |
| 3 | Cy | Eng | 700 |
| 4 | Di | Eng | 500 |
| 5 | Ed | Sales | 800 |
| 6 | Fi | Sales | 800 |
| 7 | Gu | Sales | 600 |
And a second tiny table sales (daily revenue), used from L3 onward:
| day | revenue |
|---|---|
| 1 | 100 |
| 2 | 130 |
| 3 | 130 |
| 4 | 90 |
| 5 | 150 |
Everything below is derivable by counting rows by hand against these two tables. The figures show exactly how the three rankers "walk the line".
Level 1 — Recognition
Goal: given a sentence, pick the right function. No SQL to write yet — just name and justify.
L1.1
You must produce, for each employee, a strict 1,2,3,… counter ordered by salary descending, with no ties allowed (arbitrary tie-break is fine). Which function?
Recall Solution
==ROW_NUMBER()==.
WHY: it is the only ranker that never repeats a number. RANK and DENSE_RANK both let equal values share a number; the requirement "no ties allowed" rules them out. So the answer is ROW_NUMBER() OVER (ORDER BY salary DESC).
L1.2
You need, for each day, the previous day's revenue sitting in the same row. Which function?
Recall Solution
==LAG(revenue, 1)== over ORDER BY day.
WHY: LAG looks backward by an offset (default 1 row). "Previous day" = 1 row before in day-order. LEAD would look forward (next day) — wrong direction.
L1.3
You want "the top 3 distinct salary levels" — if three people all earn the maximum, they should all count as level 1, and you still want two more levels below. Which ranker, and why not the other two?
Recall Solution
==DENSE_RANK()==.
WHY: "distinct levels, no gaps" is literally the definition of DENSE_RANK — ties share a number and the next number is not skipped (1,1,2,3…). RANK would skip (1,1,3…) so <= 3 might return only two distinct levels. ROW_NUMBER treats tied people as different positions, so it counts people, not levels.
Level 2 — Application
Goal: write and hand-evaluate a query on emp.
L2.1
Write a query returning name, salary and a column rn = ROW_NUMBER() over all employees ordered by salary descending. Then hand-evaluate rn for every row (ties broken by id ascending).
Recall Solution
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC, id ASC) AS rn
FROM emp;First put the rows in the exact window order salary DESC, id ASC. Salary decides first; when salaries tie, the smaller id comes first:
900(Ada,id1), 900(Bo,id2), 800(Ed,id5), 800(Fi,id6), 700(Cy,id3), 600(Gu,id7), 500(Di,id4).
Now ROW_NUMBER just counts 1,2,3,… straight down that ordered line:
| name | salary | rn |
|---|---|---|
| Ada | 900 | 1 |
| Bo | 900 | 2 |
| Ed | 800 | 3 |
| Fi | 800 | 4 |
| Cy | 700 | 5 |
| Gu | 600 | 6 |
| Di | 500 | 7 |
ROW_NUMBER just counts down the sorted line; the id ASC tie-break makes Ada beat Bo and Ed beat Fi deterministically.
L2.2
Now add RANK() and DENSE_RANK() (same ordering, salary DESC) and fill the full table by hand for emp.
Recall Solution
SELECT name, salary,
RANK() OVER (ORDER BY salary DESC) AS r,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
FROM emp;Apply the parent's formulas: , .
| salary | rows ahead | distinct ahead | r | dr |
|---|---|---|---|---|
| 900 | 0 | 0 | 1 | 1 |
| 900 | 0 | 0 | 1 | 1 |
| 800 | 2 | 1 | 3 | 2 |
| 800 | 2 | 1 | 3 | 2 |
| 700 | 4 | 2 | 5 | 3 |
| 600 | 5 | 3 | 6 | 4 |
| 500 | 6 | 4 | 7 | 5 |
See how RANK jumps 1→3→5 (skips, because two rows sit ahead each time) but DENSE_RANK walks 1→2→3→4 (counts distinct salaries ahead). The figure below makes this concrete.
The figure below shows the seven salaries laid out as blue dots in a sorted line (900, 900, 800, 800, 700, 600, 500), with the two tie-pairs bracketed. Underneath, three coloured rows give the number each ranker assigns to every position: yellow for ROW_NUMBER (1,2,3,4,5,6,7 — always increments), red for RANK (1,1,3,3,5,6,7 — shares then skips), and green for DENSE_RANK (1,1,2,2,3,4,5 — shares then no gap). The red and green arrows point exactly at the spot after the first tie where RANK jumps 1→3 while DENSE_RANK only moves 1→2.

L2.3
Write a query giving each employee their salary and dept_rn = ROW_NUMBER restarted per department, ordered by salary DESC. Hand-evaluate.
Recall Solution
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS dept_rn
FROM emp;PARTITION BY dept resets the counter at each department boundary:
| dept | name | salary | dept_rn |
|---|---|---|---|
| Eng | Ada | 900 | 1 |
| Eng | Bo | 900 | 2 |
| Eng | Cy | 700 | 3 |
| Eng | Di | 500 | 4 |
| Sales | Ed | 800 | 1 |
| Sales | Fi | 800 | 2 |
| Sales | Gu | 600 | 3 |
Notice dept_rn starts over at 1 for Sales — that is the whole point of PARTITION BY.
Level 3 — Analysis
Goal: reason about why a query fails or which ranker is correct, then fix it.
L3.1
A junior writes this to get the top earner per department. It errors. Why? Fix it.
SELECT name, dept, salary
FROM emp
WHERE ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) = 1;Recall Solution
Why it fails: window functions are evaluated after WHERE in the logical query order (FROM → WHERE → GROUP BY → SELECT → window → ORDER BY). At the moment WHERE runs, the ROW_NUMBER simply does not exist yet — so you cannot filter on it.
Fix: compute it in an inner query (or CTE), filter outside:
SELECT name, dept, salary
FROM (
SELECT name, dept, salary,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM emp
) t
WHERE rn = 1;Result: Ada (Eng, 900) and Ed (Sales, 800). Two rows — one per department.
L3.2
Using sales (revenues 100,130,130,90,150 for days 1–5), compute delta = revenue - LAG(revenue,1,0). Hand-evaluate every day, including day 1.
Recall Solution
SELECT day, revenue,
LAG(revenue, 1, 0) OVER (ORDER BY day) AS prev,
revenue - LAG(revenue, 1, 0) OVER (ORDER BY day) AS delta
FROM sales;Day 1 has no predecessor → LAG returns the default 0, so delta = 100 - 0 = 100.
| day | revenue | prev | delta |
|---|---|---|---|
| 1 | 100 | 0 | 100 |
| 2 | 130 | 100 | 30 |
| 3 | 130 | 130 | 0 |
| 4 | 90 | 130 | -40 |
| 5 | 150 | 90 | 60 |
The 0 default is what stops day 1's delta from being NULL. Without it, 100 - NULL = NULL.
L3.3
You want "employees earning the top 3 distinct salaries across the whole company". Someone uses WHERE RANK() ... <= 3. Argue whether that is right, and give the correct query.
Recall Solution
From L2.2, RANK over emp gives 1,1,3,3,5,6,7 for salaries 900,900,800,800,700,600,500. So RANK <= 3 keeps salaries 900 and 800 only — the 700 (which is the 3rd distinct level) has RANK 5 and is dropped. Wrong for "top 3 distinct levels".
Correct — use DENSE_RANK (values 1,1,2,2,3,4,5), keep <= 3, i.e. salaries 900, 800, 700:
SELECT name, salary
FROM (
SELECT name, salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
FROM emp
) t
WHERE dr <= 3;Returns 5 people: Ada, Bo (900), Ed, Fi (800), Cy (700). (Counts people, but only from the top 3 distinct levels — exactly the ask.)
Level 4 — Synthesis
Goal: combine several tools into one query.
L4.1
For each department, return each employee's salary and the department's average salary on the same row (do not collapse rows). Then a column diff = salary − dept average. Hand-evaluate for both departments.
Recall Solution
Use an aggregate as a window function — AVG(...) OVER (PARTITION BY dept). Because it has no ORDER BY, the whole partition is one frame, so every row in a dept sees that dept's overall average, and (unlike GROUP BY) no rows collapse.
SELECT name, dept, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY dept) AS diff
FROM emp;Eng salaries 900,900,700,500 → average . Sales salaries 800,800,600 → average .
| name | dept | salary | dept_avg | diff |
|---|---|---|---|---|
| Ada | Eng | 900 | 750 | 150 |
| Bo | Eng | 900 | 750 | 150 |
| Cy | Eng | 700 | 750 | -50 |
| Di | Eng | 500 | 750 | -250 |
| Ed | Sales | 800 | 733.33 | 66.67 |
| Fi | Sales | 800 | 733.33 | 66.67 |
| Gu | Sales | 600 | 733.33 | -133.33 |
Every row keeps its identity and carries its dept average — the signature move of a window aggregate. Notice Sales diffs sum to (deviations from the mean always cancel) — a nice hand-check.
L4.2
For sales, compute a running (cumulative) total of revenue by day, using a window aggregate with an explicit ordering. Hand-evaluate.
Recall Solution
Adding ORDER BY day to a SUM() OVER (...) gives a running total — the default frame becomes "all rows from the start of the partition up to the current row" (see frames).
SELECT day, revenue,
SUM(revenue) OVER (ORDER BY day) AS running_total
FROM sales;Revenues 100,130,130,90,150 accumulate:
| day | revenue | running_total |
|---|---|---|
| 1 | 100 | 100 |
| 2 | 130 | 230 |
| 3 | 130 | 360 |
| 4 | 90 | 450 |
| 5 | 150 | 600 |
The final running total (600) equals the plain grand total — a good sanity check.
L4.3
Using a CTE, return the 2nd-highest distinct salary in the whole company. Hand-evaluate.
Recall Solution
"2nd-highest distinct" → DENSE_RANK, then keep = 2, then pick the salary (deduplicated).
WITH ranked AS (
SELECT DISTINCT salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
FROM emp
)
SELECT salary FROM ranked WHERE dr = 2;Distinct salaries DESC: 900(dr 1), 800(dr 2), 700(dr 3), 600(dr 4), 500(dr 5). So dr = 2 → 800.
Level 5 — Mastery
Goal: everything at once — partition, order, offset, filter-after.
L5.1
For each department, list every employee with their salary, the next-lower salary in that department (via LEAD), and the gap to that next-lower salary. Where there is no lower salary, the gap should be 0. Hand-evaluate for both departments.
Recall Solution
Order by salary DESC inside each dept, then LEAD looks one row ahead — which is the next-lower salary. Default = salary handles the lowest-paid person (no row ahead): subtracting salary from itself gives gap 0.
SELECT name, dept, salary,
LEAD(salary, 1, salary) OVER (PARTITION BY dept ORDER BY salary DESC) AS next_lower,
salary - LEAD(salary, 1, salary) OVER (PARTITION BY dept ORDER BY salary DESC) AS gap
FROM emp;Eng, sorted DESC → 900(Ada), 900(Bo), 700(Cy), 500(Di):
| name | salary | next_lower | gap |
|---|---|---|---|
| Ada | 900 | 900 | 0 |
| Bo | 900 | 700 | 200 |
| Cy | 700 | 500 | 200 |
| Di | 500 | 500 | 0 |
Ada's next row is Bo (also 900) → gap 0. Di is last in Eng → default kicks in → gap 0.
Sales, sorted DESC → 800(Ed), 800(Fi), 600(Gu):
| name | salary | next_lower | gap |
|---|---|---|---|
| Ed | 800 | 800 | 0 |
| Fi | 800 | 600 | 200 |
| Gu | 600 | 600 | 0 |
Ed's next row is Fi (also 800) → gap 0. Gu is last in Sales → default kicks in → gap 0. Every gap is measured within its own department because of PARTITION BY dept.
L5.2 — Capstone
In one query using a CTE: for each department, return only the employees whose salary is strictly above their department average, and show how far above (above_by). Hand-evaluate the full result.
Recall Solution
Compute the per-department average as a window (keeps rows), then filter outside the CTE (you cannot filter on a window function inside WHERE — L3.1).
WITH w AS (
SELECT name, dept, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM emp
)
SELECT name, dept, salary,
salary - dept_avg AS above_by
FROM w
WHERE salary > dept_avg;Eng avg = 750 → Ada(900), Bo(900) are above (+150 each); Cy(700), Di(500) are not. Sales avg = → Ed(800), Fi(800) above ( each); Gu(600) not.
| name | dept | salary | above_by |
|---|---|---|---|
| Ada | Eng | 900 | 150 |
| Bo | Eng | 900 | 150 |
| Ed | Sales | 800 | 66.67 |
| Fi | Sales | 800 | 66.67 |
Every design decision earned: window (keep rows), PARTITION BY (per-dept average), CTE (so we can filter after the window computes), strictly > (excludes anyone exactly at the average).
Recall One-line self-check for the whole page
Ranking on ties 900,900,700? ::: ROW_NUMBER 1,2,3 · RANK 1,1,3 · DENSE_RANK 1,1,2
Why filter window results in an outer query? ::: Windows are computed after WHERE in the logical order.
Aggregate + keep every row? ::: Use AGG(...) OVER (...), not GROUP BY.
Connections
- Window functions — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD (index 4.4.9) — the parent this drills.
- SQL Logical Query Processing Order — the "why WHERE can't see the window" rule behind L3.1 & L5.
- Common Table Expressions (CTE) — the clean compute-then-filter pattern in L4.3 & L5.2.
- GROUP BY and Aggregate Functions — the collapsing cousin contrasted in L4.1.
- Self Joins — what LAG/LEAD replace in L3.2 & L5.1.
- Window Frames ROWS vs RANGE — the frame that makes L4.2's running total work.
- Subqueries and Derived Tables — the inner-query form used in L3.1 & L3.3.