skip to content

What does COUNT(DISTINCT h.salary) + 1 compute in a self-join with ON h.salary > e.salary?

level: middleimportance: should knowfreq 52%

answer

  1. a rank is a count of better rows
  2. the join predicate is an inequality
  3. the top earner matches nothing
  4. distinct values versus matched rows
  5. plus one for the row itself

basics

~20 s

It computes each employee's salary rank: the number of distinct salaries above theirs, plus one for their own. Tied salaries share a rank and the numbering has no gaps, matching dense-rank behaviour, provided the join is a LEFT JOIN so the top earner survives.

solid answer

~50 s

The non-equi self-join pairs every employee `e` with all employees `h` who earn strictly more; counting the **distinct** higher salaries and adding one gives `e`'s position in the salary ordering. ```sql SELECT e.employee_id, e.salary, COUNT(DISTINCT h.salary) + 1 AS salary_rank FROM employees AS e LEFT JOIN employees AS h ON h.salary > e.salary GROUP BY e.employee_id, e.salary; ``` Three details carry the answer. The join must be LEFT, or the top earner — who has no one above them — vanishes. `COUNT(DISTINCT ...)` gives dense ranking with no gaps; counting rows instead (`COUNT(h.employee_id) + 1`) gives competition ranking, where ties consume the numbers below them. And `COUNT(*)` is wrong under LEFT JOIN: it counts the NULL-extended row, so the top earner gets 2. Filter with `HAVING COUNT(DISTINCT h.salary) = n - 1` for the nth-highest salary.

code

sql · 6 lines
sql
SELECT e.employee_id, e.salary,
       COUNT(DISTINCT h.salary) + 1 AS salary_rank
FROM employees AS e
LEFT JOIN employees AS h ON h.salary > e.salary
GROUP BY e.employee_id, e.salary
ORDER BY salary_rank;

go deeper

for a junior

Recall that a rank can be computed by counting how many rows beat a given row. Be able to read the query and say what the count plus one means for one employee.

for a middle

Explain why the join must be LEFT, why COUNT(*) misreports the unmatched row, and how counting distinct salaries versus matched rows changes tie handling. Add HAVING to pick the nth value.

for a senior

Show that you know this shape produces roughly half of all ordered row pairs before aggregation, so it belongs to small data or teaching contexts, and be explicit about which tie semantics the requirement needs.

for a principal

Decide when a ranking belongs in a query at all versus a precomputed or incrementally maintained value, and make sure tie semantics are defined once for the organisation rather than per report.

## Ranking expressed as counting A rank is just a count: your position in a descending list is the number of things ahead of you, plus one. A self-join makes "the things ahead of you" concrete — join the table to itself on a **non-equi** predicate that means "strictly better", and count the matches per row. ```sql SELECT e.employee_id, e.name, e.salary, COUNT(DISTINCT h.salary) + 1 AS salary_rank FROM employees AS e LEFT JOIN employees AS h ON h.salary > e.salary GROUP BY e.employee_id, e.name, e.salary ORDER BY salary_rank; ``` This is the classic pre-window-function formulation of ranking, and it is still asked because it tests whether you understand joins as row pairing rather than as table lookup. ## Why LEFT JOIN, not JOIN The highest-paid employee has nobody earning more, so the predicate `h.salary > e.salary` matches nothing for that row. An inner join therefore drops exactly the row you most wanted — rank 1 is missing from a ranking query, which is a memorable bug. `LEFT JOIN` preserves `e` and NULL-extends all `h` columns. ## Why COUNT(DISTINCT ...) and not COUNT(*) Two separate issues hide in the choice of counting expression. First, NULLs. Under `LEFT JOIN`, the unmatched top earner still contributes **one** row to its group, with every `h` column NULL. `COUNT(*)` counts rows, so it returns 1 and the top earner ranks 2. `COUNT(h.employee_id)` and `COUNT(DISTINCT h.salary)` ignore NULLs and correctly return 0. Getting this wrong shifts the entire ranking by one for exactly one row, which is easy to miss in a spot check. Second, ties. Counting distinct higher **salaries** produces a dense ranking: three people on 100, 100 and 90 rank 1, 1 and 2. Counting matched **rows** produces a competition ranking: 1, 1 and 3, because the person on 90 has two people above them. Neither is wrong — they answer different questions — but you must say which one the requirement wants: ```sql -- competition ranking: ties consume the numbers below them COUNT(h.employee_id) + 1 AS salary_rank ``` ## Selecting the nth highest Because the rank is an aggregate, `HAVING` filters it. The nth-highest distinct salary is the one with exactly n-1 distinct salaries above it: ```sql SELECT e.salary FROM employees AS e JOIN employees AS h ON h.salary > e.salary GROUP BY e.salary HAVING COUNT(DISTINCT h.salary) = 1; -- second-highest distinct salary ``` Here the inner join is fine because n-1 is at least 1, so a match is guaranteed. Grouping by `e.salary` rather than by employee returns the salary once, however many people are on it; grouping by employee returns every person at that rank. ## Per-group ranking Add the partitioning column as an equality alongside the inequality — this is the shape of every "rank within department" question: ```sql SELECT e.employee_id, e.department_id, COUNT(DISTINCT h.salary) + 1 AS dept_rank FROM employees AS e LEFT JOIN employees AS h ON h.department_id = e.department_id AND h.salary > e.salary GROUP BY e.employee_id, e.department_id; ``` The equality confines the comparison to the employee's own department; forgetting it silently ranks everyone against the whole company. ## Reading the shape of the output Before aggregation this join emits, for each employee, one row per better-paid employee — roughly half of all ordered pairs across the table. That is quadratic in the row count by construction, which is why this formulation is a teaching and small-data tool rather than the thing you run over millions of rows. It is worth saying out loud in an interview, along with the modern alternative: `DENSE_RANK() OVER (ORDER BY salary DESC)` expresses the same result directly on engines with window-function support, which entered the standard in SQL:2003. Knowing both, and knowing they agree, is the point. ## What separates a good answer Stating that a rank is a count of better rows; naming LEFT JOIN and *why*; distinguishing `COUNT(DISTINCT salary)` from `COUNT(rows)` as dense versus competition ranking; spotting that `COUNT(*)` is broken here specifically because of the NULL-extended row; and knowing how to pin the nth value with `HAVING`.

  • Why must the join be a LEFT JOIN here?
    The highest-paid employee has no row satisfying `h.salary > e.salary`, so an inner join discards them and the result starts at rank 2 with rank 1 missing. LEFT JOIN keeps the row and NULL-extends the `h` columns, and because `COUNT(DISTINCT h.salary)` skips NULLs it correctly yields 0, giving rank 1.
  • What changes if you write COUNT(h.employee_id) + 1 instead of COUNT(DISTINCT h.salary) + 1?
    You switch from dense to competition ranking. Counting matched rows counts people, so if two employees tie at the top the third-highest earner gets rank 3 rather than rank 2. Both are legitimate; pick the one the requirement asks for and say which you used.
  • How do you rank employees within their own department using this pattern?
    Add the partition as an equality next to the inequality: `ON h.department_id = e.department_id AND h.salary > e.salary`. The comparison set is then confined to the employee's department. Omitting the equality is a silent bug — the query still runs and returns company-wide ranks under a column named like a departmental one.

saying these in an interview costs you the question

  • Uses an inner join and loses the top-ranked row
  • Writes COUNT(*) and ranks the top earner 2
  • Confuses dense ranking with competition ranking
  • Forgets the partition equality when ranking per group
  • Thinks the inequality join needs an ORDER BY to work

context