Top N Per Group: Advanced
What you will be able to do
The follow-up lands 2 minutes after ROW_NUMBER is on the board: "This report runs on MySQL 5.7, no window functions. Same result." It also arrives as Hive 1.x, an old SQLite build or a BI tool that rejects OVER, and it tests 1 thing: whether you can say what the window function computed for you. The candidate who reaches for MAX(salary) with GROUP BY department_id returns 1 row per department.
Every per-group top N computes 1 number per row: how many rows in the same group outrank it. The row stays when that number is below N. PARTITION BY was "same group", ORDER BY was "outranks", and rn <= N compared the count against N. Say that first. It frames everything after as a translation, and a translation is graded on the 1 detail that changes meaning: ties.
The count, written as a correlated subquery
Narrate it as a contract: "For each row, count the same-department employees who strictly outrank it; fewer than 3 means top 3. The OR chain is the ORDER BY: higher salary wins, then the earlier hire_date, then the lower employee_id." The rung order is business policy, exactly as the column order in the ORDER BY was.
SELECTemployee_id,salary,hire_dateFROM employees AS e1WHERE (SELECTCOUNT(*)FROM employees AS e2WHERE e2.salary > e1.salaryOR (e2.salary = e1.salaryAND e2.hire_date < e1.hire_date)) < 3
Three employees tie at 185000, so the hire_date rung decides which one the count pushes out
The last rung has to be a unique column
The fork the interviewer watches for: drop the tie rungs and count only e2.salary > e1.salary. The 3 employees at 185000 each get a count of 1 and all of them pass, so a top 3 returns 4 rows. That is RANK <= 3. The chain turns it into exactly N only when its last rung is unique within the group: end on hire_date and 2 people hired the same day still tie. Say "the unique column closes the tie" unprompted; it is the sentence of someone who has debugged a bonus pool with 4 names in it.
The same count as a self-join
Some planners handle a join and a GROUP BY better than a subquery tied to the outer row, so carry the second form too. The join pairs each e1 row with every e2 row that outranks it, GROUP BY folds the pairs back to 1 row per employee, and HAVING is the count comparison. Two details are scored: LEFT JOIN, because the top earner has nothing above them and an INNER JOIN drops them, and COUNT(e2.employee_id), because COUNT(*) counts the NULL-padded row and gives the top earner a count of 1.
SELECTe1.employee_id,e1.salaryFROM employees AS e1LEFT JOIN employees AS e2ON e2.salary > e1.salaryGROUP BY e1.employee_id, e1.salaryHAVING COUNT(e2.employee_id) < 2
Both 185000 salaries have 1 higher salary above them, so a top 2 by HAVING keeps 3 rows
SELECTe1.employee_id,e2.employee_id AS beaten_byFROM employees AS e1LEFT JOIN employees AS e2ON e2.salary > e1.salaryOR (e2.salary = e1.salaryAND e2.hire_date < e1.hire_date)
With the hire_date rung the tie is one-sided: 103 beats 102 and nothing beats 103, so exactly 1 survives
Add the hire_date rung to the join condition and the tie turns one-sided: 103, hired in 2018, outranks 102, hired in 2020, and nothing outranks 103. One carries a beaten_by value and the other NULL, which is what lets HAVING keep exactly 1. The depth signal is converting both ways: reading a window query as this comparison, and reading the comparison back as its ORDER BY.
- Reads as the contract: count who outranks me, keep the row below N
- Postgres and MySQL 5.7 run it once per outer row: rows times group size
- SQL Server and Oracle may unnest it into a join plus aggregate; the plan says whether they did
- The same count as LEFT JOIN plus HAVING COUNT(e2.employee_id) < N
- MySQL 5.7 has no hash join: a nested loop that wants an index on (department_id, salary)
- Hash-join engines join on department_id and apply the inequality as a residual filter
- Join output grows with group size and with ties, which the plan shows as join rows
| Change in employees | What the store needs | Action |
|---|---|---|
| Insert into a group holding fewer than N rows | Nothing | Insert the row |
| Insert into a full group, below the floor | The floor | Nothing |
| Insert into a full group, above the floor | The floor | Insert the row, evict the Nth |
| Delete of a row outside the store | Nothing | Nothing |
| Delete of, or pay cut for, a stored row | Who ranked 1 to N | Rebuild the group from source |
| Transfer between departments | The new group's floor | Insert into the new group; rebuild the old one if it held the row |
3 ways to keep the stored table current
Option 1 rebuilds everything on a cadence. A nightly job runs the window query over the 3.2 million employee rows, writes the 5,400 survivors (3 per department across 1,800 departments) and swaps the table in; Postgres REFRESH MATERIALIZED VIEW CONCURRENTLY does this with readers unblocked. It reads nearly 600 rows per row kept and runs up to 24 hours stale. Name both numbers, then say that a 40-second nightly rebuild is the right design for most reports, because the interviewer is waiting to hear it.
Option 3 stores a margin: keep the top 10 per department and serve WHERE rn <= 3. A deleted stored row promotes the 4th from rows already held, and the group needs a source rebuild only when its margin falls below 3. The cost is a table 3.3 times larger and a filter on every read; it pays when deletes are frequent and the source is expensive, such as a top N over a 2 billion row event table.
Warehouse materialized views will not maintain it
The closing follow-up is "how do you know the stored table is right?" Recompute the top 3 for 50 random departments from employees, EXCEPT it against the store in both directions, and alert on any row. Drift runs both ways: a missed delete or pay cut leaves a stale row in the store, and a missed insert leaves a row missing, as does a raise or inbound transfer you never saw. Report which side of the EXCEPT the rows came from, because that names the kind of CDC event you lost.
The infrastructure probe arrives as a number. "Production Postgres, 3.2 million employee rows, and the HR portal's org page shows every department's top 3 in under 50 milliseconds. What index?" "An index on salary" finds the company's top 3; "an index on department_id" finds a department and then sorts it. The query needs each group's rows already in rank order and a way to stop after N, and an index supplies the order only when its key starts with the group column and continues with the ORDER BY.
The index is (department_id, salary DESC, hire_date ASC, employee_id ASC). The leading column locates the group's entries as 1 contiguous range. The following columns are the ORDER BY, so within that range the entries are already in rank order and no sort runs. The tail is the tiebreaker, which makes the index order total: 2 employees with the same salary and hire date sit in a fixed position in the index, the same position the query promised. Say the 3 parts in that sequence, because an index that puts salary first serves a different query.
ROW_NUMBER cannot stop early
The part most candidates miss is that the window query gets no stop from the index. In Postgres rn <= 3 sits above the WindowAgg; version 15 adds a run condition that stops computing row numbers past 3, but the operator still reads every remaining row of the partition to reach the next department. The plan is an ordered scan of all 3.2 million entries with 3,194,600 thrown away. Oracle's pushed rank and Spark 3.5's group limit cut rows earlier, and none of them jumps to the next group inside the index. The index saved the sort, not the read. The form that stops is a per-group LIMIT: LATERAL in Postgres, CROSS APPLY in SQL Server.
Each department costs 1 index descent plus 3 entries: 5,400 entries read in total in place of 3.2 million
That is 1 descent and 3 entries per department, a few milliseconds for the whole page. For a single department the stop needs no LATERAL: WHERE department_id = ? ORDER BY salary DESC, hire_date, employee_id LIMIT 3 reads 3 entries on its own, and LATERAL is that query run once per group. Say "the index gives order, the per-group LIMIT gives the stop," and expect the next question: can the index skip the table?
It can when every returned column is in the index: INCLUDE (employee_name) in Postgres 11 or later keeps the scan off the heap, where a department's 3 rows sit on 3 different pages. The trade is a wider entry on every insert, which shows up in HR's 40,000-row upload on the first of the month.
When the index does not pay
The partial stage removes 99.7% of the rows before any of them cross the network
Spark 3.5 added this as the WindowGroupLimit rule: a filter like rn <= 3 directly over ROW_NUMBER gets a Partial node before the Exchange and a Final one after, and the same holds for RANK and DENSE_RANK. On earlier versions, or when the filter hides behind a UDF or a view, the plan shows a bare Exchange of all 8 billion rows, and SQL can't fix it: a window partitioned by spark_partition_id() still shuffles every row, because a window needs its partition keys co-located. The hand-written local stage lives below SQL, as a per-partition heap of 3 per store in mapPartitions, or an aggregate whose buffer holds the top 3. Ask which version runs, and look for the Partial node below the Exchange.
Rank values that break the local cut
| Question | Local top N sound? | Why |
|---|---|---|
| Top 3 sales per store by revenue | Yes | Each sale's revenue is complete where it sits |
| Top 3 products per store by SUM(revenue) | Only after the aggregate | Partial sums across executors add up |
| Top 3 by RANK, ties kept | Yes | A global rank of 3 or better means at most 2 strictly ahead anywhere |
| Top 3 distinct levels by DENSE_RANK | Yes | A global top level is a local top level on any executor |
| Top 10 percent per store | No | The cutoff depends on the store's full row count |
Two more sentences earn credit. Skew: the single-pass window sends all 400 million rows of the largest store to 1 executor, the task that runs 40 minutes while 199 finish; after the local cut that store sends at most 600 rows, so the local stage is also the skew fix. Determinism: both stages need a total tiebreaker. If 2 sales tie at 9,400 and the ORDER BY stops at revenue, which one an executor keeps depends on read order, and 2 runs over identical data return different rows.
The last turn swaps salaries for frequencies. "Top 10 search queries per country per hour. 3 billion queries a day, 200 million distinct strings. Does it have to be exact?" Exact is 2 steps you own: COUNT(*) per query within each country and hour, then ROW_NUMBER over the counts. The cost is the first step, 1 counter per distinct (country, hour, query), tens of millions per executor in a busy hour, all of them shuffled. Ask whether the consumer needs the 10th count right.
Warehouses ship the approximate form: APPROX_TOP_K(query, 10, 1000) in Snowflake, approx_most_frequent(10, query, 1000) in Trino. The third argument is the number of counters the sketch keeps, and the error bound comes straight from it. BigQuery's APPROX_TOP_COUNT(query, 10) takes no capacity and does not document one, so no bound can be stated for it.
Space-Saving with 1000 counters
| Guarantee | With m = 1000 over 10 million events in 1 bucket |
|---|---|
| Any item with true count above n / m is tracked | Every query seen more than 10,000 times is in the sketch |
| No count is under its true value | A reported 48,000 means at least 48,000 |
| Overcount is at most the min counter at insertion | Reported 48,000 with error 10,000 means 38,000 to 48,000 |
| A merge charges an item missing from 1 sketch that sketch's min, then keeps the top m | 200 executor sketches merge into 1, overcount still at most n / m |
The items a sketch gets wrong
- Trending and most-viewed panels refreshed every few minutes
- Autocomplete and recommendation seeds, where the 11th item is a near miss
- Spike alerts, where the heavy hitter is the signal
- Any output that triggers a payment or a penalty, prizes included
- Compliance and audit reports, where a count must reproduce
- Buckets with a few thousand distinct items, where exact is cheap
- Name the computation first: the count of rows in the group that strictly outrank this one
- End every ranking with a unique column, whichever of the 3 forms you write
- Rebuild a stored group from source on any delete or downgrade; apply inserts against the floor
- Lead the index with the group column, follow with the ORDER BY, and stop with a per-group LIMIT
- Aggregate before a local top N cut whenever you rank by a SUM or COUNT
- Use INNER JOIN in the self-join form; the group's first row has nothing above it
- Assume the warehouse maintains a ranking view incrementally without reading its refresh rules
- Expect rn <= N to stop an index scan; the window reads every row of the partition
- Report an approximate top K where the 10th position pays out or gets audited
- Drop the error field from a sketch result; it tells the consumer which counts to trust
> A payroll analytics vendor serves 400 employer clients. Each client's HR portal shows the top 3 earners per department from that client's own database, several of which are still MySQL 5.7 and one of which is a 3.2 million row Postgres with a 50 ms budget. A nightly Spark job on the vendor's side ranks products from the benefits marketplace per employer across 8 billion click rows, and the marketplace search team wants the top 10 queries per country per hour on a trending panel.
Count who outranks each row, keep the stored table honest, stop the scan early, and know when a sketch will do.
- Category
- SQL
- Difficulty
- advanced
- Duration
- 25 minutes
- Challenges
- 4 hands-on challenges
Topics covered: "Do It Without Window Functions", Keeping a Stored Top N Current, Indexing for a Per-Group Top N, Local Top N Before a Global Merge, Approximate Top-K by Frequency
Lesson Sections
- "Do It Without Window Functions" (concepts: Correlated Subquery, Self-Join, Outer Joins, Group Filtering)
The count, written as a correlated subquery The animation runs that predicate over department 10 alone, so the chain is the whole query. Employee 101 earns 210000 and nobody outranks them. The next 3 employees, 102 through 104, tie at 185000, so the hire_date rung decides: 103 (hired 2018) is outranked by 101 only, 104 (2019) by 101 and 103, and 102 (2020) by all 3. A count of 3 removes 102, and watching those comparisons land is the narration for the room. The last rung has to be a unique colum
- Keeping a Stored Top N Current
The next probe assumes the query is settled. "The dashboard reads top 3 per department from a table you maintain, top_earners. HR deletes a terminated employee. What happens to your table?" The answer that costs the round is "delete the matching row." Department 10 then shows 2 names, because the 4th-highest earner should move up and a top 3 table discarded everything below the cutoff when it was written. State the asymmetry as a rule: a stored top N absorbs any change that adds a competitor, an
- Indexing for a Per-Group Top N (concepts: Lateral Joins, Row Limiting, Sorting Results, Storage Optimization)
ROW_NUMBER cannot stop early When the index does not pay That last item is where the round goes at a Snowflake or BigQuery shop. On a warehouse the work moves to layout: cluster by department_id so pruning reads a handful of micro-partitions, and accept a window over every row of those partitions. The sentence to say: "On a warehouse I would promise clustering by department and a window over the pruned scan; if 50 milliseconds is the requirement, the answer is the stored table served from a row
- Local Top N Before a Global Merge (concepts: Row Numbering, Window Partitioning, Ranking)
The distributed probe trades the HR table for volume. "Same pattern: the 3 biggest sales per store by revenue, but sales is 8 billion rows in Spark across 200 executors. Walk me through the shuffle." The expected wrong answer describes the window faithfully: hash every row by store_id, shuffle all 8 billion, sort, number, filter. It is correct, it is what older engines do unprompted, and the interviewer is waiting for the sentence that cuts the shuffle by a few hundred times. Each executor can c
- Approximate Top-K by Frequency (concepts: Approximate Aggregates, Grouping)
Space-Saving with 1000 counters The algorithm underneath, wherever one is documented, is Space-Saving, and narrating it earns the credit. The sketch keeps m counters, and each holds an item and its count plus an error term. A tracked item's count goes up by 1. An untracked item takes a free counter at count 1. When none is free, the smallest counter is evicted, the newcomer inherits it at count min + 1, and min is recorded as its error. Nothing is undercounted, because an evicted item's count ca