Skip to content

Intermediate: combining and changing data · Window functions · topic 36 of 62 · 35 min

Top-N per group and de-duplication

Explanation

To get the top N rows of each group, number the rows with ROW_NUMBER (or RANK) in a CTE, then keep rn <= N in the outer query. You cannot filter a window function in WHERE, because WHERE runs first. The same pattern finds the latest record per key and finds duplicate rows to delete, keeping the lowest id.

A very common question

"The highest-paid person in each department", "the latest order of each customer", "the top 3 products in each category": these top-N-per-group questions come up at work and in almost every SQL interview. ORDER BY with LIMIT gives the top N of the whole table, not of each group. Window functions solve it cleanly.

Why not WHERE rn = 1?

The natural first try is to put ROW_NUMBER in WHERE. It fails, because window functions are computed after WHERE has already run (FROM, WHERE, GROUP BY, HAVING, then windows). SQLite says "misuse of window function ROW_NUMBER()"; PostgreSQL says window functions are not allowed in WHERE. An alias does not help either.

sql
SELECT name, salary
FROM employees
WHERE ROW_NUMBER() OVER (ORDER BY salary DESC) = 1;
-- Error: misuse of window function ROW_NUMBER()

The pattern: number in a CTE, filter outside

Compute the number in a CTE (a named subquery, from module 6), then filter it in the outer query, where the number is just an ordinary column.

sql
WITH ranked AS (
  SELECT d.name AS dept, e.name, e.salary,
         ROW_NUMBER() OVER (PARTITION BY e.dept_id ORDER BY e.salary DESC, e.name) AS rn
  FROM employees e
  JOIN departments d ON d.id = e.dept_id
)
SELECT dept, name, salary
FROM ranked
WHERE rn = 1
ORDER BY dept;
-- Engineering | Meera Iyer     | 250000
-- Finance     | Farhan Ali     | 105000
-- HR          | Lakshmi Pillai | 80000
-- Sales       | Pooja Gupta    | 110000
For the top 3 per department, write WHERE rn <= 3.

ROW_NUMBER or RANK?

Think about ties. ROW_NUMBER returns exactly N rows per group, picking among tied rows by the tie-breaker. RANK or DENSE_RANK keeps every tied row. In Engineering, the 3rd highest salary is shared by Anita and Vikram: with ROW_NUMBER and rn <= 3 you get one of them; with RANK and rnk <= 3 you get both. Ask which one the business wants.

QUALIFY in some engines

Snowflake, BigQuery and DuckDB (and some others) have a QUALIFY clause, which filters on a window function directly, like a WHERE for windows: ... QUALIFY ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) = 1. SQLite, PostgreSQL, MySQL and SQL Server do not support QUALIFY, so the CTE pattern is the one that works everywhere.

Latest record per key

The same pattern with ORDER BY ... DESC gives the latest row for each key: the latest order per customer, the current address of each user, the last known price of each product. This is far more reliable than joining on MAX(order_date), which returns several rows if two orders share the latest date.

sql
WITH ranked AS (
  SELECT customer_id, id, order_date, status,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, id DESC) AS rn
  FROM orders
)
SELECT customer_id, id, order_date, status FROM ranked WHERE rn = 1 ORDER BY customer_id;
-- 1 | 108 | 2025-04-02 | Placed
-- 2 | 106 | 2025-03-15 | Delivered
-- 3 | 104 | 2025-02-20 | Cancelled
-- 4 | 105 | 2025-03-02 | Shipped
-- 5 | 107 | 2025-03-28 | Placed

Finding and removing duplicates

Imported data often has duplicates: the same email signed up three times. Number the rows inside each email by id; row 1 is the one to keep, every row with rn > 1 is a duplicate.

sql
WITH numbered AS (
  SELECT id, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
  FROM signups
)
SELECT id, email FROM numbered WHERE rn > 1 ORDER BY id;
-- 3 | asha@example.com
-- 5 | ravi@example.com
-- 6 | asha@example.com
These are the rows to delete; ids 1, 2, 4 and 7 stay.

To actually delete them (module 8 covers DELETE in detail), feed those ids to a DELETE. Always run the SELECT first to check what will go.

sql
DELETE FROM signups
WHERE id IN (
  SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
    FROM signups
  ) WHERE rn > 1
);
-- signups now has ids 1, 2, 4, 7

Common mistakes

  • Putting the window function in WHERE. Compute it in a CTE or subquery, then filter outside.
  • Using ROW_NUMBER when tied rows should all be kept. Use RANK or DENSE_RANK.
  • Forgetting PARTITION BY, so you get the top N of the whole table instead of each group.
  • Sorting the wrong way: the latest record needs ORDER BY date DESC inside OVER.
  • Deleting duplicates without a preview, or without a stable rule for which copy to keep. Decide (lowest id, newest date) and run the SELECT first.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Top-N per group and de-duplication · 1/7

