Home Database SQL Top N Per Group: 3 Methods That Scale in Production
Intermediate 3 min · September 07, 2026
SQL Top N per Group Greatest N per Group

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.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Production
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
Before you start⏱ 25 min
  • SQL SELECT, WHERE, ORDER BY, LIMIT
  • GROUP BY vs window function basics
  • Reading EXPLAIN output
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
✦ Definition~90s read
What is SQL Top N per Group Greatest N per Group?

Top-N per group (greatest-N per group) is the ranking query behind 'latest orders per customer', 'top products per category', and 'highest-paid staff per department': within each partition, order rows and keep the first N. It appears in every SQL interview loop because it fuses window functions (ROW_NUMBER/RANK), subquery correlation, join planning, tie semantics, and indexing into one question.

Imagine a school with 10 classes and you need the 3 tallest kids per class.

Variants span clickstream (latest events per user), billing (largest invoices per account), and leaderboards (top scores per region). The durable skills are tie contracts (exact-N vs with-ties decided upfront), total ordering (unique tiebreak columns), and plan-first verification (EXPLAIN at production scale).

Get those three right and every ranking query you ship stays fast and truthful.

Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

'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.

fixture.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
-- Same fixture for every method below. Run it once:
CREATE TABLE products (
  id INT PRIMARY KEY,
  category VARCHAR(40) NOT NULL,
  name VARCHAR(80) NOT NULL,
  price NUMERIC(10,2) NOT NULL
};
INSERT INTO products (id, category, name, price) VALUES
  (1,'books','SQL Guide',29.99),(2,'books','DB Internals',49.99),
  (3,'books','Query Tuning',49.99),(4,'books','Old Almanac',9.99),
  (5,'games','Rogue Quest',59.99),(6,'games','Pixel Racer',39.99),
  (7,'games','Chess Master',39.99),(8,'games','Retro Pack',19.99);
-- Goal used throughout: top 2 products per category by price.
🔥The Core Distinction
GROUP BY answers 'what is the max'. Ranking answers 'which rows hold it'. Different questions, different tools.
📊 Production Insight
Build tie-containing fixtures deliberately. Staging datasets with all-distinct scores hide tie bugs for months — the incident above ran 6 weeks on exactly such data.
🎯 Key Takeaway
One 8-row fixture with a built-in tie exposes every method's semantics on first run.

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.

row_number.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Method 1: ROW_NUMBER (MySQL 8+, Postgres, SQL Server, Oracle, SQLite 3.25+)
WITH ranked AS (
  SELECT id, category, name, price,
         ROW_NUMBER() OVER (
           PARTITION BY category
           ORDER BY price DESC, id ASC   -- id tiebreak = deterministic
         ) AS rn
  FROM products
)
SELECT id, category, name, price
FROM ranked
WHERE rn <= 2
ORDER BY category, rn;
-- books -> (2, DB Internals, 49.99), (3, Query Tuning, 49.99)
-- games -> (5, Rogue Quest, 59.99), (6, Pixel Racer, 39.99)
⚠ Total Order or Bust
Window ORDER BY must be total (score + unique id) or ties resolve nondeterministically per run.
📊 Production Insight
Default to this method in code review. Ask authors who chose anything else to justify it — 9 in 10 can't, and the rewrite is always shorter.
🎯 Key Takeaway
One pass, exact N, deterministic with id tiebreak; RANK/DENSE_RANK swap gives ties/tiers.

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.

correlated.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- Method 2: correlated COUNT subquery (works on MySQL 5.7, any SQL)
SELECT p1.id, p1.category, p1.name, p1.price
FROM products p1
WHERE (
  SELECT COUNT(*)
  FROM products p2
  WHERE p2.category = p1.category
    AND (p2.price > p1.price
         OR (p2.price = p1.price AND p2.id < p1.id))
) < 2
ORDER BY p1.category, p1.price DESC, p1.id;
-- Same result as ROW_NUMBER above. COUNT(*) counts strictly-better rows;
-- fewer than 2 better rows  <=>  row is in the top 2 (exact-N semantics).
-- Drop the id tiebreak line for RANK-like with-ties behavior.
⚠ Fallback, Not Default
Portable everywhere, quadratic anywhere big. Version-gate it to legacy hosts only.
🎯 Key Takeaway
Same answer as ROW_NUMBER via better-row counting; quadratic cost limits it to legacy/smaller tables.

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.

lateral.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- Method 3a: LATERAL (Postgres 9.3+)
SELECT g.category, t.id, t.name, t.price
FROM (SELECT DISTINCT category FROM products) g
CROSS JOIN LATERAL (
  SELECT id, name, price
  FROM products p
  WHERE p.category = g.category
  ORDER BY p.price DESC, p.id ASC
  LIMIT 2
) t
ORDER BY g.category, t.price DESC;

