open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 04 · 04

Ranking functions and top-N-per-group

ROW_NUMBER, RANK, and DENSE_RANK differ only in how they treat ties. ROW_NUMBER is the workhorse for top-N-per-group and dedup: rank inside a subquery, then filter rn <= N — the pattern that replaces a dozen correlated subqueries.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

“Top 3 salespeople per region.” A junior reaches for GROUP BY and LIMIT and discovers LIMIT applies to the whole result, not per group — there’s no LIMIT 3 PER region. They try a correlated subquery counting “how many in this region beat me,” which works and runs in O(n²), melting on the first real table. The one-line senior answer is ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) in a subquery, then WHERE rn <= 3. Ranking functions are how you do top-N-per-group, dedup, and pagination without quadratic pain.

Three ranking functions, one difference: ties

All three assign a position within an ordered partition. They agree on everything except what happens when two rows tie on the ORDER BY value:

  • ROW_NUMBER() — a strict 1, 2, 3, 4… No ties allowed; if two rows are equal it still gives them different numbers (the order between them is arbitrary unless you add a tiebreaker column). Use it when you need a unique sequence: top-N, dedup, pagination.
  • RANK() — ties get the same rank, then the next rank skips. Two rows tied for 1st are both 1, and the next row is 3 (2 is gone). This is “Olympic” ranking: two golds, no silver.
  • DENSE_RANK() — ties get the same rank, but the next rank does not skip. Two 1s, then 2. Use it for “how many distinct levels are there,” e.g. distinct price tiers.

All three produce the same result when no ties exist — the distinction only matters at the boundary condition, which is exactly when your requirement is ambiguous. When you see a ranking function in a query, ask yourself: “what should happen if two rows share the top score?” — the answer picks the function.

SELECT region, salesperson, total,
       ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) AS rn,
       RANK()       OVER (PARTITION BY region ORDER BY total DESC) AS rnk,
       DENSE_RANK() OVER (PARTITION BY region ORDER BY total DESC) AS drnk
FROM region_sales;

Top-N-per-group: the canonical pattern

You cannot filter on a window function in the same SELECT’s WHERE — window functions are computed after WHERE, in a later logical phase. So the pattern is always two levels: compute the rank in a subquery (or CTE), then filter the rank in the outer query.

SELECT region, salesperson, total
FROM (
  SELECT region, salesperson, total,
         ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) AS rn
  FROM region_sales
) ranked
WHERE rn <= 3
ORDER BY region, rn;

This is the answer to “top 3 per region,” “5 most recent orders per customer,” “the highest-paid employee in each department.” Choosing the ranking function matters here: ROW_NUMBER gives exactly N rows per group (ties broken arbitrarily); RANK may return more than N if there’s a tie at the boundary (three people tied for 3rd all have rank 3 and all pass rnk <= 3). For “exactly the top 3,” use ROW_NUMBER with a deterministic tiebreaker; for “everyone who reached the top-3 score,” use RANK.

Dedup: keep one row per key

The same machinery deduplicates. To collapse duplicate rows to the newest per key, rank by recency and keep rn = 1:

-- keep the most recent event per user, drop older duplicates
SELECT user_id, ts, payload
FROM (
  SELECT user_id, ts, payload,
         ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) AS rn
  FROM events
) e
WHERE rn = 1;

PARTITION BY user_id makes each user a group; ORDER BY ts DESC puts the newest first; rn = 1 is that newest row. This is cleaner and far faster than DISTINCT ON gymnastics or a self-join, and it generalizes: rn <= k keeps the k newest.

NTILE(n) is the bucketing cousin — it splits the ordered partition into n roughly equal buckets and labels each row 1…n, the basis for quartiles and percentile bands. PERCENT_RANK() and CUME_DIST() give the relative standing of a row as a fraction in [0, 1] — “this sale is in the 92nd percentile of its region.”

Common mistake

The number-one ranking bug: filtering the window function directly, WHERE ROW_NUMBER() OVER (...) <= 3. Postgres rejects it — window functions are not allowed in WHERE — because windows are evaluated after WHERE in the logical pipeline (FROMWHEREGROUP BY → window → SELECTORDER BY). The window doesn’t exist yet when WHERE runs. You must materialize the rank one query level down (subquery or CTE) and filter it in the outer query. Same reason you can’t put an alias from SELECT into WHERE: the phase that creates it hasn’t run.

Quiz

Two rows tie for the top score in a region. Which function gives them DIFFERENT numbers, and which gives the SAME rank but then skips the next number?

Quiz

Why can't you write WHERE ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) <= 3 directly?

Order the steps

Order the steps of the top-3-salespeople-per-region pattern:

  1. 1 Inner query: SELECT region, salesperson, total FROM region_sales
  2. 2 Add ROW_NUMBER() OVER (PARTITION BY region ORDER BY total DESC) AS rn
  3. 3 Wrap it as a subquery (or CTE) so rn becomes a real column
  4. 4 Outer query filters WHERE rn <= 3
  5. 5 ORDER BY region, rn to present the top-3 per region
Recall before you leave
  1. 01
    How do ROW_NUMBER, RANK, and DENSE_RANK differ when two rows tie?
  2. 02
    Write the shape of the top-N-per-group pattern and say why it needs two query levels.
  3. 03
    You want exactly one row per user_id, keeping the most recent. Which function and filter?
Recap

Ranking functions assign a position within an ordered partition and differ only in tie policy: ROW_NUMBER is strictly distinct (1, 2, 3, 4), RANK repeats a tie then skips (1, 1, 3), DENSE_RANK repeats without skipping (1, 1, 2). That distinction is the whole choice — pick the one whose tie behavior matches your requirement. The dominant use is ROW_NUMBER for top-N-per-group and dedup: because window functions are evaluated after WHERE, you compute the rank in a subquery or CTE and then filter rn <= N (top N) or rn = 1 (keep the newest) in the outer query — a pattern that replaces O(n²) correlated subqueries with one linear pass. RANK is the right call when every row tied at the cutoff should qualify; NTILE buckets the partition into equal slices, and PERCENT_RANK/CUME_DIST express a row’s relative standing as a fraction. Now when you reach for LIMIT 3 per group and it doesn’t work, stop — write ROW_NUMBER() OVER (PARTITION BY group ORDER BY metric DESC) in an inner query, then filter rn <= 3 outside. The next lesson moves from position to accumulation: running totals, moving averages, LAG/LEAD deltas, and the LAST_VALUE frame trap.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.