How do you delete duplicate rows from a table while keeping one row per duplicate group?
answer
- Two decisions before any SQL
- Duplicate on which columns?
- Which copy survives, deterministically?
- Delete rows having a better twin
- MIN(id) per group is the survivor set
basics
~20 sDefine which columns make rows duplicates and which row survives, then delete rows that have a surviving twin: DELETE FROM contacts c WHERE EXISTS (SELECT 1 FROM contacts k WHERE k.email = c.email AND k.id < c.id) keeps the lowest id per email.
solid answer
~50 sThe task has two decisions before any SQL: **what makes two rows duplicates** (the key columns) and **which one survives** (a deterministic tiebreaker, usually the smallest id or the earliest timestamp). Then delete every row that has a surviving twin: ```sql DELETE FROM contacts c WHERE EXISTS (SELECT 1 FROM contacts k WHERE k.email = c.email AND k.id < c.id); ``` A row goes only if another row with the same email has a smaller id, so exactly the minimum-id row of each group survives. The aggregate spelling `WHERE id NOT IN (SELECT MIN(id) FROM contacts GROUP BY email)` is equivalent. Two cautions: some engines reject a subquery that reads the table being deleted from, and the fix is to wrap it in a derived table; and if rows are duplicated on *every* column with no identifier to break the tie, plain `DELETE` cannot single one out — you rebuild the table from a `SELECT DISTINCT` instead. Always run the `SELECT COUNT(*)` twin first.
code
sql · 8 lines-- keep the lowest id per email, delete the rest
DELETE FROM contacts c
WHERE EXISTS (
SELECT 1
FROM contacts k
WHERE k.email = c.email
AND k.id < c.id
);go deeper
Be able to state the two decisions — which columns define a duplicate and which copy survives — and to recognise that deleting on the duplicated key alone wipes the whole group.
Write the self-correlated or MIN-per-group statement from memory, explain why exactly one row per group survives, and know the derived-table workaround when the engine refuses to read the delete target.
Handle the messy cases: NULL keys, no primary key at all, huge duplicate volumes where rebuilding beats deleting, and closing the loop with the UNIQUE constraint that prevents a repeat.
Treat repeated deduplication as a modelling and ingestion defect rather than a chore: decide where the uniqueness invariant belongs, how the pipeline stays idempotent, and what a one-off cleanup on a live table is allowed to cost.
## Decide the two rules first "Remove the duplicates" is underspecified until you answer two questions: 1. **Duplicate on what?** Rarely on every column — usually on a business key like `email`, or `(customer_id, order_date, amount)`. 2. **Which row survives?** Any deterministic rule will do, but it must be deterministic: smallest surrogate id, earliest `created_at`, the row with the most non-NULL columns. "Any one of them" is not a rule a `DELETE` can express, and a non-deterministic tiebreaker means re-running the statement can keep a different row. An interviewer is usually watching for whether you ask these before writing SQL. ## The self-referencing EXISTS form The cleanest portable spelling deletes every row that has a *better* twin: ```sql DELETE FROM contacts c WHERE EXISTS ( SELECT 1 FROM contacts k WHERE k.email = c.email AND k.id < c.id ); ``` Read it as: "remove this row if some other row shares its email and has a smaller id." For a group of ids 1, 2, 3 sharing an email, rows 2 and 3 each have a smaller-id twin and go; row 1 has none and stays. Exactly one survivor per group, and the same survivor every time you run it. Swap `<` for `>` to keep the largest id, or correlate on a timestamp to keep the newest — with a second comparison as a tiebreaker if timestamps can tie. Engines that support window functions offer a ranking-based route as well; the aggregate and self-join forms shown here work everywhere. ## The aggregate form ```sql DELETE FROM contacts WHERE id NOT IN (SELECT MIN(id) FROM contacts GROUP BY email); ``` This computes the survivor set once — the minimum id per email — and deletes everything else. It is equally correct and reads well. Note that `MIN(id)` is never NULL for a non-empty group, so the usual `NOT IN` hazard around NULLs in the subquery result does not arise here. ## Two traps worth knowing **Reading the table you are deleting from.** Some engines — MySQL is the well-known case, with its "You can't specify target table ... for update in FROM clause" error — refuse a subquery in the `FROM` clause that reads the table being modified. The standard workaround is to wrap the subquery one level deeper so the engine materialises it first: ```sql DELETE FROM contacts WHERE id NOT IN ( SELECT keep_id FROM (SELECT MIN(id) AS keep_id FROM contacts GROUP BY email) AS survivors ); ``` That extra `SELECT ... FROM (...) AS survivors` is not decoration; it is what makes the statement run on those engines. **Deleting a whole group by accident.** A very common wrong answer is: ```sql -- WRONG: removes every row of every duplicated email, keeping none DELETE FROM contacts WHERE email IN (SELECT email FROM contacts GROUP BY email HAVING COUNT(*) > 1); ``` The subquery identifies *which emails* are duplicated, not *which rows* to drop, so all copies disappear — including the one you meant to keep. Finding the duplicated keys and choosing survivors are two different steps. ## When the rows have no identifier at all If the table has no primary key and rows are identical in every column, no `WHERE` clause can distinguish copy #1 from copy #2 — a predicate that is true for one is true for all. Standard SQL has no answer here; engines expose non-standard physical row identifiers that you can use, and the portable route is to rebuild: ```sql CREATE TABLE contacts_dedup AS SELECT DISTINCT * FROM contacts; -- verify counts, then swap the tables ``` That is also the pragmatic choice when the duplicate share is enormous: writing the survivors is cheaper than deleting the rest. ## NULLs in the key Be explicit about NULL keys. `k.email = c.email` is UNKNOWN when both emails are NULL, so the self-join form treats NULL-keyed rows as non-duplicates and keeps all of them; the `GROUP BY` form treats all NULL emails as one group and keeps one. Decide which behaviour you want before running either statement. ## Before you run it Convert the statement to `SELECT COUNT(*)` with the identical `FROM`/`WHERE`, compare it against `COUNT(*) - COUNT(DISTINCT email)` — those two numbers should agree — and run the real statement inside a transaction so an unexpected count can be rolled back. Deduplication is usually followed by adding the `UNIQUE` constraint that should have been there all along.
- Why is DELETE FROM contacts WHERE email IN (SELECT email FROM contacts GROUP BY email HAVING COUNT(*) > 1) wrong?It deletes every row whose email is duplicated, so each duplicated group loses all of its rows and no survivor remains. Identifying the duplicated keys is only step one; the statement must additionally exclude the chosen survivor, for example by comparing ids or by deleting only rows not in the `MIN(id)` set.
- What do you do when the table has no primary key and duplicate rows are identical in every column?No `WHERE` clause can separate them, because any predicate true of one copy is true of all. Either use the engine's non-standard physical row identifier, or rebuild portably: create a new table from `SELECT DISTINCT *`, verify the row counts, then swap the tables and re-create constraints and indexes.
- After deduplicating, what should you usually do next?Add the `UNIQUE` constraint that would have prevented the duplicates, since a one-off cleanup fixes today's data and nothing about tomorrow's writes. Adding it also serves as verification: the constraint creation fails if any duplicates survived your delete.
saying these in an interview costs you the question
- Deletes every row of each duplicated group, keeping none
- Picks no tiebreaker, so the survivor is arbitrary
- Assumes any engine lets a subquery read the delete target
- Claims duplicates are impossible to remove without a primary key
- Forgets to add the UNIQUE constraint afterwards