Analytical SQL · Window Functions: Ranking and Top-N

Ranking and Top-N per Group

ROW_NUMBER, RANK and DENSE_RANK with PARTITION BY: the top N per category, how ties change the answer, and keeping one row per duplicate.

  • 9 min read
  • 12 practice questions
  • 3 graded problems
On this page
  1. Description
  2. Windows keep the rows
  3. ROW_NUMBER, RANK and DENSE_RANK
  4. Filtering on a window: the top N per group
  5. Second highest, latest per group, and deduplication
  6. NULLs sort first when descending
  7. Where it breaks
  8. Tradeoffs
  9. Check yourself
  10. Key takeaways
  11. Quick revise
  12. Practice

Description

"Show the top 3 best-selling products in each category." It sounds like ORDER BY ... LIMIT 3, but that gives the top 3 overall, not per category. GROUP BY category gives one row per category and loses the products. What you need is a way to rank rows within each group while keeping every row - and that's what a window function does.

The same shape turns up again and again: "the second highest salary", "each customer's most recent order", "keep only the newest signup per email", "who's in the top 10% of sellers". They're among the most common SQL interview questions, and the answers usually hinge on one small detail - what should happen when two rows tie. In this lesson:

  1. What a window function is, and how it differs from GROUP BY.
  2. ROW_NUMBER, RANK and DENSE_RANK - three ways to handle ties.
  3. Top N per group, and why you can't filter on a rank in WHERE.
  4. The second highest value, the latest row per group, and removing duplicates.
  5. Where NULLs land when you sort biggest-first.

Examples use eight employees (department, salary, hired_on) with two deliberate ties, five signups with duplicate emails, and three bonuses, one of them NULL. Every result was produced by PostgreSQL 18.

Windows keep the rows

A window function computes a value for each row from a set of related rows - its window - without collapsing them. OVER (...) defines the window:

  • PARTITION BY dept - split rows into groups; the calculation restarts in each one.
  • ORDER BY salary DESC - order rows inside each partition (needed for ranking and running totals).
SELECT name, department, salary,
       ROUND(AVG(salary) OVER (PARTITION BY department)) AS dept_avg
