Write a query returning the second-highest salary in each department, then tell me what it does when two people tie for the top.
To get the second highest salary per department in SQL, rank employees inside each department with DENSE_RANK and filter rank two. DENSE_RANK handles ties where ROW_NUMBER or a global MAX gives the wrong result. Use this window functions answer to show the decision, trade-off, and evidence rather than a memorised definition. It also connects sql queries to the point an interviewer is testing.
What the interviewer is scoring
- Does the candidate ask what "second-highest" means before writing anything, distinct value or second person
- Whether ROW_NUMBER, RANK and DENSE_RANK are distinguished by what they do to ties rather than recited as synonyms
- That they notice a department with one distinct salary disappears from the aggregate version
- Whether NULL salaries are considered at all, given DESC ordering puts them first in PostgreSQL
- Does the candidate test their own query mentally against a three-row example instead of declaring it correct
Answer
Short answer
Use DENSE_RANK() over partition by department order by salary desc, then keep rank = 2. That returns the second distinct salary per department and handles tied top salaries correctly.
Clarify the requirement before writing SQL
"Second-highest salary" is ambiguous, and the interviewer usually knows it. With salaries of 90,000, 90,000 and 75,000 in one department, the second-highest distinct salary is 75,000, but the second-highest-paid employee earns 90,000. Asking which one is wanted takes ten seconds and is graded. Candidates who guess have a fifty percent chance of solving a different problem well.
Assume for the rest of this answer that the requirement is the second-highest distinct salary, and that ties at the top should collapse to one rank.
The version most candidates write first
-- Wrong. The subquery is uncorrelated, so it is the company-wide maximum.
SELECT department_id, MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees)
GROUP BY department_id;
This reads convincingly and returns a plausible-looking result set, which is what makes it dangerous. The subquery excludes only the single highest earner in the whole company, so for every department except the one containing that person, the query returns that department's highest salary. One row is right and the rest are wrong, and a spot check on the first row of output will not catch it.
Correlating the subquery fixes the arithmetic:
SELECT e.department_id, MAX(e.salary) AS second_highest
FROM employees e
WHERE e.salary < (SELECT MAX(x.salary)
FROM employees x
WHERE x.department_id = e.department_id)
GROUP BY e.department_id;
This is correct for distinct salaries, and it handles ties at the top properly because the strict < removes every row holding the maximum, not just one of them. It has one behaviour worth naming out loud: a department where everybody earns the same amount contributes no surviving rows, so it vanishes from the output rather than appearing with a NULL. Whether that is a bug depends on the report, but you should be the one who says it, not the interviewer.
The window-function answer
SELECT department_id, employee_id, salary
FROM (
SELECT e.department_id,
e.employee_id,
e.salary,
DENSE_RANK() OVER (PARTITION BY e.department_id
ORDER BY e.salary DESC) AS salary_rank
FROM employees e
WHERE e.salary IS NOT NULL -- see below: DESC sorts NULLs first here
) ranked
WHERE salary_rank = 2;
The three ranking functions are not interchangeable, and the difference only shows up on tied data. On 90,000 / 90,000 / 75,000:
ROW_NUMBERproduces 1, 2, 3, so= 2returns the second person on 90,000 — the top salary again, dressed up as an answer.RANKproduces 1, 1, 3, so= 2matches nothing and the department silently disappears.DENSE_RANKproduces 1, 1, 2, which is the distinct-value semantics asked for.
ROW_NUMBER is the right choice only when you genuinely want the second-highest-paid person and are content for the tie to be broken arbitrarily. If you use it, add a deterministic tiebreaker such as ORDER BY salary DESC, employee_id so the query returns the same row on every run rather than depending on plan choice.
The detail that separates the two grades
Nullable salaries. In PostgreSQL, ORDER BY salary DESC defaults to NULLS FIRST, so an employee with an unrecorded salary takes rank 1 in their department and the genuine top earner is reported as the second-highest. Oracle behaves the same way for descending sorts. The fix is either the WHERE salary IS NOT NULL filter above or ORDER BY salary DESC NULLS LAST, and knowing which of the two you want is a real decision: filtering drops the employee entirely, while NULLS LAST keeps them in the partition at the bottom where they cannot distort the ranks you care about. Almost nobody raises this unprompted, and raising it moves an adequate answer to a strong one.
The sequence-gap variant
The same partition-and-compare shape answers the other question in this family. Given invoice numbers with holes, LAG gives each row its predecessor and any jump larger than one is a gap:
SELECT prev_number + 1 AS gap_start,
invoice_number - 1 AS gap_end
FROM (
SELECT invoice_number,
LAG(invoice_number) OVER (ORDER BY invoice_number) AS prev_number
FROM invoices
) t
WHERE invoice_number - prev_number > 1;
The first row has a NULL prev_number, and because NULL > 1 is unknown rather than true, that row is excluded by the WHERE clause without any special handling. Being able to explain why it is excluded, rather than noticing it happens to work, is the point.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Rewrite it without window functions, using only a correlated subquery or a self-join.
- Now return the Nth highest for a parameterised N. What changes?
- How would you list departments that have no second-highest salary at all?
- Given a table of invoice numbers with holes in it, find every missing range.
Related questions
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumAlso on sql-queries and null-handling4 min
- Why is a recommender built as candidate generation followed by ranking rather than as one model?hardAlso on ranking5 min
- Where does a cross-encoder reranker earn the latency it costs you?hardAlso on ranking5 min
- Why is dense retrieval on its own not enough, and how do you combine it with keyword search?hardAlso on ranking5 min