-- Method 3b: CROSS APPLY (SQL Server) — same shape, different keyword:
-- SELECT g.category, t.id, t.name, t.price
-- FROM (SELECT DISTINCT category FROM products) g
-- CROSS APPLY (
--   SELECT TOP (2) id, name, price FROM products p
--   WHERE p.category = g.category ORDER BY p.price DESC, p.id ASC
-- ) t;
💡The Many-Groups Fast Path
Per-group LIMIT with index lookups beats a global window when groups are many and N is small.
🎯 Key Takeaway
Per-group LIMIT via index walks; fastest for many groups + small N, needs the composite index.

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.

antipattern.sqlSQL
1
2
3
4
5
6
7
8
9
10
-- ANTI-PATTERN (do not ship): GROUP BY + MAX join
-- Returns right prices, indeterminate names:
SELECT p.category, p.name, m.max_price
FROM products p
JOIN (SELECT category, MAX(price) AS max_price
      FROM products GROUP BY category) m
  ON m.category = p.category AND m.max_price >= p.price
-- With the tie (ids 2,3 both 49.99) this returns 3+ rows for books
-- and MySQL's ONLY_FULL_GROUP_BY rejects the unaggregated p.name.
-- Fix: use ROW_NUMBER (Method 1) whenever you need ROWS, not scores.
⚠ The Wrong-Row Guarantee
MAX-joins return A score with SOME row. If reviewers can't name which row, reject the query.
🎯 Key Takeaway
MAX-joins can't return rows safely; encode tie choice (ROW_NUMBER/RANK/DENSE_RANK) explicitly.

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).

indexing.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Index that serves all three methods:
CREATE INDEX idx_products_cat_price
  ON products (category, price DESC, id ASC);

-- Verify (Postgres): expect Index Scan + bounded Sort per partition
EXPLAIN ANALYZE
WITH ranked AS (
  SELECT id, category, name, price,
         ROW_NUMBER() OVER (PARTITION BY category
                            ORDER BY price DESC, id ASC) AS rn
  FROM products
)
SELECT * FROM ranked WHERE rn <= 2;
-- MySQL: EXPLAIN FORMAT=TREE SELECT ... ; look for no 'filesort' on 10^6 rows.
-- Incident numbers: 2.1M rows, no index -> 47s disk sort; with index -> 180ms.
🔥Plan-First Discipline
No plan print, no deploy. The index is the query — the SQL is just its spelling.
🎯 Key Takeaway
Composite (group, score DESC, id) + EXPLAIN at prod scale; fixtures never prove performance.

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'.

whiteboard.sqlSQL
1
2
3
4
5
6
7
-- Whiteboard order: (1) state tie choice, (2) write Method 1, (3) name fallbacks
-- Q: 'Top 2 orders per customer by total?' A: 'Exact 2 or with ties?'
-- Exact: ROW_NUMBER() OVER (PARTITION BY customer_id
--                          ORDER BY total DESC, id) ... WHERE rn <= 2
-- Ties:  RANK() ... (same shape, counts may exceed 2 per customer)
-- Legacy: correlated COUNT (MySQL 5.7) / LATERAL (many groups, indexed)
-- Close: 'index (customer_id, total DESC, id); EXPLAIN at prod rows.'
💡The 60-Second Opener
Say 'ROW_NUMBER with id tiebreak, RANK if you want ties' in the first 60 seconds — then write.
🎯 Key Takeaway
Tie question first, ROW_NUMBER second, fallbacks third, index close — 10 minutes total.
● Production incidentPOST-MORTEMseverity: high

The MAX-Join That Named the Wrong Products for 6 Weeks

Symptom
Category report correct on totals, wrong on product names (mismatched rows) for 6 weeks; after the rewrite, the fixed query timed out in production at 47 seconds while staging (200 rows) ran in 9ms.
Assumption
The team assumed GROUP BY + MAX + join returns 'the top row' and that staging timing (200 rows) predicts production (2.1M rows). Nobody specified tie behavior, and nobody ran EXPLAIN.
Root cause
Two stacked failures: the MAX-join fetched non-aggregated columns from indeterminate rows (wrong names, right prices — invisible without line-level audits), and the replacement window query had no composite index, forcing a 2.1M-row on-disk sort. Finance caught the names; customers never did — 6 weeks of wrong reports.
Fix
Rewrote as ROW_NUMBER with explicit tiebreak (price DESC, id), added composite index (category, price DESC, id), and documented with-ties vs exact-N as a product decision. Runtime 47s → 180ms; Finance signed off on exact-N semantics. Logged rule: ranking queries ship with a plan print and a tie contract.
Key lesson
  • 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.
