Hook: Interviewers love this one because it checks whether you can rank inside groups, not just sort one big table.
Question: How do you return the top 3 salaries per department in SQL?
Answer: Use a window function such as ROW_NUMBER() or DENSE_RANK() with PARTITION BY department and sort salaries descending. That gives each employee a rank inside their own department, so you can keep only the first 3 rows or the first 3 salary levels. The key detail is whether ties should be included.
Interview-Ready Answer: I would partition the employees by department, order each department by salary descending, and assign a window rank. If the requirement means the top 3 salary levels including ties, I’d use DENSE_RANK() and filter to ranks <= 3; if it means exactly 3 rows per department, I’d use ROW_NUMBER(). That is clean, readable, and much safer than writing separate queries per department.
“Top 3 salaries per department” means every department gets its own leaderboard. You are not ranking the whole company; you are ranking only within each department. The subtle part is the word top: if two people tie for third place, do you want both of them or only one row? In interviews, say your assumption out loud.
PARTITION BY department. A partition is just a mini-table inside the window function.ORDER BY salary DESC.<= 3.Memory model: think of each department as a separate podium. You hand out gold, silver, and bronze inside each room, not across the whole building.
| Function | Ties | Best for |
|---|---|---|
ROW_NUMBER() | Breaks ties | Exactly 3 rows per department |
RANK() | Shares rank, leaves gaps | Competition-style ranking |
DENSE_RANK() | Shares rank, no gaps | Top 3 salary levels including ties |
For this interview question, DENSE_RANK() is usually the safest answer because “top 3 salaries” often means the top 3 distinct salary values, and all employees tied at those values should appear. If the interviewer says “exactly three employees per department,” switch to ROW_NUMBER() and add a stable tie-breaker like employee_id.
Before window functions, people used correlated subqueries or self-joins. Those can work, but they are harder to read and often slower on big tables. A modern window-function query is usually the cleanest interview solution and the one most teams would ship today.
In practice, this is usually dominated by sorting. A rough mental model is O(n log n) overall, because the database must order rows inside each department. On a table with 1 million employees across 200 departments, that is still very manageable on a modern server, but if memory is tight the sort can spill to disk and slow down.
An index on (department, salary DESC) can help the optimizer, especially if the query returns only a few columns. But an index is not magic; the engine may still sort depending on the plan, the selected columns, and the database version. If window functions are unavailable, such as on older MySQL 5.7-style systems, you would need a more manual approach with a self-join or correlated subquery.
Real-World Example: Imagine a SaaS payroll dashboard that shows each department’s top earners to finance managers. The query powers a monthly report used for bonus planning, so accuracy matters. If the team uses plain LIMIT 3, the dashboard shows the top 3 salaries across the whole company, which is obviously wrong but easy to miss in testing because the query still returns rows.
Now add ties. Suppose two engineers share the third-highest salary. If the business wants both names shown, DENSE_RANK() is correct. If a developer mistakenly uses ROW_NUMBER(), one employee disappears from the report, and finance thinks the data is incomplete. The symptoms are subtle: no SQL error, but managers file tickets because the headcount and bonus numbers do not match the HR system.
In production, that kind of bug shows up as a quiet trust problem. The dashboard looks polished, but the numbers do not reconcile. That is exactly why interviewers ask this question: they want to see whether you can turn a vague business phrase into a precise SQL rule.
-- Top 3 salary levels per department, including ties.
-- This is a realistic demo: it ignores NULL salaries, keeps tied salaries together,
-- and shows the exact place where you'd switch to ROW_NUMBER() if the business wants
-- exactly 3 employees instead of 3 salary levels.
WITH employees(employee_id, employee_name, department, salary) AS (
VALUES
(1, 'Ava', 'Engineering', 150000),
(2, 'Ben', 'Engineering', 140000),
(3, 'Chloe', 'Engineering', 140000), -- tie at the second salary level
(4, 'Diego', 'Engineering', 130000),
(5, 'Esha', 'Engineering', NULL), -- edge case: unknown pay should not rank
(6, 'Fatima','Sales', 120000),
(7, 'Gabe', 'Sales', 115000),
(8, 'Hana', 'Sales', 115000), -- tie at the second salary level
(9, 'Ivan', 'Sales', 110000),
(10,'Jin', 'Support', 90000),
(11,'Kai', 'Support', 85000)
),
cleaned AS (
SELECT *
FROM employees
WHERE salary IS NOT NULL
),
ranked AS (
SELECT
employee_id,
employee_name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, employee_id
) AS row_num,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS salary_rank
FROM cleaned
)
SELECT
department,
employee_id,
employee_name,
salary,
row_num,
salary_rank
FROM ranked
WHERE salary_rank <= 3
ORDER BY department, salary DESC, employee_id;
-- If the interviewer says "exactly 3 rows per department," replace:
-- WHERE salary_rank <= 3
-- with:
-- WHERE row_num <= 3
--
-- Why the tie-breaker matters:
-- ROW_NUMBER() needs a deterministic ORDER BY, so employee_id makes the result stable
-- when two people share the same salary.
Follow-up & Tricky Questions:
DENSE_RANK(). If you want exactly three employees, use ROW_NUMBER() with a deterministic tie-breaker like employee_id.RANK() and DENSE_RANK()? RANK() leaves gaps after ties, while DENSE_RANK() does not. For “top 3 salary levels,” DENSE_RANK() is usually easier to reason about.employee_id, in the ROW_NUMBER() order clause. That prevents the database from choosing a random winner when salaries tie.(department, salary DESC) often helps, but the main cost is still sorting. If the table is very large, check the execution plan and watch for disk spills.LIMIT 3? Because LIMIT applies to the whole result set, not each department. It gives you three rows total, which is the wrong shape for this problem.GROUP BY department and MAX(salary)? That only gives one salary per department. You lose the employee rows, and you cannot get the second and third places from a single MAX.Tricky gotchas:
ROW_NUMBER() include all ties? No. It picks one row at a time, so ties are broken by the ORDER BY list.Common Mistakes:
LIMIT 3: This returns three rows total, not three per department. Correction: use PARTITION BY department with a window function.ROW_NUMBER() and DENSE_RANK() answer different business questions. Correction: say whether you want exactly three rows or the top three salary levels.Memory Hook: Think of each department as its own podium: gold, silver, bronze. You do not mix the podiums together; you award medals in every room separately.
Cheat Sheet:
PARTITION BY department = separate leaderboard per department.ORDER BY salary DESC = highest salary first.ROW_NUMBER() = exactly 3 rows, ties broken.DENSE_RANK() = top 3 salary levels, ties included.<= 3 after ranking, not before.employee_id.Practice Tasks:
ROW_NUMBER() instead of DENSE_RANK().