Top N Per Group: Beginner
"Find the Top 3" Is Never About Sorting
Identify when a question requires per-group ranking instead of a global ORDER BY LIMIT.
- ▸"top N highest/lowest X per group"
- ▸"the most-recent N records for each customer"
- ▸"for every department, return the top performer"
- ▸"within each category, list the best-selling items"
- ▸Any question with the word per or each followed by a group
What the interviewer is actually testing
- ▸You read the prompt carefully
- ▸You know the pattern by name
- ▸You reach for window functions, not nested loops
Say the words 'partition by' out loud the moment you spot the pattern. That single phrase tells the interviewer you saw it. Most candidates start typing before they say anything. The scorecard rewards naming the approach more than writing the right code.
The 10-second decision
- "I'll use ORDER BY salary DESC LIMIT 3."
- Starts typing immediately
- Returns 3 rows total, not 3 per department
- Has to be told the answer is wrong
- "This is top N per group. I'll partition by department."
- Names the pattern before writing SQL
- Reaches for ROW_NUMBER OVER (PARTITION BY ... ORDER BY ...)
- Filters where rank <= N in an outer query
Why companies care
Why ORDER BY ... LIMIT Gets It Wrong
Write a correct top-N-per-group query using ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) with an outer filter.
The query you should be able to write from memory
Read this query out loud while you type it. PARTITION BY department_id is what makes each department its own ranking universe. ORDER BY salary DESC is what makes "highest" mean highest. ROW_NUMBER assigns 1, 2, 3, ... within each partition. The outer WHERE keeps rows where rn is 1, 2, or 3. The result has the top 3 from each department, no matter how many departments exist or how their salaries compare to other departments.
- ▸PARTITION BY isolates each department into its own ranking universe
- ▸ORDER BY defines what 'highest' means inside each partition
- ▸Outer WHERE filters the rank to keep only the top N
Why the outer filter is required
If your database supports it, you can use a subquery instead of a CTE: SELECT ... FROM (SELECT ..., ROW_NUMBER() OVER ... AS rn FROM employees) WHERE rn <= 3. Same result, slightly less readable. In an interview, write the CTE. CTEs read top-to-bottom; subqueries read inside-out. Readability is a scorecard item.
Walk through the trace, and the bug it catches
PARTITION BY Plus ROW_NUMBER
Choose between ROW_NUMBER, RANK, and DENSE_RANK based on whether ties should inflate, collapse, or be broken arbitrarily.
ROW_NUMBER: always exactly N
ROW_NUMBER assigns 1, 2, 3, ... arbitrarily for ties. If two employees both earn $150k and tie for second place, ROW_NUMBER picks one to be rn=2 and the other to be rn=3. The choice is non-deterministic unless you add a tiebreaker column to ORDER BY. Filtering rn <= 3 gives you exactly 3 rows per group, no matter how many ties exist. Use ROW_NUMBER when the business needs exactly N rows.
- ▸Without one: same query returns different rows on consecutive runs
- ▸Without one: impossible to reproduce in dev environments
- ▸Without one: bug surfaces only after a data refresh in prod
- ▸With one: same row wins every time, every refresh
RANK: ties get the same rank, then a gap
DENSE_RANK: ties get the same rank, no gap
| Function | 1st value | Tie at 2nd | Tie at 2nd | Next | Returns top 3 means |
|---|---|---|---|---|---|
| ROW_NUMBER | 1 | 2 | 3 | 4 | Exactly 3 rows; ties broken arbitrarily |
| RANK | 1 | 2 | 2 | 4 | 3 distinct positions with possible gaps |
| DENSE_RANK | 1 | 2 | 2 | 3 | All rows whose value is in the top 3 distinct levels |
When the interviewer asks 'what about ties?', do not just say 'I'll add a tiebreaker.' Name the three functions and which business question each answers. 'If they need exactly 3 rows, ROW_NUMBER. If they need top 3 including ties, RANK. If they need top 3 distinct salary levels, DENSE_RANK.' Showing you know the difference is a strong signal.
NULLs in the ORDER BY
The interviewer follow-up template
- "What if two employees tie for third?"
- "What if salary is NULL?"
- "What if you need a deterministic tiebreaker?"
- "Depends on the business rule. ROW_NUMBER for exactly N, RANK or DENSE_RANK if ties should be included."
- "NULLS LAST in the ORDER BY so missing salaries do not become the top of the ranking."
- "Add a secondary ORDER BY column like hire_date ASC or employee_id ASC to break ties predictably."
Filtering rn <= N
Solve top-N-per-group using a correlated subquery or self-join when the interviewer bans window functions.
The correlated subquery approach
The correlated subquery runs once per outer row. On modern engines the optimizer rewrites it into a hash semi-join. On older engines it can be O(N^2). If the interviewer asks about scale, say 'this works but it is O(N^2) on engines that cannot rewrite the subquery.' That sentence is the depth signal.
The self-join approach
- Correlated subquery: simpler, easier to read, default reach
- Self-join: slightly faster on old engines, easier to extend with extra conditions
- ROW_NUMBER: when window functions are allowed
- "I'd prefer ROW_NUMBER, but I'll write the correlated subquery if window functions are off the table."
- "Both fallbacks behave like RANK, so I'll add a tiebreaker if exactly N is required."
- "On a modern engine the optimizer rewrites the correlated subquery, but worst case it's O(N^2)."
Talking Through Top-N in an Interview
Discuss index strategies, partition pruning, and how to handle "top N with ties" at warehouse scale.
Exactly N vs at-least N: the business semantics probe
- "Give me exactly 3 employees, even if I have to break ties."
- "Give me everyone in the top 3 positions, including ties."
- "Give me everyone earning a top-3 salary level."
- ROW_NUMBER with a deterministic tiebreaker, rn <= 3
- RANK, rank <= 3 (returns Alice, Bob, Carol; Dave excluded)
- DENSE_RANK, rank <= 3 (returns everyone at the top 3 distinct salaries)
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see 'top 3 per department' | "I'll ORDER BY salary DESC LIMIT 3." | "This is top N per group. I'll PARTITION BY department_id and ORDER BY salary DESC inside ROW_NUMBER, then filter rn <= 3 in an outer query." |
| The interviewer asks about ties | "I'll add a tiebreaker." | "ROW_NUMBER if the business needs exactly 3 rows; RANK if 'top 3 including ties' is the contract; DENSE_RANK if they want top 3 distinct salary levels. Which does the consumer expect?" |
| You realize some salaries are NULL | "I'll filter them out." | "I'd add NULLS LAST to the ORDER BY so NULLs don't silently land at the top of a DESC sort. Whether to filter them depends on whether 'no salary recorded' should appear in the report at all." |
| The interviewer asks 'how does this scale' | "Window functions are fast." | "The window function sorts each partition; a composite index on (department_id, salary DESC) lets the engine walk the index in partition order without a sort step. For a dashboard, I'd materialize the result nightly rather than re-rank on every page load." |
| The interviewer says 'no window functions' | "I'm stuck." | "Correlated subquery counting how many same-group rows have a strictly greater value, filtered to < N. Behaves like RANK; on older engines it's O(N²) before the optimizer rewrites it." |
Performance: indexes and partitioning
Partitioning at warehouse scale
If the interviewer asks 'how would you compute this incrementally?', the answer is 'I would not.' Top N per group is not an incremental computation; one new row can change every rank. The pattern is to recompute the top N nightly into a separate dashboard table, and serve from that. Saying this shows you understand the gap between OLTP and OLAP.
The closing you can memorize
> You are in an Amazon data engineering screen. The interviewer asks: 'Find the top 3 highest-paid employees in each department.'
ORDER BY with LIMIT, which returns the top 3 company-wide and lets one overpaid team crowd out every other group. The trigger is 'per' or 'each' next to 'top', 'highest', or 'most'.ROW_NUMBER over PARTITION BY department_id ORDER BY salary DESC inside a CTE, then WHERE rn <= 3 in the outer query.WHERE is evaluated before window functions, so rn does not exist yet in the same SELECT that computes it.ROW_NUMBER for exactly N rows, RANK for top N including ties with a gap after them, and DENSE_RANK for the top N distinct values, which can return five rows when salaries repeat.DESC in some engines and become fake top earners, so write NULLS LAST and add a deterministic tiebreaker column to the ORDER BY.LEFT JOIN with HAVING COUNT(...) < 3; both carry RANK-style tie semantics and go quadratic on engines that cannot rewrite them.The single most common SQL interview question, hiding in plain sight
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: "Find the Top 3" Is Never About Sorting, Why ORDER BY ... LIMIT Gets It Wrong, PARTITION BY Plus ROW_NUMBER, Filtering rn <= N, Talking Through Top-N in an Interview
Lesson Sections
- "Find the Top 3" Is Never About Sorting (concepts: sqlRowNumber)
When an interviewer says 'top 3 highest-paid employees,' a brand-new candidate hears 'ORDER BY salary DESC LIMIT 3.' That answers a different question. ORDER BY LIMIT gives you the top 3 in the entire company. The interviewer asked for the top 3 in each department. Those are not the same. If your company has one wildly overpaid team, every result will come from that team and every other department will be invisible. What the interviewer is actually testing This question lives in nearly every SQL
- Why ORDER BY ... LIMIT Gets It Wrong (concepts: sqlRowNumber)
The canonical solution to every top-N-per-group question has the same shape. Compute a row number inside each group, ordered by the ranking column. Wrap it in a CTE or subquery. Filter the outer query to keep only rows where the row number is at most N. That is it. Memorize this shape. Write it without thinking. The query you should be able to write from memory Why the outer filter is required You cannot put WHERE rn <= 3 in the same SELECT as the ROW_NUMBER. SQL evaluates WHERE before window fu
- PARTITION BY Plus ROW_NUMBER (concepts: sqlRankDenseRank)
The interviewer will ask 'what if two employees have the same salary?' This is the tie question, and it is the most common follow-up on top N per group. The right answer depends on what 'top 3' means in the business context. ROW_NUMBER gives one answer. RANK gives another. DENSE_RANK gives a third. Picking the wrong one produces wrong row counts and a wrong report. ROW_NUMBER: always exactly N RANK: ties get the same rank, then a gap RANK assigns 1, 2, 2, 4, ... If two employees tie for second,
- Filtering rn <= N (concepts: sqlSubqueryCorrelated)
Most interviewers let you use window functions. Some do not. If you hear 'now solve it without window functions,' the interviewer is testing whether you understand the underlying mechanics or whether you just memorized ROW_NUMBER. Two fallback approaches work. Know both. The correlated subquery is more common in interviews. The self-join is more common in older codebases. The correlated subquery approach Idea: for each row, count how many other rows in the same group have a higher value. If that
- Talking Through Top-N in an Interview (concepts: sqlRankDenseRank)
Past the basic solution, the interviewer probes business semantics and operational reality. 'What if there's a tie at position N?' is the semantic probe. 'How does this perform on a billion rows?' is the operational probe. Both have crisp answers if you know them. Exactly N vs at-least N: the business semantics probe Suppose the question is 'top 3 highest-paid employees per department.' Now imagine Engineering has Alice ($200k), Bob ($180k), Carol ($180k), Dave ($150k). Carol and Bob are tied fo