Production debug guideThree production failure signatures and the exact fix for each.3 entries
Symptom · 01
Top-N returns right count but wrong rows — names don't match the top scores
Fix
Add the tiebreak column (id) to the window ORDER BY and re-run: 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.
Symptom · 02
Query correct on staging, 40s+ timeout in production
Fix
Run EXPLAIN ANALYZE on the query. If you see Seq Scan + Sort with disk spill (or 'Using filesort' in MySQL), create the composite index (group_col, score DESC, id) and re-run. Expect 100-1000x improvement at 10^6+ rows. Never accept a window query without checking the plan.
Symptom · 03
Syntax error on LATERAL / ROW_NUMBER on a legacy replica
Fix
Check SELECT 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.
Top-N per Group: Methods at a Glance
MethodPortabilityTiesVerdict
ROW_NUMBER windowMySQL 8+, PG, SQL Server, Oracle, SQLite 3.25+Exact N (add id tiebreak)Default choice: one pass, clear semantics, fastest with index.
Correlated subquery (COUNT)Everywhere incl. MySQL 5.xWith-ties (RANK-like)Portable fallback: O(groups × rows), dies past ~10^5 rows.
LATERAL / CROSS APPLYPostgres 9.3+, SQL ServerExact N per subqueryBest for top-N joined to wide rows; per-group index lookups.
GROUP BY + MAX joinEverywhereScore only (wrong-row risk)Not top-N rows: answers the max, not which row holds it. Avoid.
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
fixture.sqlCREATE TABLE products (The Problem and the Shared Fixture
row_number.sqlWITH ranked AS (Method 1
correlated.sqlSELECT p1.id, p1.category, p1.name, p1.priceMethod 2
lateral.sqlSELECT g.category, t.id, t.name, t.priceMethod 3
antipattern.sqlSELECT p.category, p.name, m.max_priceTies and the MAX-Join Anti-Pattern
indexing.sqlCREATE INDEX idx_products_cat_pricePerformance

Key takeaways

1
Top-N per group is ranking inside partitions, not GROUP BY aggregation.
2
ROW_NUMBER is the default
one pass, exact N, needs a full ORDER BY tiebreak.
3
Correlated COUNT subqueries are the portable fallback with RANK-like ties.
4
LATERAL / CROSS APPLY wins top-N-joined-to-wide-rows in Postgres / SQL Server.
5
A (group, score DESC, id) index decides 40s vs 40ms
EXPLAIN every variant.

Common mistakes to avoid

4 patterns
×

Using ROW_NUMBER when the business wants ties included (or vice versa)

Symptom
'Top 3 per category' returns 3 rows but Finance expected 5 (three-way tie for 3rd). Report totals drift from dashboard numbers and nobody trusts the query. Tie semantics are a requirements question, not a syntax one.
Fix
Decide ties explicitly: ROW_NUMBER for exactly-N (document which row wins via the full ORDER BY incl. id), RANK for with-ties (accept variable counts), DENSE_RANK for distinct-score tiers. Name the choice in a comment so reviewers see intent, not accident.
×

Writing GROUP BY + MAX joins and calling it top-N per group

Symptom
Query returns the top score per group but the WRONG row's columns (name/price from another row) — the classic MySQL ONLY_FULL_GROUP_BY violation or silent mismatch. MAX-per-group answers 'what is the max', never 'which row holds it'.
Fix
Express top-N as 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)

Symptom
ROW_NUMBER over 2M rows does a full Seq Scan + 2M-row Sort spilling to disk — 40s+ per run. The query is 'correct' and undeployable. Window functions sort partitions; indexes decide whether that sort is trivial or catastrophic.
Fix
Create (group_col, score DESC, id) composite covering indexes: CREATE INDEX ON products (category, price DESC, id). Then EXPLAIN ANALYZE and confirm Index Scan + bounded Sort per partition instead of Seq Scan + global Sort. Re-test at production row counts, not 200-row fixtures.
×

Deploying LATERAL / window syntax to a database version that lacks it

Symptom
ERROR: syntax error at or near 'LATERAL' (or 'no such function: row_number') on the legacy replica — deploys pass in CI (Postgres 15) and explode on the client's Postgres 9.3 / MySQL 5.7. Version-gate or portable-fallback every ranking query.
Fix
In Postgres use LATERAL (or ROW_NUMBER); in SQL Server CROSS APPLY; in MySQL 8+ ROW_NUMBER; in MySQL 5.7 correlated subquery or user-variable ranking. Gate version-specific SQL behind migrations guarded by SELECT VERSION(), and test each dialect in CI.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What changes when two rows tie for Nth place?
Q02SENIOR
How do you make top-N per group fast at 2M rows?
Q03SENIOR
Your production host runs MySQL 5.7 with no window functions. Now what?
Q01 of 03SENIOR

What changes when two rows tie for Nth place?

ANSWER
Reference: ROW_NUMBER gives exactly N (document tiebreak), RANK gives N-plus-ties, DENSE_RANK gives score tiers. Expected senior move: ask the interviewer which the business wants before coding — tie semantics are requirements, and asking scores as highly as the query.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
02
Can N be a parameter instead of hardcoded?
03
Why can't I put ROW_NUMBER directly in WHERE?
04
Do window functions scale to very large tables?
05
Is there any top-N option on MySQL 5.7 without window functions?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
September 22, 2026
last updated
1,799
articles · all by Naren
🔥

That's SQL. Mark it forged?

3 min read · try the examples if you haven't

Previous
PostgreSQL psql Introspection and Top N per Group
3 / 3 · SQL