How Do You Find the Second Highest Salary in SQL?
Compare the MAX subquery, LIMIT OFFSET, and DENSE_RANK approaches, and see why each one returns something different when there's a tie or no runner-up.
The one-liner that answers it
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);The inner query finds the top salary. The outer query finds the highest salary that's still below it, which is the second highest by definition. Run it against the employees table and it gets Paul's $134,000 out of the way and returns Alice's $128,000.
That's the answer for this table, today. What every tutorial on this question skips is that there are three common ways to write it, they agree on tables like this one, and they stop agreeing the moment two people tie for first place or a filter leaves you with only one salary to compare against. Which one you picked decides what your application code has to handle.
The LIMIT/OFFSET version has a tie problem
The other common approach sorts and skips a row:
SELECT DISTINCT salary
FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 1;DISTINCT is doing real work there, not just tidying the output. Drop it
and the query silently breaks the moment two people share the top salary.
Picture a new hire joining at exactly Paul's salary, $134,000:
That returns 134000, the first salary again, because two rows now tie
for the top spot and OFFSET 1 just skips one of them, landing on the
duplicate instead of the real runner-up. Put DISTINCT back and it's
correct:
Back to 128000. The MAX-of-MAX version from the top of this post never
had this problem in the first place: MAX collapses duplicates as a side
effect of being an aggregate, so it doesn't need DISTINCT to get the tie
right.
DISTINCT isn't optional here
If you've copied a LIMIT 1 OFFSET 1 salary query from somewhere without
the DISTINCT, it's returning the wrong answer right now for any group
that has a tie at the top, and giving no error to tell you.
DENSE_RANK, if you also want the people
Both queries above only give you the number. If you need to know who earns the second highest salary, and you want every tied person rather than one arbitrary row, rank the rows and filter on rank 2:
DENSE_RANK gives tied rows the same rank and doesn't skip the next
number, so both Paul and the new hire get rank 1, and Alice lands cleanly
on rank 2 without needing DISTINCT anywhere. If two people were tied for
second, this is also the only one of the three approaches that would
return both of their names instead of collapsing them into one value. For
a closer look at how DENSE_RANK differs from RANK and ROW_NUMBER,
see the DENSE_RANK example.
What happens when there's no second highest
This is the part every ranking page for this query skips: what comes back when a filter leaves you with exactly one salary to rank? Take the employees in department 4 earning over $70,000. Only Karen clears that bar.
There is no second highest here, only Karen. The two approaches don't fail
the same way. The MAX-of-MAX version returns one row with NULL:
The DENSE_RANK version returns zero rows instead:
An aggregate with no GROUP BY always produces exactly one output row,
even when nothing matches the WHERE, because it's aggregating over an
empty set rather than filtering rows out one by one. A ranked-and-filtered
query has no such guarantee: no row has rank 2, so none comes back. If
your application code checks result.rows.length === 0 to mean "no
runner-up," the MAX version will fool it, since it always hands back one
row. If it checks row.second_highest === null, the DENSE_RANK version
will fool it the other way, since there's no row to read a column from at
all. Pick the shape that matches what your calling code actually checks,
or you'll ship a query that's individually correct and still produces a
crash or a wrong default a level up.
Match the empty case to how you'll consume it
Reaching for a single scalar in application code? Use the MAX subquery,
it never returns an empty result set. Reaching for a list you'll iterate?
Use DENSE_RANK, since an empty list is already the natural "nothing
found" signal and needs no null check.
Nth highest, and portability
Every version generalizes past "second." Swap rnk = 2 for rnk = 3 to
get the third highest, or, for the subquery form, nest another level:
MAX(salary) < (that second-highest subquery) gets you the third. The
LIMIT/OFFSET version generalizes the same way: OFFSET n - 1 for the
n-th highest.
That last one is also the least portable of the three. LIMIT ... OFFSET
works as written on DuckDB, PostgreSQL, MySQL, and SQLite, but SQL Server
needs ORDER BY salary DESC OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY, and
older Oracle versions need a ROWNUM wrapper instead of OFFSET at all.
The MAX subquery and DENSE_RANK versions are standard SQL and run
unchanged on all of them.
Where to go next
The MAX reference page covers the function's syntax
and how it treats NULL salaries, which this post assumed away by using
a table with none. For ranking more than one row deep per group, for
example the top two earners in every department at once, see the
top-N-per-group example. The
practice problems are worth trying next if you want to write
one of these three variants from scratch without the queries in front of
you.
Cite this resource
SQLSimplified. "How Do You Find the Second Highest Salary in SQL?". Available at: https://sqlsimplified.online/blog/second-highest-salary-in-sql