Hook: Interviewers love this question because deduping looks simple until you must choose which copy survives.
Question: How do I remove duplicate records from a SQL table?
Answer: First define what makes two rows duplicates, like the same email or the same customer_id plus date of birth. Then use a window function such as ROW_NUMBER() to rank rows inside each duplicate group, keep the row with number 1, and delete the rest. If the table does not have a stable unique key, add one or use a database-specific row id so you can delete the exact physical row safely.
Interview-Ready Answer: I’d start by defining the business key that makes a row duplicate, such as email or a composite key. Then I’d assign ROW_NUMBER() over that key, order the rows by the version I want to keep, usually the oldest or newest, and delete every row with rn > 1. I’d do it in a transaction, because cleanup work should be repeatable and safe, and if the table has no primary key I’d add a surrogate key or use a database-specific row identifier to target rows precisely.
SQL does not know your business rule. Two rows are duplicates only if your system says they are—for example same email, or same first_name + last_name + dob. The first job is always to choose the duplicate key and the tie-breaker, which is the column used to decide which row survives.
PARTITION BY. A partition is just a bucket of rows that share the same key.created_at first or highest id first. This step must be deterministic, meaning the same input always picks the same winner.ROW_NUMBER() starting at 1 for the winner, then 2, 3, and so on for the extras.GROUP BY ... HAVING COUNT(*) > 1 check, and then add a unique constraint or unique index if the business rule should never be violated again.SELECT DISTINCT is great for reading unique results, but it does not change stored data. The ROW_NUMBER() pattern is better for cleanup because it lets you say exactly which copy stays.
| Approach | Best use | Limitation |
|---|---|---|
SELECT DISTINCT | Reporting | Does not delete |
ROW_NUMBER()+DELETE | Table cleanup | Needs tie-breaker |
| Rebuild table | Huge tables | Needs extra space |
The window query usually needs to sort rows by the partition and order columns, so think about O(n log n) work if there is no helpful index. If you already have an index that matches the key and order, many engines can do less sorting and the job gets much cheaper. The delete itself is I/O heavy because every removed row must be logged and every index entry must be updated; for multi-million-row tables, deleting in batches of roughly 5,000 to 20,000 rows per transaction is often easier on the database.
Watch out for NULL values: rows with NULL in the duplicate key may be grouped together by the window function, which is often fine but not always what the business wants. Also watch out for ties in the ORDER BY; if two duplicates look equal, add another column such as the primary key so the winner is predictable. If there is no primary key at all, use a surrogate key in the future, or a database-specific row locator such as PostgreSQL ctid for a one-time cleanup.
Real-World Example: In a subscription checkout service, the customers table may get duplicate email rows when retries happen during a network timeout. The dedupe job should keep the earliest verified profile, delete the later retry rows, and then add a unique constraint so the same email cannot be inserted twice again. If a developer uses plain SELECT DISTINCT in a report and assumes the table is fixed, the UI looks clean while the underlying table still contains duplicates; later, users receive two welcome emails, support sees repeated receipts, and logs show repeated inserts or unique-key failures after the cleanup script runs inconsistently.
-- PostgreSQL example: remove duplicate customer records while keeping the earliest row for each email.
DROP TABLE IF EXISTS customers;
CREATE TEMP TABLE customers (
customer_id INT PRIMARY KEY,
email TEXT,
full_name TEXT,
created_at TIMESTAMP NOT NULL
);
INSERT INTO customers (customer_id, email, full_name, created_at) VALUES
(1, 'ada@example.com', 'Ada', '2024-01-01 09:00:00'),
(2, 'ada@example.com', 'Ada Lovelace', '2024-01-02 10:00:00'),
(3, 'ben@example.com', 'Ben', '2024-01-03 11:00:00'),
(4, 'ben@example.com', 'B. Example', '2024-01-01 08:00:00'),
(5, NULL, 'Unknown A', '2024-01-04 12:00:00'),
(6, NULL, 'Unknown B', '2024-01-05 13:00:00'),
(7, 'cara@example.com', 'Cara', '2024-01-06 14:00:00');
-- Before cleanup: this shows the duplicate groups we are about to fix.
SELECT *
FROM customers
ORDER BY email NULLS LAST, created_at, customer_id;
WITH ranked AS (
SELECT
customer_id,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY created_at ASC, customer_id ASC
) AS rn
FROM customers
)
DELETE FROM customers c
USING ranked r
WHERE c.customer_id = r.customer_id
AND r.rn > 1;
-- After cleanup: one row remains per email group.
SELECT *
FROM customers
ORDER BY customer_id;
-- Verification: if this returns no rows, the duplicate cleanup worked.
SELECT email, COUNT(*) AS row_count
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- Edge-case note:
-- This keeps one NULL email row too, because PARTITION BY groups NULLs together.
-- If NULL should not be deduped, change the business rule before running the delete.Follow-up & Tricky Questions:
ORDER BY so the result is deterministic.PARTITION BY. That makes each unique business identity its own group.GROUP BY key HAVING COUNT(*) > 1 returns no rows, the duplicates are gone.SELECT DISTINCT remove duplicates from the table? No. It only removes duplicates from the query result; the stored rows remain unchanged.NULL values always kept separate? Not in this pattern. In window partitioning, NULLs are grouped together for deduping, so decide whether that matches your business rule before you run the delete.Common Mistakes:
SELECT DISTINCT and calling it cleanup. Correction: it only changes the result set, not the table.Memory Hook: Think of duplicates like a stack of identical shipping labels: pick one label to keep on the package, and recycle the rest.
Cheat Sheet:
ROW_NUMBER() with PARTITION BY.rn > 1.GROUP BY ... HAVING COUNT(*) > 1.Practice Tasks:
(first_name, last_name, dob) instead of email.