SQL Top N Per Group: 3 Methods That Scale in Production
SQL top N per group: ROW_NUMBER vs correlated subquery vs LATERAL with runnable examples, ties, and indexing.
20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.
- ✓SQL SELECT, WHERE, ORDER BY, LIMIT
- ✓GROUP BY vs window function basics
- ✓Reading EXPLAIN output
- Top-N per group = rank rows inside each partition, then filter — not GROUP BY aggregation
- ROW_NUMBER (modern default): one pass, exact N rows, deterministic with full ORDER BY incl. id
- Correlated COUNT subquery (portable): works on MySQL 5.x, RANK-like ties, O(groups × rows)
- LATERAL / CROSS APPLY (Postgres / SQL Server): per-group index lookups, best joined to wide rows
- Ties are a product decision: ROW_NUMBER (exact N) vs RANK (N plus ties) vs DENSE_RANK (tiers)
- Index (group, score DESC, id) turns 47s sorts into ~180ms — EXPLAIN every variant
Imagine a school with 10 classes and you need the 3 tallest kids per class. You wouldn't find the tallest height per class then guess names — that's the GROUP BY trap (right height, wrong kid). Instead, line each class up tallest-first and count off 1-2-3 (that's ROW_NUMBER). Ties? Decide upfront: exactly 3 kids (tallest ID wins ties) or everyone tied for 3rd (5 kids). Same line-up, different whistle.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
'Top 3 products per category.' 'Latest 2 orders per customer.' 'Highest-paid 5 engineers per department.' You've written GROUP BY a hundred times, but top-N per group isn't aggregation — it's ranking inside partitions. And if your first instinct is GROUP BY + MAX + self-join, you'll fetch the right score with the wrong row's name on it. Don't ship that.
Three methods compete: ROW_NUMBER (modern, one pass), correlated subquery (portable to ancient MySQL), and LATERAL (Postgres797 power tool). Each wins somewhere and embarrasses you elsewhere. Pick wrong and your 2M-row table sorts on disk for 40 seconds.
This guide runs all three on the same dataset with copy-paste SQL you can execute today. You'll see tie semantics done right, the composite index that makes windows fly, and the version matrix that stops deploys from exploding on legacy replicas.
The Problem and the Shared Fixture
The running example: products(id, category, name, price), goal = top 2 per category by price. Note books has a tie at 49.99 (ids 2,3) — every method's tie behavior will show on this fixture. Run the CREATE + INSERT once; all queries below execute against it unchanged.
Why fixtures matter: tie behavior is invisible on distinct scores. This 8-row table exposes exact-N vs with-ties on the first run — the decision stakeholders must make. Keep it; interviewers love asking 'what happens on your data when two tie?' and here you can show them.
Expected exact-N answer (price DESC, id tiebreak): books → ids 2,3; games → ids 5,6. Expected with-ties at N=2 (RANK): identical here — the divergence needs a 3-way tie, covered in section 5.
Method 1 — ROW_NUMBER (The Modern Default)
ROW_NUMBER partitions rows by category, orders each partition by price DESC, and numbers 1..N. The outer query keeps rn <= 2. One table pass plus a per-partition sort — O(P log P) per partition with P = partition size.
The id ASC tiebreak is not cosmetic: without a unique ordering key, tied rows (ids 2,3) can swap between runs and replicas. Deterministic order is a correctness property for pagination, tests, and report diffs.
Variants in one line: swap ROW_NUMBER → RANK for with-ties (both 49.99 books share rn=1, next is 3), → DENSE_RANK for tier numbers without gaps. Same query shape, three business meanings — comment your choice.
Method 2 — Correlated Subquery (The Portable Fallback)
Logic: keep rows with fewer than N strictly-better rows in the same group. The (price > OR price = AND id <) tuple comparison encodes exact-N determinism identical to ROW_NUMBER's tiebreak; dropping the id clause yields with-ties semantics.
Cost: the subquery re-executes per outer row — O(R × P) with R rows and P partition size. On 10^4 rows it's fine; past 10^5 it degrades visibly; at 10^6+ it's a pager incident. An index on (category, price DESC, id) converts each execution to an index count, buying roughly 10x but not changing the complexity class.
Use when the database lacks windows (MySQL 5.7, ancient SQLite) or when policy forbids CTEs. Otherwise prefer Method 1 and say why.
Method 3 — LATERAL / CROSS APPLY (The Indexed Fast Path)
LATERAL runs the subquery once per group with LIMIT 2 pushed inside — each execution is an index range scan returning 2 rows, not a global sort. With G groups the cost is G × O(log P) instead of O(R log P). For 10^4 groups × small N, this beats ROW_NUMBER by 5-50x.
Requirements: composite index (category, price DESC, id) so each lateral leg is a pure index walk. Without it, LATERAL degrades to G sequential scans — worse than the window. EXPLAIN should show 'Index Scan using ... Limit' per leg.
MySQL has no LATERAL (until 8.0.14's limited form); SQL Server spells it CROSS APPLY with TOP. Know both spellings — cross-dialect fluency is a senior signal in polyglot shops.
Ties and the MAX-Join Anti-Pattern
The MAX-join answers 'what is the top score' then glues arbitrary rows onto it. With ties it multiplies rows (both 49.99 books match); with strict SQL modes it errors on unaggregated columns; without them it silently picks indeterminate names — the 6-week incident above.
Ties done right: exact-N → ROW_NUMBER + id tiebreak (documented). With-ties → RANK (all tied-for-Nth included, counts vary). Tiers → DENSE_RANK (distinct scores numbered). Put the business choice in a comment; future editors must see intent.
Test ties explicitly: add a third 49.99 book and confirm ROW_NUMBER still returns 2 rows while RANK returns 3. That one INSERT is the tie contract made executable.
Performance — The Index That Decides 47s vs 180ms
The composite index (category, price DESC, id) stores each partition pre-sorted: ROW_NUMBER reads partitions in order (sort becomes trivial), LATERAL legs become 2-row index walks, correlated subqueries become index counts. One index serves all methods.
Measurement protocol: EXPLAIN ANALYZE at production row counts (fixtures lie — 200 rows never spill). Watch for Seq Scan, Sort Method: external merge (disk), or Using filesort. Any of those at 10^6+ rows means minutes, and the fix is the index, never query cleverness.
Maintenance note: the index costs write amplification on price/category updates — acceptable for read-heavy catalogs, worth stating in review for write-heavy tables (then consider partial indexes per hot category).
Interview Playbook — Presenting Top-N Live
Minute 0–1: ask the tie question ('exactly 2, or everyone tied for 2nd?'). Interviewers plant ties in the data; candidates who ask score requirements points before writing SQL. Minutes 1–5: write the ROW_NUMBER CTE with id tiebreak and the outer filter.
Minutes 5–8: name the fallbacks without writing them fully ('correlated COUNT on 5.7, LATERAL for many-groups-indexed'). Minutes 8–10: index + EXPLAIN close. That arc — semantics, default, fallbacks, performance — is the senior-shaped answer.
Red flags to voice: GROUP BY + MAX for rows, window without tiebreak, any ranking query without an index sentence. Voicing them unprompted is what separates hired from 'good SQL'.
The MAX-Join That Named the Wrong Products for 6 Weeks
- GROUP BY + MAX answers the max score, never which row holds it — use ranking for rows.
- Time ranking queries at production row counts; 200-row fixtures hide sort spills completely.
- Tie semantics are a product decision: lock exact-N vs with-ties before coding.
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC, id). Diff the two result sets to isolate tie-affected groups. Then confirm with stakeholders whether exact-N or with-ties is correct and encode it (ROW_NUMBER vs RANK) with a comment.VERSION() on the failing host versus CI. Rewrite with the portable fallback (correlated COUNT subquery) or gate the migration on version. Add a CI job running the oldest supported version so dialect regressions fail before deploy, not after.| File | Command / Code | Purpose |
|---|---|---|
| fixture.sql | CREATE TABLE products ( | The Problem and the Shared Fixture |
| row_number.sql | WITH ranked AS ( | Method 1 |
| correlated.sql | SELECT p1.id, p1.category, p1.name, p1.price | Method 2 |
| lateral.sql | SELECT g.category, t.id, t.name, t.price | Method 3 |
| antipattern.sql | SELECT p.category, p.name, m.max_price | Ties and the MAX-Join Anti-Pattern |
| indexing.sql | CREATE INDEX idx_products_cat_price | Performance |
Key takeaways
Common mistakes to avoid
4 patternsUsing ROW_NUMBER when the business wants ties included (or vice versa)
Writing GROUP BY + MAX joins and calling it top-N per group
ROW_NUMBER() OVER (PARTITION BY group ORDER BY score DESC, id) in one pass. Reserve correlated subqueries for MySQL 5.x / legacy hosts only, and LATERAL for top-N-joined-to-wide-rows in Postgres. Default to the window function.Shipping top-N queries with no composite index on (group, sort key)
Deploying LATERAL / window syntax to a database version that lacks it
VERSION(), and test each dialect in CI.Interview Questions on This Topic
What changes when two rows tie for Nth place?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.
That's SQL. Mark it forged?
3 min read · try the examples if you haven't