Real queries · Real output · Run them yourself

How to find duplicates
in SQL

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.

Practise on a real table — free Start with the query ↓

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.

the duplicates, for context16 rows → 13 unique emails
customer_idnameemail
7Daniel Martinezdaniel.martinez@email.com
16Dan Martinezdaniel.martinez@email.com
2Emma Wilsonemma.wilson@email.com
15Emma 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.

1Which values are duplicated?

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.

find duplicate emails
SELECT email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY copies DESC, email
3 rows
emailcopies
daniel.martinez@email.com2
emma.wilson@email.com2
john.smith@email.com2

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.

2Which row should survive?

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.

number the copies, keep every column
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
6 rows
customer_idnameemailrncopies
7Daniel Martinezdaniel.martinez@email.com12
16Dan Martinezdaniel.martinez@email.com22
2Emma Wilsonemma.wilson@email.com12
15Emma W.emma.wilson@email.com22

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.

one row per email
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
13 rows — the deduplicated set

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.

3The 10-second sanity check

Before writing anything clever, ask whether you have a problem at all. Two counts and a subtraction:

do I even have duplicates?
SELECT COUNT(*) AS rows_total,
       COUNT(DISTINCT email) AS unique_emails,
       COUNT(*) - COUNT(DISTINCT email) AS extra_copies
FROM customers
1 row
rows_totalunique_emailsextra_copies
16133

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.

4Actually removing them

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.

delete duplicates, keep the earliestmodifies data
-- 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
1 row — 16 rows became 13, and they now match
rows_leftunique_emails
1313

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.

Decision Table

Pick the tool by the question you're asking

GROUP BY + HAVING

  • "Do I have duplicates, and how many?"
  • Reporting a data-quality number
  • Any SQL version — no window functions needed
  • Collapses rows: cannot tell you which to keep

ROW_NUMBER + CTE

  • "Which specific row should survive?"
  • Deduplicating while keeping every column
  • You need a deliberate tiebreak rule
  • Needs window function support (SQLite 3.25+, MySQL 8+)

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.

Practice

Run these in the browser

Same table, real engine, instant grading. No signup to start, nothing to install.

Open the editor — free

Keep going

Window functions, properly → CTEs from scratch → CTE vs subquery → GROUP BY and HAVING → Why NULL broke your count → Analyst interview questions → SQL interview prep → Cheat sheet →

Frequently Asked

Reading a query isn't
the same as writing one.

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