Top-N per group

Top N per group is one of the most asked SQL interview questions. Window functions make it easy.

Prefer a video by a teacher? Find free videos on “sql top n per group row_number cte remove duplicates” on YouTube ↗

In real life: The current status of every parcel

A courier or e-commerce tracking system stores every status change of a parcel as a new row: picked up, in transit, out for delivery, delivered. The "where is my parcel" page needs only the latest row for each parcel, which is exactly ROW_NUMBER() OVER (PARTITION BY parcel_id ORDER BY event_time DESC) with rn = 1. The same pattern keeps the latest KYC record per customer in a bank.

sql
WITH latest AS (
  SELECT parcel_id, status, event_time,
         ROW_NUMBER() OVER (PARTITION BY parcel_id ORDER BY event_time DESC) AS rn
  FROM parcel_events
)
SELECT parcel_id, status, event_time
FROM latest
WHERE rn = 1;
An illustration on an imagined parcel_events table: one row per parcel, its newest event.

Practice (0/3 done)

Task 1 · Code · SQL

Find the highest-paid employee in each department. Return the department name as dept, the employee name and salary, one row per department, sorted by dept A to Z. (Departments without employees and employees without a department do not appear.)

Task 2 · Code · SQL

Return the latest order of each customer who has ordered: customer_id, id, order_date and status, sorted by customer_id.

Task 3 · Code · SQL

The signups table has people who registered more than once with the same email. For each email, the row with the lowest id is the one to keep. Return the duplicates to delete: id and email of every other row, sorted by id.

Quick quiz

Question 1

1.Why does WHERE ROW_NUMBER() OVER (...) = 1 fail?

Question 2

2.You need the top 3 salaries per department, and everyone tied with the 3rd must be included. Which do you use?

Question 3

3.Which engines support the QUALIFY clause?

Question 4

4.To get each customer's latest order, which OVER do you use with rn = 1?

Question 5

5.In a de-duplication with ROW_NUMBER() OVER (PARTITION BY email ORDER BY id), which rows are deleted to keep the lowest id?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

Why does WHERE ROW_NUMBER() OVER (...) = 1 fail?

Answer: Window functions are computed after WHERE, so WHERE cannot use them

Compute the number in a CTE or subquery, then filter outside.

You need the top 3 salaries per department, and everyone tied with the 3rd must be included. Which do you use?

Answer: RANK() and rnk <= 3

RANK keeps all tied rows; ROW_NUMBER picks only one of them.

Which engines support the QUALIFY clause?

Answer: Snowflake, BigQuery and DuckDB

QUALIFY is not in SQLite, PostgreSQL, MySQL or SQL Server; use a CTE there.

To get each customer's latest order, which OVER do you use with rn = 1?

Answer: OVER (PARTITION BY customer_id ORDER BY order_date DESC)

Partition per customer and put the newest date first.

In a de-duplication with ROW_NUMBER() OVER (PARTITION BY email ORDER BY id), which rows are deleted to keep the lowest id?

Answer: rn > 1

Row 1 has the lowest id in each email and stays; the others are copies.

How is a window function different from GROUP BY?Related

Answer: It keeps every row and adds a value computed over related rows

GROUP BY collapses each group into one row; a window function keeps all rows and adds, for example, a rank.

From Subqueries, CTEs and window functions

Two employees tie for the 2nd highest salary. Which function returns both of them for "rank = 2"?Related

Answer: DENSE_RANK

DENSE_RANK (and RANK) give ties the same rank; ROW_NUMBER numbers them 2 and 3.

From Classic interview queries, part 1

employees has 11 rows. How many rows does SELECT name, AVG(salary) OVER (PARTITION BY dept_id) FROM employees return?Related

Answer: 11

A window function never removes rows; each of the 11 rows gets its department average.

From OVER and PARTITION BY

Salaries in order are 300, 200, 200, 100. What does RANK() OVER (ORDER BY salary DESC) give?Related

Answer: 1, 2, 2, 4

RANK gives ties the same number and then skips, so the next rank is 4.

From Ranking: ROW_NUMBER, RANK, DENSE_RANK and NTILE

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next: INSERT →

Next steps

  1. This is the last topic of “Window functions”. The module exam comes next.
  2. Module 7 exam: Window functions · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, Changing data, transactions and ACID. 62 topics left in the course before the final exam.

← Previous: LAG, LEAD, FIRST_VALUE and LAST_VALUE