FROM employees
ORDER BY id;
namedepartmentsalarydept_avg
Avaeng150000137500
Liameng140000137500
Sofiaeng140000137500
Noaheng120000137500
Emmasales9000088333
Lucassales9000088333
Miasales8500088333
Oliversupport7000070000
The same eight employees through GROUP BY department (three rows, the detail gone) and through AVG(salary) OVER (PARTITION BY department) (eight rows, each with its department's average).

Every aggregate (SUM, AVG, COUNT, ...) can be used this way. Each row keeps its own columns and gains its department's average - "compare with the group" without a join or a correlated subquery.

ROW_NUMBER, RANK and DENSE_RANK

They differ only on ties:

SELECT name, salary,
       ROW_NUMBER() OVER (ORDER BY salary DESC, name) AS row_number,
       RANK()       OVER (ORDER BY salary DESC)       AS rank,
       DENSE_RANK() OVER (ORDER BY salary DESC)       AS dense_rank
FROM employees
WHERE department = 'eng'
ORDER BY salary DESC, name;
namesalaryrow_numberrankdense_rank
Ava150000111
Liam140000222
Sofia140000322
Noah120000443
  • ROW_NUMBER() numbers rows 1, 2, 3, ... with no ties. Which of two tied rows comes first is arbitrary unless the ORDER BY breaks the tie (here, name).
  • RANK() gives ties the same number and then skips (1, 2, 2, 4) - Olympic medals.
  • DENSE_RANK() gives ties the same number without gaps (1, 2, 2, 3).

So "the top 2 salaries" means three different things. ROW_NUMBER() <= 2 returns exactly two people (dropping a tied one at random); RANK() <= 2 returns everyone ranked 1 or 2 (three people here); DENSE_RANK() <= 2 returns everyone earning one of the two highest distinct salaries. Read the requirement for its tie rule before choosing.

Which ranking function does the requirement want?

The requirement saysUseOn salaries 150, 140, 140, 120 with "top 2"
Exactly N rows, ties broken somehow ("pick 2 for the panel")ROW_NUMBER() with a tiebreaker in ORDER BYAva and Liam (Sofia loses the tiebreak)
Everyone whose position is N or better ("medal winners")RANK()Ava, Liam, Sofia (both tied for 2nd)
Everyone with one of the N highest values ("top 2 salary bands")DENSE_RANK()Ava, Liam, Sofia
One row per group, Postgres onlyDISTINCT ON-
A percentile bucket ("top 10% of sellers")NTILE(10) or PERCENT_RANK()-

RANK and DENSE_RANK agree here; they part ways one step further down. Asked for the top 3, RANK still stops at the same three people (Noah ranks 4th), while DENSE_RANK adds Noah, because 120 is the third-highest distinct salary.

Filtering on a window: the top N per group

Window functions are computed after WHERE, GROUP BY and HAVING - just before ORDER BY - so WHERE DENSE_RANK() OVER (...) <= 2 is an error ("window functions are not allowed in WHERE"). Compute the rank in a derived table (or a CTE) and filter outside it:

SELECT department, name, salary
FROM (SELECT department, name, salary,
             DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r
      FROM employees) AS ranked
WHERE r <= 2
ORDER BY department, salary DESC, name;
departmentnamesalary
engAva150000
engLiam140000
engSofia140000
salesEmma90000
salesLucas90000
salesMia85000
supportOliver70000

(Snowflake, BigQuery and DuckDB have a QUALIFY clause for this; PostgreSQL 18 doesn't.) Support has only one person, and still appears - top-N means "at most N".

Second highest, latest per group, and deduplication

The Nth highest value. "Second highest salary" has a trap: ORDER BY salary DESC OFFSET 1 LIMIT 1 returns the second row, which is the top salary again when two people share it, and returns no row at all when there's no second salary. The robust answers are DENSE_RANK() = 2, or an aggregate that returns one row even when empty:

SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
second_highest
140000

The latest row per group. Postgres's DISTINCT ON (expr) keeps the first row of each group in the ORDER BY order - compact, but Postgres-only, and the ORDER BY must start with the DISTINCT ON expressions:

SELECT DISTINCT ON (department) department, name, hired_on
FROM employees
ORDER BY department, hired_on DESC;
departmentnamehired_on
engNoah2025-09-01
salesMia2025-02-03
supportOliver2024-07-22

Deduplication. ROW_NUMBER() partitioned by the key that should be unique and ordered by which copy to keep marks every extra copy with rn > 1. Normalize the key (emails are case-insensitive) and break ties so the choice is deterministic:

SELECT id, email
FROM (SELECT id, email,
             ROW_NUMBER() OVER (PARTITION BY lower(email) ORDER BY created_at DESC, id DESC) AS rn
      FROM signups) AS s
WHERE rn = 1
ORDER BY id;

To actually remove the copies: DELETE FROM signups WHERE id IN (SELECT id FROM (...) WHERE rn > 1) - then add a unique index on lower(email) so they can't come back.

NULLs sort first when descending

PostgreSQL treats NULL as larger than any value: ascending, NULLs come last; descending, they come first. A ranking by bonus puts the person with no bonus at number 1:

SELECT name, bonus, RANK() OVER (ORDER BY bonus DESC) AS rank
FROM bonuses
ORDER BY rank, name;
namebonusrank
LiamNULL1
Sofia70002
Ava50003

Write ORDER BY bonus DESC NULLS LAST whenever the column can be NULL (other engines default differently, so say it explicitly either way).

Where it breaks

  • A window function in WHERE or HAVING - an error; filter in an outer query.
  • ROW_NUMBER without a tiebreaker - which tied row wins can change between runs, and with it the result.
  • Picking the wrong ranking function - ROW_NUMBER drops tied rows, RANK leaves gaps that exclude the next value, DENSE_RANK can return more than N rows.
  • OFFSET for the Nth value - wrong with ties, and returns no row instead of NULL when there isn't one.
  • DESC rankings over a nullable column - NULLs rank first in Postgres. Add NULLS LAST.
  • Deduplicating on the raw key - Ava@Example.com and ava@example.com look different to PARTITION BY email.

Tradeoffs

  • Window + filter vs DISTINCT ON: DISTINCT ON is shorter and fast for "one row per group", but Postgres-only and limited to one row; ranking functions handle top-N and ties and port to every engine.
  • Ranking the whole table vs LATERAL ... LIMIT: for "latest 3 orders for each of 20 customers" in a table of millions, JOIN LATERAL (SELECT ... WHERE customer_id = c.id ORDER BY ordered_at DESC LIMIT 3) can read just those rows through an index, where a window ranks every order first.
  • MAX(...) WHERE < MAX(...) vs DENSE_RANK for the second highest: the aggregate version is short and returns NULL when there's none; DENSE_RANK generalizes to any N.

Going further. The deep dive (next tab) shows how the database evaluates a window - one sort per distinct window, a "run condition" that stops early - when an index removes the sort, why LATERAL can still beat it, and how ranking works when the data is spread over many machines.

Check yourself

  1. Salaries are 100, 90, 90 and 80. What do ROW_NUMBER, RANK and DENSE_RANK (ordered by salary descending) give the 80?
Show answer

4, 4 and 3. ROW_NUMBER counts rows; RANK skips after the tie (1, 2, 2, 4); DENSE_RANK doesn't (1, 2, 2, 3).

  1. Why does SELECT ... WHERE ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) <= 3 fail, and what's the fix?
Show answer

Window functions are evaluated after WHERE, so they can't be used in it. Compute the row number in a derived table or CTE, then filter WHERE rn <= 3 in the outer query.

  1. What does SELECT salary FROM employees ORDER BY salary DESC OFFSET 1 LIMIT 1 return when the two best-paid people earn the same, and when everyone earns the same?
Show answer

The top salary again in the first case (the second row holds the tie), and the same top salary in the second case too - not "no second salary". With a single employee it returns no row at all. MAX(salary) WHERE salary < (SELECT MAX(salary) ...) returns 140000-style answers correctly and NULL when there's none.

Key takeaways

  • A window function computes over related rows (OVER (PARTITION BY ... ORDER BY ...)) and keeps every row.
  • ROW_NUMBER never ties; RANK ties and skips; DENSE_RANK ties without gaps. Choose by the requirement's tie rule.
  • Window functions run after WHERE/HAVING: filter on them in an outer query.
  • The Nth highest value: DENSE_RANK or MAX ... WHERE <, not OFFSET.
  • Deduplicate with ROW_NUMBER() OVER (PARTITION BY normalized_key ORDER BY keep_first) = 1; DISTINCT ON is the Postgres shortcut.
  • Descending order puts NULLs first in Postgres - add NULLS LAST.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the ranking function

The requirement saysUseSalaries 150, 140, 140, 120, "top 2"
Exactly N rows, ties broken somehowROW_NUMBER() + a tiebreaker in ORDER BY150 and one of the 140s
Everyone placed N or better ("medal winners")RANK() (1, 2, 2, 4)150, 140, 140
Everyone with one of the N highest valuesDENSE_RANK() (1, 2, 2, 3)150, 140, 140
One row per group, Postgres onlyDISTINCT ON (key) ... ORDER BY key, ...-
A percentile bucket ("top 10%")NTILE(10) or PERCENT_RANK()-

RANK and DENSE_RANK part ways one step further down: asked for the top 3, DENSE_RANK also returns the 120.

Snippets worth knowing by heart

-- a window keeps every row and adds the group's value
AVG(salary) OVER (PARTITION BY department)

-- top 2 per department (filter OUTSIDE - windows run after WHERE)
SELECT * FROM (
  SELECT department, name, salary,
         DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS r
  FROM employees) ranked
WHERE r <= 2;

-- second highest, NULL when there's none
SELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees);

-- latest row per group (Postgres)
SELECT DISTINCT ON (department) department, name, hired_on
FROM employees ORDER BY department, hired_on DESC, id DESC;

-- dedup: keep the newest copy per normalized key
SELECT id FROM (
  SELECT id, ROW_NUMBER() OVER (PARTITION BY lower(email) ORDER BY created_at DESC, id DESC) AS rn
  FROM signups) s
WHERE rn = 1;

The traps

  • A window function in WHERE or HAVING is an error - compute it in a subquery or CTE and filter outside. (QUALIFY exists in Snowflake, BigQuery and DuckDB, not PostgreSQL.)
  • ROW_NUMBER without a unique tiebreaker picks a different tied row on different runs.
  • OFFSET 1 LIMIT 1 for the second highest returns the top value again on a tie, and no row when there's no second value.
  • DESC over a nullable column puts NULLs first in Postgres. Write NULLS LAST.
  • Deduplicating on the raw key misses Ava@Example.com vs ava@example.com - normalize first, then add a unique index on the normalized key.
  • Top N per group means "at most N": a department with one person still appears.

Staff-level points, one line each

  • A window = sort by PARTITION BY + ORDER BY, then one streaming pass (WindowAgg). Its cost is the sort: in memory (quicksort) or on disk (external merge).
  • Each distinct window ordering is another full sort; windows that share a prefix (department, salary DESC and department) share one.
  • PostgreSQL 15+ pushes rn <= 3 into the window as a Run Condition (for row_number, rank, dense_rank, count): it stops emitting rows early, but still reads and sorts every row.
  • An index on (department, salary DESC) removes the sort but the scan still walks every entry - Postgres can't skip to the next department. LATERAL ... LIMIT 3 does the skip: 3 rows per group.
  • Few big groups → LATERAL + index. Many tiny groups or no index → window + run condition.
  • ORDER BY ... LIMIT 10 uses a top-N heapsort: memory depends on 10, not on the table (25 kB vs ~6 MB for the full sort).
  • DISTINCT ON is a sort plus a Unique node - the cheapest one-row-per-group form in Postgres.
  • A tied order can change when the plan, the parallel interleaving or the rows' physical location changes - so every row-choosing ORDER BY ends in a unique column.
  • Distributed top N: merging each shard's local top 10 is exact only when an item's whole value lives on one shard. Counts spread across shards need combining first, or heavy-hitter sketches (Count-Min, Space-Saving) with over-fetching.
  • Exact percentiles need the full ordering; at scale use t-digest or KLL sketches. Live leaderboards use a sorted set (Redis gives rank in O(log n)) or score buckets, not RANK() per request.

Say it in an interview

  • "Which tie rule does the requirement want? That decides ROW_NUMBER, RANK or DENSE_RANK."
  • "I'll add id to the ORDER BY so the dedup keeps the same row every run."
  • "With 50 huge categories and an index on (category, sales DESC), LATERAL reads 3 rows per category; the window has to read everything."
  • "Local top-10s from each shard can miss the global winner when its count is spread across shards."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Ranking and Top-N per Group free

Top Two Products per Category

MediumProSQL problem

Each category page gets a "Best sellers" strip showing its products with the two highest revenue figures. Revenue is the sum of a product's sales that weren't refunded (refunded = false).

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Keep the Latest Signup per Email

HardProSQL problem

A bug in the signup form let people register more than once. Before a unique index can go on, the duplicates have to go.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Ranking and Top-N per Group free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime