Remember GROUP BY's one iron rule from lesson 3? Rows go into buckets, and the individual rows are gone — you get one summary row per bucket. But what if I ask: "show me every employee, with their salary, and their department's average right next to it"? I want the detail and the summary, together. GROUP BY can't do that. Window functions can — and they're the topic that separates a good SQL round from a great one.
The OVER clause — aggregates that don't collapse
A window function is an ordinary calculation with a magic suffix: OVER (...). The OVER clause says: "for each row, look at a window of related rows, compute, and attach the result to the row — without collapsing anything."
SELECT name, dept_id, salary,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg
FROM employees;Result
Alice | 10 | 70000 | 66666.67 Bob | 10 | 70000 | 66666.67 Carol | 10 | 60000 | 66666.67 David | 20 | 90000 | 85000.00 Eve | 20 | 80000 | 85000.00
Every row survives. Each one carries its own department's average alongside — detail and summary together.
PARTITION BY — GROUP BY's gentler cousin
PARTITION BY dept_id divides rows into per-department windows, exactly like GROUP BY's buckets — with one crucial difference: PARTITION BY groups the calculation, GROUP BY collapses the rows. A 100-row table stays 100 rows with a window function; GROUP BY would shrink it to one row per group. Omit PARTITION BY entirely and the window is the whole table — handy for "each salary vs the company average" in one line.
ROW_NUMBER, RANK, DENSE_RANK — three ways to count to 4
Add ORDER BY inside OVER and rows get positions. Three functions assign them, and the difference only shows up on ties. Salaries 500, 400, 400, 300:
| salary | ROW_NUMBER() | RANK() | DENSE_RANK() |
|---|---|---|---|
| 500 | 1 | 1 | 1 |
| 400 | 2 | 2 | 2 |
| 400 | 3 | 2 | 2 |
| 300 | 4 | 4 ← skips 3 | 3 ← no skip |
ROW_NUMBER numbers rows blindly (ties broken arbitrarily), RANK gives ties equal rank then skips (like exam merit lists — two people at rank 1 means the next is rank 3), DENSE_RANK gives ties equal rank and never skips. Which to use for "second highest salary, including ties"? DENSE_RANK = 2 — you'll write it in the practice.
LEAD and LAG — looking at your neighbours
"How does this month compare to last month?" — the most-asked question in every business review. LAG(col) fetches the value from the previous row (in the window's order); LEAD(col) from the next:
SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change
FROM monthly_sales;Result
2026-01 | 50000 | NULL 2026-02 | 55000 | 5000 2026-03 | 52000 | -3000 2026-04 | 60000 | 8000
The first row has no previous month, so LAG returns NULL — pass a third argument, LAG(revenue, 1, 0), to default it.
Running totals — and the frame clause
Now imagine a passbook: each entry shows the balance so far. That's an aggregate over a growing window — from the first row up to the current one. The frame clause says exactly that:
SELECT sale_date, amount,
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM sales;Change the frame and you change the question: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a 3-row moving average — the smooth line on every stock chart you've seen. One subtlety for interviews: ROWS counts physical rows, while RANGE groups rows with equal ORDER BY values together as peers — and RANGE is the silent default when you write an ORDER BY with no frame, which occasionally surprises people when ties exist.
Wait — you can't WHERE a window
"Top 3 earners per department" — surely just WHERE rnk <= 3? Try it:
-- WRONG: window results don't exist yet when WHERE runs
SELECT name, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
FROM employees
WHERE rnk <= 3; -- ERROR
-- RIGHT: compute in a subquery (or CTE), filter outside
SELECT name, dept_id, salary, rnk
FROM (
SELECT name, dept_id, salary,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk
FROM employees
) ranked
WHERE rnk <= 3;Same execution-order story as lesson 1: window functions are computed with the SELECT list — after WHERE has already filtered. Wrap and filter outside. This wrap-then-filter shape is the skeleton of half of all window-function interview questions.
NTILE — slicing rows into buckets
NTILE(4) deals rows into 4 as-equal-as-possible buckets in order — instant quartiles ("which salary quartile is each employee in?"). When the count doesn't divide evenly, the earlier buckets take the extra rows: 5 rows into NTILE(4) = sizes 2, 1, 1, 1. Fewer rows than buckets? Later bucket numbers simply never get used.
Common mistakes
- Filtering on a window result directly in
WHERE— wrap it in a subquery or CTE first. - Using
RANKwhen the question says "no gaps" — that'sDENSE_RANK. - Using
ROW_NUMBERfor "including ties" questions — it arbitrarily picks one of the tied rows. - Forgetting
PARTITION BYand ranking the whole company when the question said "per department". - Assuming
LAGon the first row gives 0 — it gives NULL unless you pass a default.
Quick recap
| Function | Gives you | Classic use |
|---|---|---|
AVG/SUM(...) OVER (PARTITION BY ...) | group stats without collapsing rows | salary vs department average |
ROW_NUMBER() | unique 1, 2, 3, 4… | de-duplication, pagination keys |
RANK() | ties share, then skip | merit-list style ranking |
DENSE_RANK() | ties share, no skip | Nth highest salary with ties |
LAG / LEAD | previous / next row's value | month-over-month change |
SUM(...) OVER (ORDER BY ... ROWS ...) | running totals, moving averages | passbook balance, trend lines |
NTILE(n) | n near-equal buckets | quartiles, percentile bands |
Practice Zone — PYQs from real selection rounds
Ranking, month-over-month deltas, and the dedupe pattern every data engineer uses weekly. Attempt first, reveal second.
A 100-row employees table. I run SELECT name, salary, AVG(salary) OVER (PARTITION BY dept_id) FROM employees;. How many rows come back?
Asked in


Salaries in one partition, sorted DESC: 500, 400, 400, 300. What does RANK() assign?
Asked in


LAG(revenue) OVER (ORDER BY month) — what does it return on the very first row?
Asked in


Why does SELECT name, RANK() OVER (ORDER BY salary DESC) AS rnk FROM employees WHERE rnk <= 3; fail?
Asked in


NTILE(4) over a partition with 5 ordered rows — what bucket sizes result?
Asked in


Given the employees table below, write a query using RANK() to rank employees by salary (highest first) within each department.
Asked in


employees5 rows
| emp_id | name | dept_id | salary |
|---|---|---|---|
| 1 | Alice | 10 | 70000 |
| 2 | Bob | 10 | 70000 |
| 3 | Carol | 10 | 60000 |
| 4 | David | 20 | 90000 |
| 5 | Eve | 20 | 80000 |
Given the monthly_sales table, write a query using LAG() to compute each month's revenue change compared to the previous month.
Asked in


monthly_sales4 rows
| month | revenue |
|---|---|
| 2026-01 | 50000 |
| 2026-02 | 55000 |
| 2026-03 | 52000 |
| 2026-04 | 60000 |
The contacts table contains duplicate rows per email (from repeated updates). Write a query using ROW_NUMBER() to return only the most recently updated row for each email.
Asked in


contacts3 rows
| contact_id | updated_at | |
|---|---|---|
| 1 | a@x.com | 2026-01-01 |
| 2 | a@x.com | 2026-03-01 |
| 3 | b@x.com | 2026-02-01 |
FAQ
Window function vs GROUP BY — how do I decide?
Ask: does the answer need one row per group (use GROUP BY) or every row, enriched with group information (use a window function)? "Total sales per region" = GROUP BY. "Each sale with its region's total beside it" = window.
Are window functions available in MySQL?
Yes — since MySQL 8.0 (2018). If a question says "without window functions", they're testing the older subquery patterns from lesson 4 — know both routes to answers like Nth-highest salary.
What's the second argument in LAG(revenue, 2)?
The offset — look 2 rows back instead of 1. The third argument is the default used when there's no such row: LAG(revenue, 1, 0) gives 0 instead of NULL on the first row.
Next lesson: stacking two result sets on top of each other — Lesson 6: Set Operations →


