Finding duplicates in SQL is like sorting mail at a busy front desk: you do not just want to know that two envelopes look alike, you want to know which one arrived first and which ones are extras.
Question: Duplicate Detection
Answer: In SQL, duplicate detection usually means finding rows that match on a business key, such as email, order id, or a combination of columns. The most common solution is a window function like ROW_NUMBER() with PARTITION BY to group matching rows and ORDER BY to choose a stable winner. Rows with row_number > 1 are the duplicates, and row number 1 is the row you keep.
Interview-Ready Answer: I would detect duplicates by partitioning rows on the business key and using ROW_NUMBER() to number rows inside each group. Then I would treat rn = 1 as the keeper and rn > 1 as duplicates. I like this approach because it is deterministic when I add a stable ORDER BY, such as created time plus a unique id, so I can safely report, delete, or dedupe the extras.
Detailed Explanation: Duplicate detection is not the same thing as removing duplicate output rows. A window function is a calculation that looks at a set of related rows while still returning one result row per input row. That is why it is so useful here: you can label every row as first, second, third, and so on, instead of collapsing the group too early.
The first step is deciding what makes two rows the same. In a users table, the key might be email. In an orders table, it might be order_number. Sometimes it is a composite key, which means several columns together, like customer_id plus order_date plus amount.
PARTITION BY tells SQL, 'put matching rows into the same bucket.' Every bucket is processed separately. If you partition by email, every row with the same email value gets the same counter sequence. If you partition by multiple columns, then all of those values must match for rows to be considered duplicates.
ROW_NUMBER() needs an ORDER BY so it can decide which row is first, second, third, and so on. This is the part many candidates miss. Without a stable order, the database may pick a different row as the keeper on different runs, which is dangerous if you later delete rows based on that numbering. A good tie-breaker is usually a unique column like id or a load timestamp plus id.
Once rows are partitioned and ordered, SQL walks through each bucket and numbers the rows starting at 1. The first row is the one you usually keep. Every later row is a duplicate candidate. If you also want to know how big the duplicate group is, add COUNT(*) OVER (PARTITION BY ...) alongside ROW_NUMBER().
To find duplicates, filter for rn > 1. To keep one copy, keep rn = 1. To delete duplicates, use a CTE or subquery that ranks rows first, then delete from the ranked set. The important habit is: rank first, filter second.
| Method | Output | Best use | Limitation |
|---|---|---|---|
| DISTINCT | Unique rows | Clean result set | No row identity |
| GROUP BY | One row per group | Counts and summaries | Hard to keep one base row |
| ROW_NUMBER | Ranked rows | Find and delete extras | Needs stable order |
DISTINCT is useful when you only care about the final result set. GROUP BY is useful when you want summaries like counts. But duplicate detection is usually a row-level problem: you want to know exactly which physical rows are extras. That is where ROW_NUMBER() wins.
Window functions are not free. In many engines, ROW_NUMBER() requires the data to be sorted by the partition and order keys. The expensive part is the sort, so the overall cost is often around O(n log n) time, not pure O(n). Space usage depends on how much data must be buffered for the sort; if memory is too small, the database may spill to temporary disk files. On a table with millions of rows, that spill can be the difference between a fast query and a query that runs for minutes.
If there is an index that matches the partition and ordering columns, the engine may avoid some sorting work. For example, an index on (email, created_at, id) can help a query that partitions by email and orders by created_at, id. If you normalize the key with LOWER(email), some databases can only use that index if you create a matching functional index. That is a classic production gotcha.
NULL in the partition key end up in the same window partition. If NULL means 'unknown' and should not count as a duplicate, filter those rows out first.Alice@example.com and alice@example.com may or may not be the same depending on business rules and collation. Normalize with LOWER() if needed.id. Otherwise the 'first' row can be arbitrary.PARTITION BY when one column alone is not enough to define sameness.Bottom line: the mental model is 'bucket the matching rows, number them, keep the first one.' That is the cleanest and safest duplicate-detection pattern in SQL.
Real-World Story: Imagine a checkout service in an e-commerce system. Every order gets an idempotency_key so retries do not create two charges. A developer notices duplicate rows in the orders table and uses DISTINCT in a report, thinking the problem is solved. The report looks clean, but the underlying duplicate inserts are still there, so shipping and billing systems both process the same purchase twice.
In production, the symptom is noisy and painful: support tickets about double charges, order history showing two nearly identical rows, and logs with the same idempotency key appearing more than once. Once the team switches to a window-function query, they can prove which row arrived first, flag the extra inserts, and safely dedupe while preserving the original order record. The bug was not just 'duplicates exist' — it was 'we did not know which row was the real one.' That is exactly why ROW_NUMBER() matters.
-- Duplicate detection with a deterministic keeper row.
-- This example is intentionally small, but the pattern scales to real tables.
-- If your business rule treats emails as case-sensitive, remove LOWER(email).
WITH sample_users AS (
SELECT 1 AS user_id, 'Alice@example.com' AS email, '2024-01-01 09:00:00' AS created_at, 'Alice A' AS full_name
UNION ALL SELECT 2, 'alice@example.com', '2024-01-02 10:00:00', 'Alice B'
UNION ALL SELECT 3, 'bob@example.com', '2024-01-03 12:00:00', 'Bob'
UNION ALL SELECT 4, 'bob@example.com', '2024-01-03 12:00:00', 'Robert'
UNION ALL SELECT 5, NULL, '2024-01-04 08:00:00', 'Unknown A'
UNION ALL SELECT 6, NULL, '2024-01-05 08:00:00', 'Unknown B'
),
ranked AS (
SELECT
user_id,
email,
created_at,
full_name,
ROW_NUMBER() OVER (
PARTITION BY LOWER(email)
ORDER BY created_at, user_id
-- user_id breaks ties so the keeper is stable across runs.
-- Without a stable ORDER BY, the database may pick a different "first" row.
) AS rn,
COUNT(*) OVER (
PARTITION BY LOWER(email)
) AS dup_count
FROM sample_users
)
SELECT
user_id,
email,
created_at,
full_name,
dup_count,
rn,
CASE
WHEN dup_count > 1 AND rn = 1 THEN 'keep-first'
WHEN dup_count > 1 AND rn > 1 THEN 'duplicate'
ELSE 'unique'
END AS row_status
FROM ranked
ORDER BY COALESCE(LOWER(email), 'zzzz'), rn, user_id;
-- If you only want the extras, wrap the ranked CTE above and add:
-- WHERE rn > 1
-- If NULL emails should be ignored, filter them out before ranking with:
-- WHERE email IS NOT NULLFollow-up & Tricky Questions:
WHERE rn > 1 on the ranked result. That keeps the first row out of the result and shows only the extras.rn > 1. The key is to make the keeper deterministic before you delete anything.PARTITION BY. For example, duplicates could be defined by customer_id, order_date, and amount together.LOWER() or use a case-insensitive collation if your database supports it. Without normalization, Bob@example.com and bob@example.com may be treated as different rows.id. Otherwise the winner can change between runs, which is risky for deletes.DISTINCT solve duplicate detection? No. DISTINCT removes duplicate output rows after projection, but it does not identify which source row is the extra one. For row-level cleanup, ROW_NUMBER() is the better tool.NULL values automatically duplicates? Not by business rule, but window partitioning usually places NULL values in the same partition. If NULL means unknown and should not count, exclude those rows before ranking.ROW_NUMBER() without ORDER BY? For duplicate detection, no. You need a stable order to choose a keeper; otherwise the 'first' row is arbitrary and may vary.Common Mistakes:
DISTINCT too early. DISTINCT is great for cleanup in a final result, but it hides row identity. The fix is to rank rows first, then filter.ORDER BY. If you do not add a stable tie-breaker, the chosen keeper can change. The fix is to order by something unique, such as created_at, id.NULL. Email duplicates may be case-insensitive, and NULL may mean 'unknown' instead of 'duplicate'. The fix is to normalize or filter based on the rule.Memory Hook: 'Same bucket, ticket numbers.' Put matching rows in the same bucket, hand out 1, 2, 3, and keep ticket 1.
Cheat Sheet:
PARTITION BY for the duplicate key.ROW_NUMBER() to label rows inside each group.ORDER BY to choose the keeper.rn = 1 usually means keep; rn > 1 means duplicate.COUNT(*) OVER tells you how large each duplicate group is.NULL according to business rules.Practice Tasks:
email and mark which row you would keep.customer_id plus order_date plus amount.created_at.