Ranking rows and top N per group

Number rows within each group with ROW_NUMBER, RANK and DENSE_RANK, then keep the top few or remove duplicates.

Advanced11 min readLesson 30 of 38
In this lesson 7 sections
  1. Three ways to number rows
  2. Ranking within each group
  3. Top N per group
  4. Keep one row per key: deduplication
  5. Common mistakes
  6. Dialect notes
  7. Snowflake notes
Jump to practice

“Each rep’s biggest deal.” “The three most recent orders per customer.” “The latest record for each account.” These are top-N-per-group questions, and LIMIT can’t answer them, because LIMIT only cuts the whole result, not each group. Ranking window functions can.

Three ways to number rows

All three use OVER (ORDER BY ...). The ORDER BY inside the parentheses decides who’s first:

SELECT name,
       amount,
       ROW_NUMBER() OVER (ORDER BY amount DESC) AS row_num,
       RANK()       OVER (ORDER BY amount DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rnk
FROM opportunities
WHERE closed_at IS NULL;
name amount row_num rnk dense_rnk
Cedar Expansion 150000 1 1 1
Prairie Security Bundle 150000 2 1 1
Pine Network Refresh 120000 3 3 2
Prairie Migration 120000 4 3 2
Cedar Backup Upgrade 75000 5 5 3

They only differ on ties:

Function On a tie Next number after a tie Use it for
ROW_NUMBER() Different numbers, picked arbitrarily Continues: 1, 2, 3 Exactly N rows, or removing duplicates
RANK() Same number Skips: 1, 1, 3 “Top N, keeping ties”, like a sports ranking
DENSE_RANK() Same number No gap: 1, 1, 2 “The top N distinct amounts”

Watch ROW_NUMBER on ties: Cedar Expansion and Prairie Security Bundle tie at $150,000, and which one gets 1 is up to the database. It can change between runs. If that matters, add a tiebreaker: ORDER BY amount DESC, opportunity_id.

Ranking within each group

Add PARTITION BY, as in lesson 29, and the numbering restarts for every group:

SELECT owner_id,
       name,
       amount,
       RANK() OVER (PARTITION BY owner_id ORDER BY amount DESC) AS rnk
FROM opportunities
WHERE is_won = 1;

Each rep’s biggest won deal gets rank 1, then 2, and so on. Ben Carter’s two won deals at $30,000 both get rank 4, and his next deal is rank 6.

Top N per group

You can’t filter on a window function in WHERE (lesson 29), so rank in a CTE, then filter:

WITH ranked AS (
    SELECT owner_id,
           name,
           amount,
           RANK() OVER (PARTITION BY owner_id ORDER BY amount DESC) AS rnk
    FROM opportunities
    WHERE is_won = 1
)
SELECT owner_id, name, amount, rnk
FROM ranked
WHERE rnk <= 2;

That’s each rep’s two biggest won deals. Switch RANK for ROW_NUMBER if you want exactly two, even when deals tie.

Keep one row per key: deduplication

Source data often has several rows per thing when you only want one, like the latest. Number each group’s rows newest first, then keep number 1:

WITH ranked AS (
    SELECT account_id,
           name,
           created_at,
           ROW_NUMBER() OVER (PARTITION BY account_id
                              ORDER BY created_at DESC) AS rn
    FROM opportunities
)
SELECT account_id, name, created_at
FROM ranked
WHERE rn = 1;

One row per account, its most recent deal: 13 accounts have deals, so 13 rows. This is the standard pattern for cleaning up repeated rows from a CRM export, and it’s the most common window function in real pipelines. Use ROW_NUMBER here, not RANK: you want exactly one row per account, even if two deals were created on the same day.

Common mistakes

Mistake Fix
LIMIT for “top N per group” LIMIT cuts the whole result. Rank with PARTITION BY, then filter.
Filtering on the rank in WHERE Rank in a CTE, filter outside
ROW_NUMBER when ties should both count RANK or DENSE_RANK
RANK for deduplication Ties give two rows numbered 1. Use ROW_NUMBER, with a tiebreaker.
ORDER BY in the wrong direction DESC for biggest or newest first

Dialect notes

ROW_NUMBER, RANK and DENSE_RANK work the same in SQL Server, PostgreSQL, MySQL 8.0+ and SQLite. SQL Server’s SELECT TOP 1 WITH TIES ... ORDER BY is a whole-result shortcut, not a per-group one.

Snowflake notes

With QUALIFY (lesson 29), top N per group needs no CTE:

SELECT owner_id, name, amount
FROM opportunities
WHERE is_won = 1
QUALIFY RANK() OVER (PARTITION BY owner_id ORDER BY amount DESC) <= 2;

The dedup pattern becomes QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY created_at DESC) = 1, which you’ll see in nearly every dbt staging model. MAX_BY(name, created_at) is a shortcut when you only need one column from the latest row. None of these run in the sandbox.

More in the field guide: ROW_NUMBER / RANK / DENSE_RANK and Deduplication pattern. Exercises A1 and A3 use both patterns.

Try it

Show each rep’s top 4 won deals by amount. When deals tie, keep all of them, so a rep can have more than 4. Leave out deals with no owner. Return the owner_id, deal name and amount.

Write it in the sandbox below, then press Check answer.

SQL sandbox

Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.

Back to the top of the lesson