Top N Per Group: Intermediate
The ROW_NUMBER + Filter Trick
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"
- ▸"top N including ties" (a tell that the answer is RANK, not ROW_NUMBER)
- ▸"top N salary levels" (a tell that the answer is DENSE_RANK)
What the interviewer is actually testing
The pattern recognition is no longer the bar. What the interviewer wants to see is whether you ask the clarifying question before reaching for the syntax. The candidate who writes ROW_NUMBER without asking what should happen at ties is producing a query that may or may not match the consumer's intent. The candidate who pauses and asks 'should ties at position N be included or excluded?' before touching the keyboard has named the most important decision in the query. The clarifying question signals that the candidate treats reporting as a contract, not just a query.
- Writes ROW_NUMBER by reflex, picks an arbitrary tiebreaker silently
- Returns a result that happens to match the spec on clean data
- Has no answer when the interviewer asks 'what if two employees tie?'
- Defends the choice with 'that's what ROW_NUMBER does'
- Asks the tie-handling question before writing SQL
- Picks ROW_NUMBER, RANK, or DENSE_RANK based on the answer
- Names the tiebreaker column when ROW_NUMBER is the right tool
- Defends the choice with the consumer's interpretation, not the syntax
The clarifying question that earns the credit
- ▸Should ties at position N be included or excluded?
- ▸If ties are excluded, what is the tiebreaker rule?
- ▸Is the report counting distinct values or distinct employees?
Most candidates ask 'what should I do about ties?' after they have written the query and the interviewer points at the output. Asking before writing converts the question from 'fix the bug' to 'design the contract.' That conversion is the move; it changes the conversation from defending code to defining requirements.
Ties, NULLs, and the RANK Trap
Write a correct top-N-per-group query using ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) with an outer filter.
The query with the tiebreaker in place
Why the multi-column ORDER BY matters
- ▸salary DESC: "highest pay wins" (the primary metric)
- ▸hire_date ASC: "tenure breaks ties" (the tiebreaker)
- ▸employee_id ASC: "deterministic fallback" (the safety net)
Multi-column ORDER BY inside a window function is the move that distinguishes the candidate who has shipped reporting from the candidate who has not. Shipped reporting means having seen a dashboard where 'top 3' returned different rows on consecutive refreshes; the lesson learned is that ROW_NUMBER without a deterministic tiebreaker is a bug waiting for a test data refresh.
The tiebreaker order also encodes business semantics
- ORDER BY salary DESC (no tiebreaker)
- Results change between refreshes on tied rows
- Impossible to reproduce in dev environments
- Bug surfaces only after a data refresh in prod
- ORDER BY salary DESC, hire_date ASC, employee_id ASC
- Same row wins every time
- Business rule is encoded in the column order
- The tiebreaker is documented in the query itself
The dialect quirk to know
ROW_NUMBER vs RANK vs DENSE_RANK
Choose between ROW_NUMBER, RANK, and DENSE_RANK based on whether ties should inflate, collapse, or be broken arbitrarily.
Three functions, three business questions
ROW_NUMBER when the consumer needs exactly N rows per group and is willing to live with an arbitrary or business-defined tiebreaker. 'Show me the three employees who would receive the top performance bonuses.' That is exactly-N semantics. Ties have to be broken; the multi-column ORDER BY breaks them. The output has N rows per group, no matter how many ties exist.
| Business question | Right function | Returns |
|---|---|---|
| "Top N performers, exactly N" | ROW_NUMBER + tiebreaker | Exactly N rows per group |
| "Top N positions including ties" | RANK | ≥ N rows per group; gaps after ties |
| "All employees at the top N salary levels" | DENSE_RANK | ≥ N rows per group; no gaps |
| "Top N with ties broken by performance" | ROW_NUMBER + ORDER BY rating DESC | Exactly N rows per group |
The NULLS LAST move
The follow-up the interviewer always asks
- "What if two employees tie for third?"
- "What if the report needs top 3 salary levels, not 3 employees?"
- "What if salary is NULL for some rows?"
- "Depends on the business rule. RANK if both ties should appear, ROW_NUMBER with a tiebreaker if exactly 3 are needed."
- "Then DENSE_RANK is the right tool, because top 3 distinct salary levels can include any number of tied employees."
- "I'd add NULLS LAST to the ORDER BY so missing salaries do not dominate the ranking, and ask whether NULL salaries should be excluded entirely."
When the interviewer hands you the question, do not jump straight to ROW_NUMBER. Pause, ask the tie-handling question, and let the answer pick the function. The pause is the move. Many candidates think the pause looks like indecision; it looks like deliberation.
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| The interviewer says 'top 3 per department' | "I'll ROW_NUMBER and filter rn <= 3." | "Before I write SQL, should ties at position N be included or excluded, and is the report counting distinct salary levels or distinct employees? That answer picks between ROW_NUMBER, RANK, and DENSE_RANK." |
| You realize you need a tiebreaker | "I'll add employee_id to break ties." | "Salary DESC primary, hire_date ASC because longer tenure wins on ties (business rule), employee_id ASC as the absolute fallback for determinism across parallel passes." |
| The interviewer asks 'what about NULLs' | "I'll filter them." | "NULLS LAST on the ORDER BY so missing salaries don't dominate a DESC sort. Whether to filter NULL rows entirely is a contract question for the report owner; both are valid depending on intent." |
| The interviewer says 'how does it scale' | "Window functions are O(N log N)." | "Composite index on (department_id, salary DESC, employee_id) so the engine walks the index in partition order without a residual sort. For warehouses, the equivalent is a clustering key; for a dashboard, materialize the top-N into a separate table and serve from that." |
| The interviewer says 'no window functions' | "Correlated subquery counting predecessors." | "Correlated subquery counting strict predecessors gives RANK semantics. To match ROW_NUMBER exactly, extend the inequality with an employee_id tiebreaker so ties resolve deterministically. On modern engines the optimizer rewrites it; on legacy engines it's O(N*D) per partition." |
Deterministic Tiebreaks for Exactly N
Solve top-N-per-group using a correlated subquery or self-join when the interviewer bans window functions.
The correlated subquery fallback
Making the correlated subquery deterministic
Plan implications
When the no-window question is really asking
- ▸Tests whether you understand the underlying algebra, not just the syntax
- ▸Reveals which engines plan correlated subqueries efficiently
- ▸Surfaces whether you can fall back without panicking
- The optimizer rewrites it cleanly into a hash semi-join
- The output volume is small and the inner table fits in memory
- Readability matters; the subquery reads as 'rows with fewer than 3 predecessors'
- The interviewer wants to see the algebra, not the optimization
- The optimizer rewrites the subquery poorly (legacy Postgres, older MySQL)
- The plan is hard to predict and EXPLAIN shows nested-loop behavior
- The query needs to expose join columns that the subquery cannot return
- You want to manually control the join order with hints
Exactly-N vs At-Least-N Semantics
Discuss index strategies, partition pruning, and how to handle "top N with ties" at warehouse scale.
The supporting index
Partition or cluster layout for warehouses
Materialization for dashboards
Why this matters at the production level
- Writes the correct ROW_NUMBER query
- Defends it on a clean toy dataset
- Has no answer when asked about a billion rows
- Treats performance as somebody else's problem
- Names the supporting index unprompted, including the tiebreaker column
- Translates the index to a cluster key on warehouse engines
- Recommends materialization when the refresh cadence exceeds the data cadence
- Treats the query as the surface of a designed report, not as the report itself
The closing summary
> You are in a Stripe data engineering interview. The interviewer asks: 'Top 3 highest-paid employees per department, but the bonus pool requires the result to be deterministic and to honor a business rule about ties.'
RANK to include ties at position N, ROW_NUMBER plus a tiebreaker for exactly N, DENSE_RANK for the top N distinct values with all matching rows.ROW_NUMBER with no deterministic tiebreaker is a non-reproducible bug; order by the metric, then a business tiebreaker, then a guaranteed-unique column, as in salary DESC, hire_date ASC, employee_id ASC.hire_date ASC means longer tenure wins on a tie, and that choice belongs to the consumer, not to the query author.NULLS LAST explicitly, because Postgres, MySQL, and SQL Server disagree on where NULLs land and a NULL salary can otherwise sit at the top of the ranking.(SELECT COUNT(*) FROM employees e2 WHERE e2.department_id = e1.department_id AND e2.salary > e1.salary) < 3 has RANK semantics, not ROW_NUMBER; adding an OR on a unique column to the inequality converts it to exactly N.(department_id, salary DESC, employee_id) including the tiebreaker so no residual sort remains, a matching cluster key on warehouses where indexes do not exist, and a materialized top-N table when the dashboard refreshes faster than the source changes.The single most common SQL interview question, hiding in plain sight
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: The ROW_NUMBER + Filter Trick, Ties, NULLs, and the RANK Trap, ROW_NUMBER vs RANK vs DENSE_RANK, Deterministic Tiebreaks for Exactly N, Exactly-N vs At-Least-N Semantics
Lesson Sections
- The ROW_NUMBER + Filter Trick (concepts: sqlRankDenseRank)
The shape of the question rarely changes. 'Top 3 highest-paid employees per department.' 'Top 5 best-selling products per category.' 'Top 10 most-recent orders per customer.' What changes is what 'top 3' actually means when the data does not cooperate. Two employees tied for second place. A category with only two products. A NULL salary on a row that should be ignored. Every one of those edge cases changes the right query. The interviewer at this level is watching whether you anticipate the edge
- Ties, NULLs, and the RANK Trap (concepts: sqlRowNumber)
The canonical solution still applies. ROW_NUMBER inside a CTE, filtered to rn <= N in the outer query. The shape is unchanged. What changes at this level is how you talk about the tiebreaker. ROW_NUMBER without a deterministic tiebreaker returns arbitrary results on ties, and the interviewer will probe that. The query with the tiebreaker in place Why the multi-column ORDER BY matters Without a tiebreaker, ROW_NUMBER assigns positions arbitrarily for tied salaries. The same query on the same data
- ROW_NUMBER vs RANK vs DENSE_RANK (concepts: sqlRankDenseRank)
This section is where the question stops being about syntax and starts being about which tool to reach for. ROW_NUMBER, RANK, and DENSE_RANK look interchangeable. They are not. Each one answers a different business question, and picking the wrong one produces visibly plausible numbers that disagree with what the consumer asked for. The candidate at this level is being scored on whether they pick the right function from the answer to the clarifying question, not on whether they can recite what ea
- Deterministic Tiebreaks for Exactly N (concepts: sqlSubqueryCorrelated)
At this level the interviewer's no-window fallback is less about whether you remember the syntax and more about whether you understand what the optimizer does with each form. Two fallbacks work. The correlated subquery is the cleaner write. The self-join is the form that lets you reason about the join algebra directly. Knowing when each one's plan diverges is the depth signal. The correlated subquery fallback The semantics: for each row e1, count how many rows in the same department strictly exc
- Exactly-N vs At-Least-N Semantics (concepts: sqlRowNumber)
The closing escalation is about scale. The interviewer wants to know whether you can support this query at production volume. The right answer covers three layers: the supporting index, the partition or cluster layout, and the materialization strategy when the query powers a dashboard. Hitting all three is what flips this question from a basic SQL screen to a verdict the interviewer remembers at the debrief. The supporting index ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC)