Hook: Interviewers love this question because it looks tiny, but it tests whether you understand ties, NULLs, and window functions all at once.
Question: How do I find the second highest salary?
Answer: Use a window function such as DENSE_RANK() to rank salaries from highest to lowest. Then pick the salary with rank 2, which gives the second distinct salary, not just the second row. If there is no second distinct salary, the result should be NULL.
Interview-Ready Answer: I would rank the salaries in descending order with DENSE_RANK(), because it keeps duplicate salaries on the same rank. Then I would select the row where the rank is 2, which gives me the second highest distinct salary, and returns NULL if that salary does not exist.
Detailed Explanation: This is not asking for the second physical row after sorting. It is asking for the second distinct salary value, which is why duplicates matter. If the top salary appears three times, the answer is still the next lower salary, not one of the tied rows.
NULL salaries first, because a missing salary should not compete with real values.DENSE_RANK(), which gives equal salaries the same rank and does not skip numbers.NULL.DENSE_RANK() is the right toolDENSE_RANK() is ideal because ties share the same rank. That means two employees with salary 100000 both get rank 1, and the next salary 90000 gets rank 2. This matches the business meaning of second highest salary.
| Function | Ties | Gaps | Best use |
|---|---|---|---|
| DENSE_RANK | Same rank | No gaps | Second distinct value |
| RANK | Same rank | Yes, gaps | Competition style ranking |
| ROW_NUMBER | No ties | N/A | Pick a physical row |
Important subtlety: ROW_NUMBER() is the wrong choice here if you want the second highest salary. It numbers rows one by one, so duplicate top salaries can push the real second distinct salary to row 3 or 4.
DENSE_RANK() increments the rank by exactly 1.Use this pattern whenever the wording says second highest distinct, top N distinct, or rank within a group. It also scales nicely to per-department questions by adding PARTITION BY department_id, which means the ranking restarts inside each department.
The main cost is sorting. On a table with 10,000 rows, this is usually trivial. On a table with 1,000,000 rows, the database still has to order a lot of data, so the query is roughly O(n log n) because of the sort, with the window pass itself being linear. Space usage depends on the engine, but the sort can require memory or temporary disk space if the data does not fit in memory.
If there is an index on salary, the optimizer may use it to help the ordering step, but you should not assume that window functions become free. They still need a logically ordered view of the data.
DENSE_RANK() still gives the next distinct salary rank 2.Real-World Example: Imagine a payroll analytics dashboard in an HR platform. Managers open it to see the top salary bands in each department, including the second highest salary so they can compare compensation fairness. The query is also used in automated alerts that flag departments where salary gaps are unusually large.
One common production bug happens when a developer uses ROW_NUMBER() instead of DENSE_RANK(). If two engineers both earn 180000, the next salary 165000 should still be the second highest salary, but ROW_NUMBER() can make it look like the answer is tied to the second physical row instead. The dashboard then shows the wrong number, and HR starts asking why the report does not match payroll.
What goes wrong in the incident: support tickets spike, the report log shows duplicated top salaries, and users complain that the compensation dashboard changes after every refresh even though salaries did not change. In the worst case, a bonus rule built on the wrong ranking can trigger incorrect payouts or misleading executive reports.
WITH Employee(id, salary) AS (
VALUES
(1, 100),
(2, 200),
(3, 300),
(4, 300), -- Duplicate top salary: the second highest distinct salary is still 200
(5, NULL) -- NULL should be ignored, not ranked as a real salary
),
distinct_salaries AS (
SELECT DISTINCT salary
FROM Employee
WHERE salary IS NOT NULL
),
ranked AS (
SELECT
salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS salary_rank
FROM distinct_salaries
)
SELECT (
SELECT salary
FROM ranked
WHERE salary_rank = 2
) AS SecondHighestSalary;Follow-up & Tricky Questions:
ROW_NUMBER()? Because it counts rows, not distinct values. If the highest salary appears more than once, ROW_NUMBER() can point to a duplicate instead of the next lower salary.PARTITION BY department_id inside the window function. That restarts ranking for each department, so each group gets its own rank 1, rank 2, and so on.NULL values? Filter them out with WHERE salary IS NOT NULL before ranking. NULL means unknown, so it should not affect the top salary logic.SELECT MAX(salary) with a subquery that finds the maximum salary below the overall maximum, but that approach is less flexible for grouped rankings.NULL, which is usually the expected interview answer.DENSE_RANK().RANK() work the same as DENSE_RANK() here? Not always. RANK() leaves gaps after ties, so if the highest salary is duplicated, the next salary may become rank 3 instead of rank 2.ORDER BY salary DESC LIMIT 1 OFFSET 1? Only if you first remove duplicates, because otherwise OFFSET 1 can still land on the same highest salary. It is a row-based shortcut, not a distinct-value ranking.Common Mistakes:
ROW_NUMBER() for a distinct salary question. Correction: Use DENSE_RANK() so tied salaries share the same rank.NULL. Correction: Filter NULL salaries out before ranking.Memory Hook: Think of a podium with ties: DENSE_RANK() gives every tied person the same medal number, and the next unique score gets the next number with no gap.
Cheat Sheet:
DENSE_RANK() OVER (ORDER BY salary DESC).NULL salaries before ranking.NULL if it does not exist.ROW_NUMBER() counts rows; DENSE_RANK() counts distinct levels.Practice Tasks: