Top N Per Group: Beginner
You are 10 minutes into a SQL screen at a fintech, sharing a scratchpad with the hiring manager. She pastes a table: employees, with employee_id, department_id and salary, 6 rows showing.
"Give me the 3 highest-paid people in each department."
She stops talking. The sentence has a number and a ranking word, and most hands are already typing ORDER BY salary DESC LIMIT 3 before the word 'each' has done its work. Nobody has said what should happen when a department has 2 people, and the 3 rows that come back will all carry the same department_id, which she will notice before you do.
What she is testing is whether 'in each department' registers as a ranking that restarts for every department, and whether you can say that plan out loud before a line of SQL exists.
What you will be able to do
A grouping word and a ranking word in 1 sentence
The tell is a grouping word and a ranking word in the same sentence, usually with a number between them. 'Per department' names the group, 'highest' names the direction of the sort, 'salary' names the column being sorted and '3' names the cutoff. Those 4 pieces are the whole specification, and reading them out in that order (group, sort column, direction, N) is the first move the interviewer is listening for. Say the group column's name aloud: 'per department' means department_id, 'for each customer' means customer_id. Naming it before any SQL exists shows the sentence was parsed rather than matched to the nearest query remembered from a tutorial.
| Question | Group column | Sort column and direction | N |
|---|---|---|---|
| 3 top earners per department | department_id | salary DESC | 3 |
| 2 most recent orders for each customer | customer_id | order_date DESC | 2 |
| Best-selling item in every category | category_id | units_sold DESC | 1 |
| Cheapest listing per seller | seller_id | price ASC | 1 |
| Within each class, the 3 highest scores | class_id | score DESC | 3 |
The disguises it wears
N at 1 is the version that shows up most at work. A table loaded twice holds 2 copies of every order, and 'keep the most recently loaded copy of each order' reads as top 1 per order_id ordered by load time descending. Interviewers accept the per-group ranking as the standard deduplication answer, so recognizing N at 1 as the same question covers 2 common screens with 1 pattern. The ranking column changes from a salary to a timestamp and the number changes from 3 to 1, and the query underneath keeps its shape.
What a global top N looks like next to it
- 'Top 10 customers by lifetime spend'
- 'The 5 longest-running queries yesterday'
- 'The highest salary in the company'
- Answered by ORDER BY and LIMIT, and the result holds at most N rows
- 'Top 10 customers by spend in each country'
- 'The 5 longest-running queries per user'
- 'The highest salary in each department'
- Answered by a numbering that restarts in every group, and the result holds up to N rows for each distinct group value
- ▸Reading: did 'per department' land as 1 ranking per group rather than a sort of the whole table
- ▸Naming: before any keystroke, did the candidate name the group column and N, and say which way the sort column runs
- ▸Reach: whether the first tool reached for was a numbering that restarts per group; LIMIT misses, and so does a loop of 1 query per department
- ▸Checking: was there a question about what the result should hold when a group has fewer than N rows
| department_id | employee_id | salary |
|---|---|---|
| 10 | 101 | 180000 |
| 20 | 201 | 90000 |
| 10 | 102 | 160000 |
| 10 | 103 | 140000 |
| 20 | 202 | 85000 |
| 10 | 104 | 120000 |
The query most hands type first
Department 10 holds the 4 largest salaries on the table, so the sort fills all 3 slots from department 10 and department 20 never appears. The query runs without an error and returns 3 well-formed rows, which is what makes it dangerous: a dashboard built on it shows 3 engineers under a heading reading 'top earners by department', and the mistake surfaces weeks later when the Sales manager asks where her team went. LIMIT is 1 counter over the whole result, and SQL has no clause meaning 'take 3 from each group'.
Reading the result back is the check that catches it in the room: 3 rows, and 1 value of department_id repeated down the column. A per-group result shows every department the table has, so a result where the group column holds a single repeated value is the signature of a global sort. Say it when you see it: 'every row here is department 10, so this ranked the table rather than each department.'
Why ORDER BY and LIMIT answer a different question
ORDER BY arranges the rows into 1 sequence and LIMIT cuts that sequence at a fixed position. Both clauses see the table as a single list, and neither knows that department_id divides the list into groups. The question asked for 2 lists, 1 per department, each cut at 3. Sorting by department first, ORDER BY department_id, salary DESC, puts the groups next to each other, and LIMIT 3 still cuts once, so the result is the 3 highest earners of whichever department sorts first. The question needs a cutoff applied once per group, and the sorting clauses have no notion of a group.
The patch that turns 1 query into 1 per department
The next move, once the missing department is noticed, is a filter: WHERE department_id = 20 with LIMIT 3, then the same for department 10, then UNION ALL to glue the 2 results. On 2 departments it produces the right 5 rows. On a real table it needs 1 query per department, the list of departments has to be known when the SQL is written, and a department created next month is missing from the report until someone edits the query. Interviewers hear this patch and ask 'and if there are 400 departments?', which is the question the approach cannot answer.
- ▸Returning 3 rows and reading them back without noticing that every row carries the same department_id
- ▸Adding department_id ahead of salary DESC in the sort but keeping LIMIT 3, which still cuts the list once
- ▸Patching with 1 query per department, which breaks as soon as a department is added
- ▸Hearing 'where is department 20?' and raising LIMIT to 5, which returns 104, department 10's 4th earner, and drops 202 on this very table
A window function computes a value for every row from the rows around it, without collapsing them the way GROUP BY does. The table stays 6 rows and gains a column. ROW_NUMBER is the window function that hands each row a position, and the OVER clause after it says 2 things: which rows share a numbering, and in what order they are numbered. Those 2 things are the group and the sort from the question, written as SQL.
- ▸
ROW_NUMBER(): numbers the rows upward from 1 with no repeats and no gaps, so every row in a numbering gets a distinct position - ▸
PARTITION BY department_id: splits the rows into 1 numbering per department, so the count starts over at 1 when the department changes - ▸
ORDER BY salary DESC: decides what position 1 means inside each department; DESC puts the largest salary first - ▸
AS rn: names the new column so a later query can refer to it
Watching the counter restart
SELECTdepartment_id,employee_id,salary,ROW_NUMBER() OVER (PARTITION BY department_idORDER BY salary DESC) AS rnFROM employees
ROW_NUMBER restarts at 1 in each department, so the outer filter can keep three per group
The engine gathers the rows of department 10, sorts them by salary descending and numbers them: 101 at 180,000 gets 1, 102 at 160,000 gets 2, 103 at 140,000 gets 3 and 104 at 120,000 gets 4. The partition changes to department 20 and the counter starts over: 201 at 90,000 gets 1 and 202 at 85,000 gets 2. No row has been dropped at this stage, so the output holds all 6 rows plus rn, and the value of rn on each row is its position inside its own department, which is the per-group cutoff the question needs, written down as data a filter can read.
The bug that wears a window function
Leave out PARTITION BY and the numbering runs down the whole table: 201 gets 5 and 202 gets 6. A filter at 3 then returns 3 rows, all from department 10, which is the LIMIT answer from the previous section wearing a window function. Interviewers trace this by hand with 1 question, 'what number does 201 get?', and the answer 5 is the moment the bug shows. The defence is to say the clause before typing it: 'PARTITION BY department_id is what gives each department its own ranking.'
The direction is half the answer
ORDER BY salary DESC inside the OVER clause is where 'highest' is encoded. Drop DESC and the numbering runs upward: 104 at 120,000 gets 1 in department 10, and the filter in the next section returns the 3 lowest earners starting from 104, so the 4th earner replaces the top earner 101. Department 20 looks right only because both of its rows survive either way, which is why the bug slips past a quick read. 'Most recent' means a timestamp descending, 'cheapest' means a price ascending, 'best-selling' means a unit count descending. Say the direction when you say the column: 'ordered by salary descending' is the whole phrase, and an interviewer who hears only 'ordered by salary' waits to see which way it goes.
Same clause, different question
'The 2 most recent orders per customer' changes 3 tokens and nothing else: the cutoff moves to 2 after PARTITION BY customer_id and ORDER BY order_date DESC go in. 'Cheapest listing per seller' is PARTITION BY seller_id ORDER BY price ASC with a cutoff of 1. The clause has 3 slots for the question to fill. The group is one; the sort column and its direction are the other 2. Once those 3 have been said aloud, writing the clause is transcription, and the interviewer can check the transcription against the sentence you gave.
A follow-up around 2 people earning the same salary is common once this query runs. ROW_NUMBER gives tied rows 2 different positions, and the order among them is not guaranteed: it can change from run to run, even on the same engine. The answer that scores is to say so and ask whether the report wants exactly 3 rows or everyone who ties into the top 3. The 3 ranking functions and their tie rules have a lesson of their own.
The most common live error on this question lands 20 seconds after the pattern is recognized. rn is defined in the SELECT list, WHERE rn <= 3 is added to the same query, and the run fails with 'column rn does not exist'. The reason is the order in which the engine evaluates clauses, and an interviewer who sees the error asks for that reason, so explaining it correctly turns the fumble into a point.
The order the engine actually runs
WHERE runs in step 2 and rn appears in step 4, so at the moment WHERE is evaluated the column has no value and no name. This is also why the numbering has to finish before the cutoff can be applied: ROW_NUMBER needs to see every row of a department to know which one is third, and a filter that ran before it would change the rows being numbered. The cutoff has to sit in a query that starts after the numbering query has finished, which means a second SELECT that reads the first.
The filter reads rn from a finished query, so it runs after every department has been numbered
The shape that works
The CTE named ranked is the whole table with rn attached, 6 rows. The outer query's FROM reads ranked, so by the time its WHERE runs, rn is a base column of the thing being read and the comparison is allowed. The filter keeps rows numbered 3 or lower: department 10 loses 104 at position 4, department 20 keeps both of its rows at positions 1 and 2, and the result has 5 rows. Add 10 people to department 20 and the query returns 3 of them; shrink department 10 to 2 people and it returns those 2. The query never asks how large a group is.
| department_id | employee_id | salary |
|---|---|---|
| 10 | 101 | 180000 |
| 10 | 102 | 160000 |
| 10 | 103 | 140000 |
| 20 | 201 | 90000 |
| 20 | 202 | 85000 |
A subquery in the FROM clause does the same job with the same rows: the numbering SELECT goes inside parentheses as FROM (...) AS ranked, and WHERE rn <= 3 follows it. The CTE reads better in the room because the numbering step has a name you can point at, and interviewers follow a named step more readily than a nested one. Snowflake offers QUALIFY rn <= 3, as do BigQuery and DuckDB: a clause that runs after the window functions and saves the second SELECT. It's worth naming as a dialect shortcut, with the CTE as the version that runs everywhere.
Reading the cutoff
rn <= 3 and rn < 4 keep the same rows. rn < 3 keeps 2 per department, the off-by-one that returns the right number of groups and the wrong number of rows. rn = 1 keeps the top row of each department and is the deduplication query. rn > 3 keeps everything past the top 3, the rows a 'who is outside the top 3' question wants. The comparison is the last decision in the query and the one most often typed without being read back.
- ▸Pick the group with more than N rows and confirm that row N+1 is dropped: 104 at position 4 leaves
- ▸Pick the group with fewer than N rows and confirm that every row survives: 201 and 202 both stay
- ▸Point at the first row of the second group and say that its number is 1
- ▸Read the comparison aloud, 'rn less than or equal to 3', and confirm the 3 matches the question's N
The plan, in 4 sentences
- ▸Name the pattern: 'This is top N per group.'
- ▸Name the group: 'The group is department_id, so the ranking restarts for every department.'
- ▸Name the ranking: 'Inside each department I order by salary descending, so the highest earner is numbered 1.'
- ▸Name the cutoff and where it lives: 'I number the rows in a CTE and keep rn of 3 or lower in an outer query, because WHERE can't see a column the same SELECT creates.'
A clarifying question or 2 before the plan
- Types ORDER BY salary DESC LIMIT 3 and reads back 3 rows from 1 department
- Hears 'where is department 20?' and patches with 1 query per department
- Never says the word partition
- Writes WHERE rn <= 3 under the SELECT that defines rn, and goes quiet when it fails
- Waits to be asked about groups with fewer than 3 rows
- Says 'top N per group' and names the group column before any code
- Says 'ordered by salary descending' as 1 phrase
- Puts ROW_NUMBER inside a CTE and says why the filter sits outside
- Traces 2 departments by hand, 1 with 4 rows and 1 with 2, before saying done
- Asks about the small group and the tie before being asked
Follow-ups and the sentence that answers each
| Follow-up | The answer that scores |
|---|---|
| Why not ORDER BY and LIMIT? | LIMIT counts rows for the whole result; the question needs a count that restarts per department. |
| What does PARTITION BY do here? | It restarts the numbering at 1 every time department_id changes, so each department is ranked on its own. |
| Why is the filter in a separate query? | WHERE runs before the SELECT list, so rn does not exist until the numbering query has finished. |
| What if a department has 2 people? | Both come back; rn <= 3 keeps every row in a group smaller than 3. |
| Could you use a subquery instead of a CTE? | Yes, the same SELECT in the FROM clause; the CTE gives the step a name. |
| What if I want the lowest 3 instead? | Change DESC to ASC in the OVER clause; nothing else moves. |
Narrating the query as it runs
Habits worth keeping
- Write ASC explicitly for cheapest and earliest; highest and latest get DESC, as does any 'most'
- Treat 'latest row per key' deduplication as the same query with rn = 1
- Check the number in the cutoff against the N the question asked for
- Name QUALIFY as a dialect shortcut, and write the CTE that runs on every engine
- Restate group, ranking, cutoff from the top when a step slips
- Type rn < 3 for a top 3, which keeps 2 rows per group
- Drop DESC on a 'highest' question: 104 replaces 101, and department 20 hides the bug
- Read a result back without counting how many distinct group values it holds
- Guess at syntax in silence after a slip; say the plan still holds and restructure
- Use QUALIFY without confirming the engine in the room supports it
> An online marketplace sends a Monday email listing the 3 best-selling products in each of its 40 categories. The merchandising lead hands you product_weekly_sales, 1 row per product with product_id, category_id, product_name and units_sold for last week, and asks for the query by lunch. Some new categories have only 1 or 2 products in them.
Recognize a per-group ranking through its disguises and say the plan before you type the window function.
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 4 hands-on challenges
Topics covered: "Find the Top 3" Means Ranking, Why ORDER BY ... LIMIT Fails, PARTITION BY Plus ROW_NUMBER, Filtering rn <= N, Talking Through the Ranking Plan
Lesson Sections
- "Find the Top 3" Means Ranking (concepts: Window Partitioning)
'Show me the 3 best-selling products in every category.' 'Pull the 2 most recent orders for each customer.' 'Who are the 3 top earners per department?' Each sentence pairs a number with a ranking word, then adds a grouping word, and the grouping word decides the query. A ranking word on its own asks for 1 sort of the table and N rows off the top. A ranking word next to 'per', 'each', 'every' or 'within' asks for a ranking inside every group and up to N rows from each, and the result holds as man
- Why ORDER BY ... LIMIT Fails (concepts: Row Limiting, Sorting Results)
Here are the 6 rows on the screen, the table the rest of the lesson uses. Department 10 holds employees 101 to 104, paid from 180,000 down to 120,000 in steps of 20,000, in id order. Department 20 holds 201 at 90,000 and 202 at 85,000. The question is the 3 highest salaries in each department, and the correct result holds 5 rows: 3 from department 10, and both rows from department 20. The query most hands type first Why ORDER BY and LIMIT answer a different question The patch that turns 1 query
- PARTITION BY Plus ROW_NUMBER (concepts: Row Numbering, Window Partitioning, Sorting Results)
Watching the counter restart The bug that wears a window function The direction is half the answer Same clause, different question A follow-up around 2 people earning the same salary is common once this query runs. ROW_NUMBER gives tied rows 2 different positions, and the order among them is not guaranteed: it can change from run to run, even on the same engine. The answer that scores is to say so and ask whether the report wants exactly 3 rows or everyone who ties into the top 3. The 3 ranking
- Filtering rn <= N (concepts: Execution Order, Common Table Expressions, Row Numbering)
The order the engine actually runs The shape that works Reading the cutoff
- Talking Through the Ranking Plan (concepts: Row Numbering, Window Partitioning, Common Table Expressions)
The SQL for this question is 14 lines, and most candidates can type it. What separates an answer that scores from one that gets a polite nod is the 60 seconds before the typing: a plan said aloud, in the order the engine will run it, with the 4 pieces of the question named. Interviewers on this question are grading whether the pattern was recognized and whether the plan was stated, and the code afterward confirms the plan rather than discovering it. The plan, in 4 sentences Those 4 sentences tak