Skip to content
SQLSimplified

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.

AggregationWindow FunctionsInterview Questions

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.

Loading playground environment...

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:

Loading playground environment...

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:

Loading playground environment...

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:

Loading playground environment...

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.

Loading playground environment...

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:

Loading playground environment...

The DENSE_RANK version returns zero rows instead:

Loading playground environment...

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