datarekha

Top-N per group

The pattern in every analytics interview — top 3 products per category, latest order per user, best score per team — and the wrap-and-filter idiom behind it.

6 min read Intermediate SQL Lesson 15 of 27

What you'll learn

  • Why WHERE ROW_NUMBER() <= N doesn't work directly
  • The wrap-and-filter idiom: window in the inner query, filter in the outer
  • Variations — latest-per-group, best-per-group, first-N-per-group
  • When Postgres's DISTINCT ON beats it

Before you start

If you have interviewed for an analytics or data role, you have met this question. Top 3 products per category. Latest order per user. Best score per player. Same shape, different nouns — and it is probably the single most useful SQL pattern you will learn this week.

Why you cannot filter directly

The instinctive attempt fails:

SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM   products
WHERE  rn <= 3;          -- ERROR: rn doesn't exist yet

A window function is evaluated after the WHERE of its own SELECT, so when WHERE runs, rn has not been computed. The fix is always the same — compute the window in an inner query, then filter in the outer one:

WITH ranked AS (
  SELECT category, name, price,
         ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC, name) AS rn
  FROM   products
)
SELECT category, name, price FROM ranked WHERE rn = 1;

On our product catalogue — two Stationery, one Grocery, two Electronics — that returns the dearest item in each category:

categorynameprice
ElectronicsLaptop900
GroceryCoffee9
StationeryNotebook6

PARTITION BY category restarts the numbering per category, so rn = 1 is each category’s top row. Notice the tiebreaker ORDER BY price DESC, name — without one, “the top row” could shift between runs whenever two rows tie. Always add a tiebreaker.

One pattern, many questions

The skeleton never changes; only the PARTITION BY and ORDER BY do. Swap them and you answer a whole family of questions:

  • Latest per groupPARTITION BY user_id ORDER BY order_date DESC, keep rn = 1. This is the foundation of every “current state” query: each user’s latest subscription, each device’s most recent ping.
  • First-N per group — order ascending, keep rn <= N. First five visits, first three purchases.
  • Bottom-N — flip the ORDER BY direction. Worst performers, slowest queries.

And the choice of ranking function matters on ties: ROW_NUMBER keeps exactly one row per group (arbitrary on a tie), while RANK lets genuinely-tied rows share the top spot — a leaderboard where two players really are joint first wants RANK, a dedup that must keep one row wants ROW_NUMBER.

Practice

Quick check

0/3
Q1Why can't you write WHERE ROW_NUMBER() OVER (...) <= 3?
Q2You want the latest order per user — exactly one row each. Which function?
Q3You want each user's most expensive order instead of their cheapest. What changes in the pattern?

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions

Related lessons

Explore further

Skip to content