In SQL, how do you return the three highest-paid employees in each department in one query?
answer
- Ranking cannot be filtered where it is computed
- Two levels: rank inside, filter outside
- PARTITION BY the group, ORDER BY the metric
- WHERE rn <= N in the outer query
basics
~20 sCompute ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) inside a CTE or derived table, then filter that column with WHERE rn <= 3 in an outer query. A window function cannot be used in WHERE directly.
solid answer
~50 sThe canonical solution has two levels. Inside a CTE or derived table I number the rows within each group: `ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn`. `PARTITION BY` restarts the numbering for every department, and the window `ORDER BY` decides who is first. The outer query then filters `WHERE rn <= 3`. The second level is not stylistic — window functions are computed after `WHERE` has already run, so you cannot write `WHERE ROW_NUMBER() OVER (...) <= 3`; the rank must exist as a column of an inner result before a predicate can see it. `LIMIT`/`FETCH FIRST` does not help either, because it caps the whole result set, not each group. If a department has fewer than three employees you simply get however many exist, and if the metric ties, `ROW_NUMBER` still returns exactly three — which of the tied rows it keeps is arbitrary unless the window `ORDER BY` includes a unique tiebreaker.
code
sql · 5 lines-- Invalid: the rank does not exist yet when WHERE runs
SELECT name, department_id, salary
FROM employees
WHERE ROW_NUMBER() OVER (PARTITION BY department_id
ORDER BY salary DESC) <= 3;go deeper
Memorise the two-level shape: a CTE that adds a rank column, then an outer query that filters it. Be able to write it from a blank screen for 'top 3 salaries per department' without hesitating.
Explain why the filter cannot live in WHERE — window functions are computed after WHERE and HAVING — and describe what PARTITION BY does to the numbering compared with GROUP BY.
Be ready to discuss determinism and ties: which ranking function matches the stated requirement, what tiebreaker makes the result reproducible, and how nullable metrics change who lands at rank 1.
Own the framing question interviewers really care about: is 'top 3 per group' a stable business definition at all when the metric ties, and what does the team agree the query should return before anyone writes SQL.
## The problem shape "Top N per group" (also called greatest-N-per-group) means: for every value of some grouping column, return the N rows with the best value of some metric — the three highest-paid employees in each department, the five most recent orders per customer, the top two products per category. The output keeps whole rows, one per qualifying record, so plain aggregation is the wrong tool: `GROUP BY department_id` collapses each department to a single row, which can give you `MAX(salary)` but not the three employees behind it. ## Why the filter needs a second query level A window function is evaluated late in the logical processing of a query — after `FROM`, `WHERE`, `GROUP BY` and `HAVING` have produced the row set it operates on. That ordering is exactly why `WHERE ROW_NUMBER() OVER (...) <= 3` is rejected by every conforming engine: at the time `WHERE` runs, the rank does not exist yet. `HAVING` does not rescue it either, because `HAVING` also runs before window functions. The fix is structural. Compute the rank in an inner query — a CTE (`WITH ranked AS (...)`) or a derived table (`FROM (...) AS t`) — where it becomes an ordinary output column. In the outer query it is just a column, and an ordinary `WHERE` can filter it. ```sql WITH ranked AS ( SELECT employee_id, name, department_id, salary, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees ) SELECT department_id, name, salary FROM ranked WHERE rn <= 3 ORDER BY department_id, rn; ``` ## Reading the OVER clause `PARTITION BY department_id` splits the rows into independent groups; the numbering restarts at 1 in each one. Unlike `GROUP BY`, partitioning does not collapse rows — every input row still comes out, now carrying its rank. `ORDER BY salary DESC` defines "best first" inside each partition. Change `DESC` to `ASC` and the same query returns the three lowest-paid per department, which is the single most common typo in this pattern. If you want the top three overall rather than per department, drop `PARTITION BY` and keep `ORDER BY salary DESC` — though for a whole-table top N, `ORDER BY salary DESC FETCH FIRST 3 ROWS ONLY` is simpler and clearer. ## Choosing the ranking function `ROW_NUMBER()` assigns 1, 2, 3, … with no duplicates, so `rn <= N` returns at most N rows per group. `RANK()` gives tied rows the same number and then skips, so `rnk <= 3` can return more than three rows when the third place is tied. `DENSE_RANK()` numbers distinct metric values, so `<= 3` means "the top three salary levels", however many people sit on them. Pick the one that matches the requirement: exactly N rows, N places including ties, or N distinct values. ## Determinism With `ROW_NUMBER()`, if two employees in a department earn the same salary, one of them gets rank 2 and the other rank 3, and nothing in the query says which. Re-running the query may return them in the other order. When the choice matters — especially for `rn = 1` style queries — add a unique tiebreaker to the window `ORDER BY`, for example `ORDER BY salary DESC, employee_id`. ## Common mistakes - `SELECT department_id, name, MAX(salary) FROM employees GROUP BY department_id` — this is not even a legal query in a strict engine, because `name` is neither grouped nor aggregated, and it could never return three rows per department anyway. - `ORDER BY department_id, salary DESC LIMIT 3` — returns three rows in total, not three per department. - Putting `LIMIT 3` in the inner query — it truncates the ranked set globally before the outer filter ever sees the other departments. - Forgetting the window `ORDER BY` entirely: `ROW_NUMBER() OVER (PARTITION BY department_id)` numbers rows in an unspecified order, so "top 3" becomes "any 3". ## Edge cases worth stating out loud A department with two employees contributes two rows — nothing pads the group up to N. A department with no employees does not appear at all, because the pattern operates on `employees`; if you need every department listed, you must start from the department table and outer-join. And if `salary` is nullable, remember that engines differ on where NULLs sort by default, so a NULL salary can land at rank 1 under `DESC` unless you spell out `NULLS LAST`.
- Why does the engine reject the window function when it appears in WHERE?Window functions are evaluated after `FROM`, `WHERE`, `GROUP BY` and `HAVING` have produced their row set — the rank is computed on the rows that survived filtering. So at the moment `WHERE` is evaluated the rank column does not exist, and there is nothing for the predicate to compare. Wrapping the ranking in a CTE or derived table turns it into an ordinary column that an outer `WHERE` can filter.
- What does the query return for a department with only two employees?Both of them. `rn <= 3` is just a predicate over whatever ranks exist, so a partition with two rows contributes two rows. Nothing pads a short group up to N, and no error is raised. A department with no employees at all contributes nothing, since the query reads the employee table.
- How would you adapt this to the top three salaries across the whole company?Remove `PARTITION BY` so the numbering runs once over the entire result: `ROW_NUMBER() OVER (ORDER BY salary DESC)`, still filtered with `WHERE rn <= 3` one level out. For a whole-table top N the simpler idiom is `ORDER BY salary DESC FETCH FIRST 3 ROWS ONLY`, which needs no window function at all.
Think of it as pinning a numbered sticker on every runner as they cross their own division's finish line, then walking the pile and keeping stickers 1 to 3. You cannot keep the sticker before it has been printed.
saying these in an interview costs you the question
- Claims GROUP BY plus LIMIT 3 returns three rows per group
- Puts ROW_NUMBER() directly in WHERE and expects it to filter
- Uses LIMIT 3 in the outer query, capping the whole result
- Omits PARTITION BY, so ranks run across the entire table
- Leaves the window ORDER BY out and still calls the result top N