Deduplication
Duplicates in production data are the rule, not the exception. Two SQL patterns to remove them — and the judgement call that decides which row survives.
What you'll learn
- Two patterns — ROW_NUMBER + filter, and GROUP BY with an aggregate
- Deciding which duplicate is 'the right one' to keep
- Why SELECT DISTINCT usually isn't enough
- Finding the duplicates before you delete anything
Before you start
Production data has duplicates. A frontend retried a request, a background job ran twice, an ETL load was rerun without an idempotency key. Whatever the cause, the job is the same: collapse the duplicates without losing information.
SELECT DISTINCT is the wrong tool most of the time, because it only removes rows that are identical in every selected column — and real duplicates differ in something (a fresh id, a slightly later timestamp). So before any SQL, you must decide two things: what counts as a duplicate, and which copy to keep. Take this orders table, where the same purchase was recorded twice:
| id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 7 | 4 | 900 |
| 2 | 7 | 4 | 900 |
| 3 | 9 | 3 | 9 |
Rows 1 and 2 are the same (user_id, product_id) — a duplicate group. SELECT DISTINCT * would keep both, because their ids differ.
Pattern 1 — ROW_NUMBER, then keep rn = 1
The flexible pattern: number the rows within each “should be unique” group, ordered by which one wins, and keep the first:
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id, product_id ORDER BY id) AS rn
FROM orders
)
SELECT id, user_id, product_id, amount
FROM ranked
WHERE rn = 1;
| id | user_id | product_id | amount |
|---|---|---|---|
| 1 | 7 | 4 | 900 |
| 3 | 9 | 3 | 9 |
PARTITION BY user_id, product_id defines the duplicate group, and ORDER BY id picks the survivor — here, the lowest id, the first one inserted. Row 2 loses and drops out. This keeps the whole row, which is its advantage over the next pattern.
Pattern 2 — GROUP BY with an aggregate
When you only need the keys plus a summary, GROUP BY is tighter — but it collapses the row to keys and aggregates, so you cannot get the full surviving row back this way:
SELECT user_id, COUNT(*) AS n_rows, MAX(amount) AS max_amount
FROM orders
GROUP BY user_id;
| user_id | n_rows | max_amount |
|---|---|---|
| 7 | 2 | 900 |
| 9 | 1 | 9 |
| Use ROW_NUMBER when… | Use GROUP BY when… |
|---|---|
| You need the full row | You only need keys + aggregates |
| There’s a clear winner (latest, lowest, highest) | Duplicates are exact, either is fine |
| You may want to inspect the losers | You just want unique values + stats |
Look before you delete
Before running anything destructive, find the duplicates with the standard “show me the dupes” query:
SELECT user_id, product_id, COUNT(*) AS dup_count
FROM orders
GROUP BY user_id, product_id
HAVING COUNT(*) > 1;
| user_id | product_id | dup_count |
|---|---|---|
| 7 | 4 | 2 |
Practice
Quick check
Practice this in an interview
All questionsBoth eliminate duplicate rows, but GROUP BY is the right choice when you also want aggregate values per group. DISTINCT is cleaner when you only need unique rows with no aggregation. In practice most optimizers produce identical plans for simple cases, but semantics and intent differ.
Fan-out occurs when a join key is not unique on one side, causing each row on the unique side to match multiple rows on the non-unique side and multiply the result set. This silently inflates SUM, COUNT, and AVG unless the duplicate rows are handled before or after the join.
Many-to-many joins produce a Cartesian product of each matching subset, multiplying row counts exponentially. The correct approach is to pre-aggregate at least one side to a unique grain before joining, or to use a bridge/junction table that resolves the relationship into two one-to-many joins.
Assign ROW_NUMBER() partitioned by the columns that define a duplicate and ordered by a tiebreaker (e.g., primary key or created_at). Any row where the row number exceeds 1 is a duplicate — delete those rows via a CTE or subquery referencing the physical row identifier.