Intermediate: combining and changing data · Window functions · topic 36 of 62 · 35 min
Top-N per group and de-duplication
Explanation
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.
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.
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 | 110000ROW_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.
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 | PlacedFinding 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.
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.comTo 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.
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, 7Common 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 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.
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;Practice (0/3 done)
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.)
Return the latest order of each customer who has ordered: customer_id, id, order_date and status, sorted by customer_id.
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
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Running totals, moving averages and framesWindow functions · same module
- Subqueries, CTEs and window functionsBackend Development · SQL in depth
- Classic interview queries, part 1Interview questions and practice projects
- Common table expressions (WITH)Subqueries, CTEs and set operations
- GROUP BYAggregation: GROUP BY and HAVING
- Correlated subqueries and derived tablesSubqueries, CTEs and set operations
Questions people ask about this
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.
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.
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.
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.
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 steps
- This is the last topic of “Window functions”. The module exam comes next.
- Module 7 exam: Window functions · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Changing data, transactions and ACID. 62 topics left in the course before the final exam.