Interviewers love this one because duplicate rows are a silent data bug: they can double-charge, double-email, or make reports lie.
Question: Find Duplicate Records in SQL.
Answer: The usual approach is to define what makes a row duplicate, then group by those columns and keep only groups with more than one row. If you need the actual duplicate rows, not just the duplicated values, use ROW_NUMBER() to label rows inside each group and keep the rows after the first one. Watch out for NULL, case, and spaces, because they can change whether two rows should be treated as the same.
Interview-Ready Answer: I’d first clarify whether you mean duplicate business keys or exact duplicate rows. Then I’d usually use GROUP BY ... HAVING COUNT(*) > 1 to find the repeated values, or a ROW_NUMBER() window function to return the duplicate rows themselves. In a real system I also normalize data when needed, like trimming spaces or lowercasing emails, and I pay special attention to NULL handling because that is a common gotcha.
Detailed Explanation: The first step is not writing SQL; it is deciding the rule. A row may be a duplicate because the same business key appears twice, such as the same email, the same order number, or the same product code. A row may also be an exact duplicate, meaning every meaningful column matches except a surrogate key like id. If you skip this definition, you may find the wrong rows and “fix” data that was actually valid.
email; for order lines it might be order_id, line_number.LOWER(TRIM(email)) if the business treats Ana@x.com and ana@x.com as the same person.GROUP BY creates buckets of equal values, while ROW_NUMBER() OVER (PARTITION BY ...) puts a sequence number inside each bucket.COUNT(*) tells you how many rows are in the group. If the count is greater than 1, you have a duplicate group.HAVING COUNT(*) > 1 to return duplicate values, or WHERE rn > 1 to return the extra rows after the first.ROW_NUMBER(), you must choose an ORDER BY rule, often the oldest row, newest row, or smallest id. That rule becomes your “winner.”| Method | Best for | Limitation |
|---|---|---|
| GROUP BY + HAVING | Finding duplicate values | Does not list every row |
| ROW_NUMBER() | Finding extra rows | Needs a tie-break order |
| Self-join | Older SQL engines | More verbose, easier to slow down |
On a big table, the database usually has to inspect the relevant rows at least once, so think of this as a full scan unless an index helps. In practice, the engine may use a hash aggregate (group rows in memory by key) or a sort (order rows first, then count consecutive matches). That means the cost is often around O(n) to O(n log n) time depending on the plan, with extra memory or disk use if the data is large; for example, 10 million rows can fit in memory on one system and spill to disk on another. An index on the duplicate key can help a lot, especially when you are checking a narrow business key like email, but no index can save you if you must compare many wide columns.
NULL values: GROUP BY puts nulls in one bucket, but equality joins do not match nulls the same way. If null should count as one duplicate group, make that explicit with COALESCE.bob@example.com and bob@example.com look the same to humans but not to SQL unless you normalize them.id in the duplicate check, every row looks unique and you miss the problem.COUNT(column) skips nulls, so it can hide bad data. COUNT(*) counts rows.Memory aid: Think of duplicate detection like sorting tickets at a coat check: first you group the same ticket numbers together, then you count how many copies you have, and if there is more than one, the extras are duplicates.
Real-World Example: Imagine a checkout service for an e-commerce site. Customer emails are supposed to be unique, because the billing system, loyalty program, and receipt sender all use that key. One night, support notices that some shoppers received two welcome emails and two loyalty points entries. The root cause was a data import job that compared raw email strings, so ana@example.com, ANA@example.com, and ana@example.com were treated as three different customers. In logs, the team saw repeated insert attempts and a spike in duplicate account creation; in the app, users complained about duplicate receipts and support tickets rose within minutes. The fix was to detect duplicates using a normalized email key and to add a unique constraint after cleanup, so the bug could not return silently.
-- Find duplicate records by a business key, then return the extra rows.
-- This script is intentionally small and self-contained so you can run it in a fresh SQL session.
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
email VARCHAR(100),
full_name VARCHAR(100),
created_at DATE
);
INSERT INTO customers (customer_id, email, full_name, created_at) VALUES
(1, 'ana@example.com', 'Ana', DATE '2024-01-01'),
(2, 'bob@example.com', 'Bob', DATE '2024-01-02'),
(3, 'ANA@example.com', 'Ana Clone', DATE '2024-01-03'),
(4, 'carol@example.com', 'Carol', DATE '2024-01-04'),
(5, 'bob@example.com ', 'Bob Extra Space', DATE '2024-01-05'),
(6, NULL, 'No Email A', DATE '2024-01-06'),
(7, NULL, 'No Email B', DATE '2024-01-07');
-- 1) Find duplicate email values.
-- We normalize with LOWER(TRIM(...)) so case and trailing spaces do not split the same person into different groups.
-- COALESCE turns NULL into a visible bucket, which is helpful when NULL itself is bad data you want to count.
SELECT
LOWER(TRIM(COALESCE(email, '<NULL>'))) AS normalized_email,
COUNT(*) AS occurrences
FROM customers
GROUP BY LOWER(TRIM(COALESCE(email, '<NULL>')))
HAVING COUNT(*) > 1
ORDER BY occurrences DESC, normalized_email;
-- 2) Return the actual duplicate rows, keeping the first row in each group as the "winner".
-- ROW_NUMBER() is useful when the interviewer asks, "Which rows do I delete or review?"
WITH ranked AS (
SELECT
customer_id,
email,
full_name,
created_at,
ROW_NUMBER() OVER (
PARTITION BY LOWER(TRIM(COALESCE(email, '<NULL>')))
ORDER BY created_at, customer_id
) AS rn
FROM customers
)
SELECT
customer_id,
email,
full_name,
created_at,
rn
FROM ranked
WHERE rn > 1
ORDER BY customer_id;
-- Edge case note:
-- If you remove COALESCE, NULL emails stop being part of the duplicate key in this demo.
-- That may be correct in some systems, but in cleanup jobs it can hide bad data.
-- If you want exact-row duplicates instead of email duplicates, put every non-key column into the GROUP BY / PARTITION BY list.Follow-up & Tricky Questions:
order_id, line_number, or partition by the same list in ROW_NUMBER(). The rule is simple: whatever makes one record unique must appear in the duplicate check.ROW_NUMBER() and keep rn = 1 for the row you want to preserve. Usually that row is chosen by earliest timestamp or smallest id.ROW_NUMBER() logic in a CTE or subquery, then delete rows where rn > 1. This is safer than deleting with a plain join because the ranking makes the winner explicit.LOWER() or use a case-insensitive collation if your database and business rules support it. The important part is consistency: detect duplicates the same way the application writes the data.COUNT(email) work? Not reliably if email can be null, because COUNT(column) ignores nulls. Use COUNT(*) when you want to count rows, not just non-null values.DISTINCT find duplicates? No, DISTINCT removes repeated results but does not tell you which rows were duplicated. It is a display tool, not a diagnosis tool.id is usually just a row identifier, not the thing that should be unique from a business point of view.Common Mistakes:
id and group by the business columns that should be unique.COUNT(column) when nulls matter. Fix: prefer COUNT(*) so null rows are counted too.DISTINCT is enough. Fix: DISTINCT removes repeats, but GROUP BY ... HAVING or ROW_NUMBER() is what reveals them.Memory Hook: Group by counts the crowd; row_number gives each person a seat number. If seat number is greater than 1, that person is a duplicate you need to review.
Cheat Sheet:
GROUP BY key HAVING COUNT(*) > 1 finds repeated values.ROW_NUMBER() OVER (PARTITION BY key ORDER BY ...) finds extra rows.LOWER(TRIM(...)) when case and spaces should not matter.NULL behavior and choose COUNT(*) intentionally.Practice Tasks:
email values in the sample table using only GROUP BY.ROW_NUMBER().full_name + email combinations instead of email alone.