Hook: Interviewers love this one because it checks whether you can rank rows inside each group, not just the whole table.
Question: How do I return the top N rows for each department in SQL?
Answer: Use a window function to assign a rank within each department, then filter in an outer query. The most common choice is ROW_NUMBER() with PARTITION BY department and ORDER BY salary DESC. If ties matter, use DENSE_RANK() instead so equal salaries all make it through the cutoff.
Interview-Ready Answer: I’d solve top N per department with a window function. I partition the rows by department, order each partition by the value I care about, like salary descending, and then assign ROW_NUMBER() if I want exactly N rows per department. I filter in an outer query where that row number is less than or equal to N. If the business rule wants ties included, I switch to DENSE_RANK() so everyone tied at the cutoff is returned.
Detailed Explanation: A window function is a function that looks at a set of rows related to the current row, but it does not collapse them into one result like GROUP BY does. For top N per department, each department becomes its own little contest, and the database hands out ranks inside that contest.
FROM and removes rows that fail WHERE.PARTITION BY department splits the remaining rows into separate groups, one partition per department.ORDER BY salary DESC sorts rows inside each partition from highest salary to lowest.ROW_NUMBER() assigns 1, 2, 3, and so on inside each department. This is just a position counter.rn <= 3, because window functions are computed after WHERE in SQL’s logical order.Memory model: imagine every department has its own race lane. The window function gives medals within each lane, not across the whole stadium.
ROW_NUMBER() when you want exactly N rows per department, even if salaries tie.DENSE_RANK() when you want to include all ties at the cutoff. That may return more than N rows.employee_id, so results are deterministic when two rows have the same salary.| Function | Best for | Tie behavior |
|---|---|---|
| ROW_NUMBER | Exact N rows | Breaks ties |
| RANK | Competition style | Gaps after ties |
| DENSE_RANK | Ties included | No gaps |
For interview answers, the key sentence is: ROW_NUMBER gives exactly N rows; DENSE_RANK gives all rows tied at the cutoff.
O(n log n) because each partition must be ordered. If the engine can use a helpful index, it may do less sorting work.(department, salary DESC, employee_id) can reduce sort cost in some engines.NULL department usually form their own partition because PARTITION BY groups equal values together, including NULLs in most systems.ORDER BY, equal-salary rows can appear in any order. That is a classic interview gotcha.Rule of thumb: If the question says “top N per group,” think PARTITION BY + ranking function + outer filter. If it says “include ties,” reach for DENSE_RANK().
Real-World Example: In a payroll analytics dashboard for an HR system, managers want the top 3 earners in each department. A developer once used LIMIT 3 after ordering by salary. That returned only 3 rows total for the whole company, not 3 per department, so most departments looked empty on the dashboard.
What went wrong in production: the chart showed only a tiny slice of the company, executives assumed the ETL job had dropped rows, and support saw tickets like “Engineering disappeared from the report.” The logs were misleading because the query succeeded and returned data; it was the wrong shape of data, not a crash. The fix was to rank rows inside each department with ROW_NUMBER(), then filter by the generated rank.
-- Example: top 2 earners per department.
-- ROW_NUMBER() gives exactly 2 rows per department, even when salaries tie.
-- DENSE_RANK() is shown too, because tied salaries are the most common edge case.
CREATE TABLE employees (
employee_id INTEGER,
employee_name VARCHAR(50),
department VARCHAR(50),
salary INTEGER
);
INSERT INTO employees (employee_id, employee_name, department, salary) VALUES
(1, 'Ava', 'Sales', 120000),
(2, 'Ben', 'Sales', 110000),
(3, 'Cara', 'Sales', 110000),
(4, 'Dan', 'Sales', 90000),
(5, 'Eli', 'Engineering', 150000),
(6, 'Fay', 'Engineering', 140000),
(7, 'Gus', 'Engineering', 130000),
(8, 'Hana', 'HR', 80000),
(9, 'Ivy', 'HR', 75000),
(10, 'Jon', 'HR', 75000),
(11, 'Kai', 'Support', 70000);
-- Good pattern: rank inside each department, then filter outside.
WITH ranked AS (
SELECT
department,
employee_id,
employee_name,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, employee_id
) AS rn,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS salary_rank
FROM employees
)
SELECT
department,
employee_id,
employee_name,
salary,
rn
FROM ranked
WHERE rn <= 2
ORDER BY department, rn, employee_id;
-- If the business wants ties included, use DENSE_RANK instead.
-- Notice how Sales returns 3 rows here because Ben and Cara tie at 110000.
WITH ranked AS (
SELECT
department,
employee_id,
employee_name,
salary,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS salary_rank
FROM employees
)
SELECT
department,
employee_id,
employee_name,
salary,
salary_rank
FROM ranked
WHERE salary_rank <= 2
ORDER BY department, salary_rank, employee_id;
-- Wrong idea to avoid:
-- SELECT ... FROM employees ORDER BY salary DESC LIMIT 2;
-- That gives 2 rows total, not 2 rows per department.Follow-up & Tricky Questions:
DENSE_RANK() instead of ROW_NUMBER(). If the cutoff is rank 2, everyone with rank 1 or 2 stays, even if that means more than N rows.rn <= :n. The ranking logic stays the same.ORDER BY, like salary DESC, hire_date ASC, employee_id. That gives a deterministic order.ORDER BY salary ASC. The ranking idea is identical; only the direction changes.LIMIT 3 solve it? LIMIT applies to the final result set, so it caps rows globally. You need a per-department rank, which means partitioning first and filtering later.WHERE? Usually no, because WHERE is evaluated before window functions in SQL’s logical order. That is why you wrap the ranked rows in a CTE or subquery.employee_id to make the result predictable.Common Mistakes:
LIMIT instead of partitioning: This returns top rows overall, not top rows per department. Correction: use PARTITION BY department plus a rank filter.WHERE rn <= 3 right after defining rn. Correction: put the window function in a CTE or subquery, then filter outside.ROW_NUMBER() for exact N or DENSE_RANK() for tie-inclusive results.employee_id to the ORDER BY.Memory Hook: Think: “Each department gets its own podium.” First rank inside the department, then take the medal winners.
Cheat Sheet:
PARTITION BY = split into independent groups.ORDER BY inside the window = define the top order.ROW_NUMBER() = exactly N rows.DENSE_RANK() = include ties at the cutoff.WHERE.Practice Tasks:
salary DESC, employee_name ASC and observe how ties change.