SQL code snippet using ROW_NUMBER and PARTITION BY to delete duplicate rows, keeping one row per group

Deleting Duplicate Rows With ROW_NUMBER()

To remove duplicate rows in SQL, use ROW_NUMBER() OVER (PARTITION BY key_column ORDER BY id) to number each duplicate group, then delete every row where that number is greater than 1 — always inspect the rows in a SELECT before running the DELETE.

Deleting rows is permanent, so duplicate removal in SQL is worth doing carefully — preview exactly what you’re about to delete before you delete it.

Step 1: find the duplicates with ROW_NUMBER()

SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM customers

PARTITION BY emailgroups rows by the column that defines a “duplicate,” and ROW_NUMBER() numbers each row within its group starting from 1. Any row with rn > 1 is a duplicate of an earlier row in that group.

Step 2: preview what would be deleted

SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM customers) t WHERE rn > 1

Run this as a plain SELECTfirst. Every row it returns is a row the next step will delete — confirm that’s actually what you want before proceeding.

Step 3: delete the duplicates

WITH ranked AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn FROM customers) DELETE FROM ranked WHERE rn > 1

Using a CTE (WITH ranked AS (...)) and deleting from it is supported in PostgreSQL and SQL Server. In MySQL, delete via a join back to the same table on its primary key instead, since MySQL doesn’t allow deleting directly from a CTE.

When you don’t need to delete anything

If the goal is just a de-duplicated result set for a report — not a permanent change to the table — SELECT DISTINCT or GROUP BY is simpler and leaves the underlying data untouched:

SELECT DISTINCT email FROM customers or SELECT email, COUNT(*) FROM customers GROUP BY email HAVING COUNT(*) > 1 to find which values are duplicated in the first place.

Back up before you delete

Deletes in SQL are permanent the moment they commit, and a duplicate-removal query touching the wrong partition key can delete far more than intended. Before running the DELETE on anything you can’t easily reconstruct, either wrap it in a transaction you can roll back if the row count looks wrong (BEGIN; DELETE ...; -- check the count, then COMMIT or ROLLBACK), or copy the table first:

CREATE TABLE customers_backup AS SELECT * FROM customers;

Getting the row count right

After deleting, compare row counts to confirm the result matches expectations: SELECT COUNT(*) FROM customers before and after. If the count dropped by more than the number of duplicate rows you previewed in step 2, stop and investigate before doing anything else — that usually means the PARTITION BYcolumn wasn’t as unique a key as assumed.

MySQL without CTE deletes

Versions of MySQL that don’t support deleting from a CTE directly need a self-join instead, deleting the higher-numbered duplicate by its primary key:

DELETE c1 FROM customers c1 JOIN customers c2 ON c1.email = c2.email AND c1.id > c2.id

This keeps the row with the lowest id for each email and deletes the rest.

Run inside a transaction on a production table, so a bad match on the join condition can be rolled back before it commits.

Same idea in other languages

Deduping a pandas DataFrame instead of a live table? See removing duplicate rows in pandas. For a plain list, see removing duplicates from a Python list.

No code required

To find duplicate values in an exported column without writing SQL, paste it into the duplicates remover instead.

Checking an exported column instead of a live table?

Open the duplicates remover

Frequently asked questions

What's the safest way to delete duplicate rows in SQL?

Run the ROW_NUMBER()/PARTITION BY query as a SELECT first to see exactly which rows would be deleted, before changing it to a DELETE statement.

How is DISTINCT different from deleting duplicates?

SELECT DISTINCT returns unique rows in a query's output without changing the underlying table. Deleting duplicates permanently removes the extra rows from the table itself.

Which row does ROW_NUMBER() keep?

Whichever row gets rn = 1 within its PARTITION BY group, which depends on the ORDER BY clause — order by id ASC to keep the earliest row, or a timestamp DESC to keep the most recent.

Does every database support ROW_NUMBER()?

Most modern databases do (PostgreSQL, SQL Server, MySQL 8+, Oracle). Older MySQL versions need a self-join or a correlated subquery instead.

Related guides