Three different questions get asked as one. Which values are duplicated needs GROUP BY. Which rows to keep needs ROW_NUMBER. Getting rid of them needs DELETE. Mixing them up is why the query you copied off Stack Overflow returned the wrong thing.
Every query below was executed against the sample customers table (16 rows, 3 duplicated emails). The outputs shown are the actual results.
Here is the mess we are working with. A customers table where the same person signed up twice under a slightly different name — the single most common way duplicates actually appear in production. Not identical rows. Identical keys with different payloads.
| customer_id | name | |
|---|---|---|
| 7 | Daniel Martinez | daniel.martinez@email.com |
| 16 | Dan Martinez | daniel.martinez@email.com |
| 2 | Emma Wilson | emma.wilson@email.com |
| 15 | Emma W. | emma.wilson@email.com |
Note what this rules out. SELECT DISTINCT name, email will not help you: the names differ, so every row is already distinct. This is the trap that makes people think DISTINCT is broken.
Group by the column that is supposed to be unique, then throw away every group that only has one member. That is all HAVING is for.
SELECT email, COUNT(*) AS copies FROM customers GROUP BY email HAVING COUNT(*) > 1 ORDER BY copies DESC, email
| copies | |
|---|---|
| daniel.martinez@email.com | 2 |
| emma.wilson@email.com | 2 |
| john.smith@email.com | 2 |
Why not WHERE COUNT(*) > 1? Because WHERE filters rows before they are grouped, and at that point COUNT(*) does not exist yet. HAVING filters the groups after. That ordering — FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY — is worth memorising; a large share of "why doesn't this work" is really a question about it.
Composite keys work the same way. If a row is only a duplicate when several columns match, list them all: GROUP BY customer_id, product, order_date HAVING COUNT(*) > 1. On our sample orders table that returns zero rows — and that is the point of running it. An empty result here is the healthy answer, not a broken query.
GROUP BY cannot answer this, and it is important to understand why: grouping collapses the rows. You get one output row per email, so the individual customer_id and name values are gone. You know a problem exists but you cannot act on it.
A window function keeps every row and adds a number alongside it. PARTITION BY is the grouping key; ORDER BY inside the OVER() decides which copy gets number 1.
WITH ranked AS ( SELECT customer_id, name, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY customer_id) AS rn, COUNT(*) OVER (PARTITION BY email) AS copies FROM customers ) SELECT customer_id, name, email, rn, copies FROM ranked WHERE copies > 1 ORDER BY email, rn
| customer_id | name | rn | copies | |
|---|---|---|---|---|
| 7 | Daniel Martinez | daniel.martinez@email.com | 1 | 2 |
| 16 | Dan Martinez | daniel.martinez@email.com | 2 | 2 |
| 2 | Emma Wilson | emma.wilson@email.com | 1 | 2 |
| 15 | Emma W. | emma.wilson@email.com | 2 | 2 |
Now you can see the decision you are actually making. ORDER BY customer_id keeps the oldest record — "Daniel Martinez", the original signup. Change it to ORDER BY customer_id DESC and you keep "Dan Martinez" instead. Neither is automatically right. Newest wins if the later row is a correction; oldest wins if it is an accidental re-signup. That choice is a business decision wearing an ORDER BY costume.
WITH ranked AS ( SELECT customer_id, name, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY customer_id) AS rn FROM customers ) SELECT customer_id, name, email FROM ranked WHERE rn = 1 ORDER BY customer_id
ROW_NUMBER, RANK, or DENSE_RANK? For deduplication you almost always want ROW_NUMBER, because it is the only one of the three guaranteed to produce exactly one row numbered 1 per partition. RANK and DENSE_RANK both assign 1 to every tied row — so if your ORDER BY has ties, WHERE rn = 1 quietly keeps the duplicates you were trying to remove.
Before writing anything clever, ask whether you have a problem at all. Two counts and a subtraction:
SELECT COUNT(*) AS rows_total, COUNT(DISTINCT email) AS unique_emails, COUNT(*) - COUNT(DISTINCT email) AS extra_copies FROM customers
| rows_total | unique_emails | extra_copies |
|---|---|---|
| 16 | 13 | 3 |
NULLs make these two counts disagree even with no duplicates. COUNT(DISTINCT email) skips NULLs entirely, while COUNT(*) counts every row. A table with 5 NULL emails and zero duplicates still reports extra_copies = 5. If the number looks wrong, count the NULLs before you trust it.
Everything so far only changed what you see. DISTINCT and rn = 1 filter a result set; the duplicate rows are still sitting in the table. Changing the stored data takes DELETE.
-- keep the lowest id per email, delete the rest DELETE FROM customers WHERE customer_id NOT IN ( SELECT MIN(customer_id) FROM customers GROUP BY email ); -- prove it worked SELECT COUNT(*) AS rows_left, COUNT(DISTINCT email) AS unique_emails FROM customers
| rows_left | unique_emails |
|---|---|
| 13 | 13 |
Read this before running a DELETE on anything real. Swap DELETE FROM customers for SELECT * FROM customers first and check the row count is what you expect. A NOT IN subquery that returns even one NULL matches nothing — and a DELETE whose WHERE matches nothing is harmless, while one whose subquery you typo'd can empty the table. Wrap it in a transaction you can roll back, or take a backup. This is the one operation on this page you cannot undo with another query.
MySQL will refuse this exact form with "You can't specify target table for update in FROM clause" — wrap the subquery one level deeper (SELECT id FROM (SELECT MIN(...) ...) AS t) or use a self-join. Postgres, SQLite, and SQL Server run it as written.
GROUP BY + HAVING
ROW_NUMBER + CTE
In interviews the follow-up is almost always the second column. Finding duplicates is the warm-up; being asked which one would you keep and why is the actual question.
Same table, real engine, instant grading. No signup to start, nothing to install.
DISTINCT Values
Where DISTINCT does work — and what it quietly does to your row count.
How Many Genres Do We Cover?
COUNT(DISTINCT …) — the sanity check from step 3, on its own.
Find Duplicate Emails
Step 1, graded. The single most-asked duplicate question in interviews.
UNION ALL Dedup: Cross-Dataset Search
Duplicates you created yourself by stacking two result sets.
Delete Duplicate Records, Keep the Original
Step 4, graded — an actual DELETE, checked against the resulting table.
Deduplicate Orders with ROW_NUMBER
Step 2 on a composite key, where the tiebreak actually changes the answer.
The duplicate patterns on this page are graded challenges inside SQL Quest. Write them yourself, get told exactly where you're wrong, and watch your skill radar move.
Start Free — No Signup ⚡250+ challenges · 5 sample databases · Browser only · Pro from $29/month or $199 one-time