Deduplication: Beginner
The interviewer shares her screen: an orders table, 5 columns, a few thousand rows.
'This feed double-writes sometimes. Give me one row per customer per order date.'
She has not named the columns that decide whether 2 rows match, and she has not said which copy to keep when 3 rows agree on customer and date but carry different amounts. SELECT DISTINCT * is the move most people make in the first 10 seconds, and it returns every row untouched, because the rows differ in order_id and created_at. The next move is a GROUP BY that returns the right number of rows and none of the columns she asked for.
What she is scoring is whether you define the duplicate before you remove it: name the key, measure the repeats, then keep one whole row for each key with a rule for which copy it is.
What you will be able to do
The question rarely contains the word key. It arrives as 'this feed double-writes sometimes, clean it up', as 'the daily order count runs 3% above what finance reports', or as 'every join off this table fans out'. All 3 describe the same table, and none says which columns make 2 rows the same. A row in orders has 5 columns, and 2 rows can agree on all 5, on 2 of them, or on 2 of them only after a string is trimmed, so the word duplicate means nothing until a column list goes with it.
| order_id | customer_id | order_date | amount | created_at |
|---|---|---|---|---|
| 1001 | 1 | 2024-01-01 | 100 | 2024-01-01 09:00:00 |
| 1002 | 1 | 2024-01-01 | 140 | 2024-01-01 10:00:00 |
| 1003 | 1 | 2024-01-01 | 110 | 2024-01-01 11:00:00 |
| 1004 | 1 | 2024-01-02 | 60 | 2024-01-02 08:15:00 |
| 1005 | 2 | 2024-01-01 | 75 | 2024-01-01 12:45:00 |
Match on every column and no 2 of these rows are the same: 5 distinct rows, nothing to remove. If customer_id and order_date decide the match, rows 1001 to 1003 are copies of one key, which leaves 3 rows. On customer_id alone, customer 1's 4 rows fold into 1, which leaves 2. The same table gives a different answer under each definition, and the interviewer has handed you none of them. The first thing you produce is the definition, because every query after it is scored against that choice.
Exact duplicates and duplicates by key
An exact duplicate agrees with another row in every column. It comes from a file loaded twice, a UNION ALL that should have been UNION, or a lookup table someone appended to instead of replacing. Nothing in the row tells the copies apart, so any copy can survive, and one keyword removes the rest. The question is answered in a line and the interviewer learns nothing from it.
A duplicate by key agrees in the columns that identify the entity and differs somewhere else. Rows 1001 to 1003 share a customer and a date, but order_id differs on each one, as do amount and created_at, because the feed wrote the order 3 times and stamped each write. Retries, change-data-capture replays after a failover, and corrections written as fresh rows all produce this shape. Removing these means deciding on a key and then on which copy survives, and almost every interview version of the question is this case, because it tests judgement rather than vocabulary.
The grain is the definition
The sentence worth saying out loud is the grain: what one row of the finished table stands for. 'I'll treat customer_id plus order_date as the key, so the result holds a single row for each customer on each date' takes 2 seconds for the interviewer to confirm or correct, and either response hands you the column list. If she answers that order_id is the business key, this table has no duplicates at all and the 3% overcount comes from a join downstream. That is a different problem, and you caught it before writing a line. Candidates who skip the sentence commit to a definition inside their SQL and discover it was the wrong one when the row count comes back.
- ▸Which columns make 2 rows the same? The answer is the dedup key, and it picks the tool: a keyword for exact copies, a window function for copies by key.
- ▸When 2 rows are the same, which copy survives? The answer is a sort order, and it becomes the ORDER BY of the query that keeps one row.
- ▸Ask both before touching the keyboard; the second has no answer until the first does.
Where the copies come from
- Types SELECT DISTINCT * before asking a single question and gets every row back
- Switches to GROUP BY and loses the columns the interviewer asked to keep
- Spends the next 5 minutes recovering from a definition nobody agreed on
- Hears 'why did the count not change?' before saying anything scorable
- Names the key and the grain in one sentence and waits for a nod
- Asks which copy survives when 3 rows share a key
- Counts what repeats before removing anything
- Writes the keep-one query once, with the right columns and no rewrite
Start with the case where the copies agree in every column. A customer file was assembled from 2 regional exports that overlapped, so imported_customers holds 6 rows for 3 customers. The ask is one copy of each row, and the whole answer is one keyword. Watch what the keyword actually compares, because the rows it keeps are the ones that trip people in the next question.
SELECT DISTINCTcustomer_id,email,segmentFROM imported_customers
Only rows equal in every selected column collapse, so Cara's two segments both stay
Of the 6 rows that go in, 4 come out. Customer 101's 2 rows agree on customer_id and email and carry the same segment, so they fold into 1, and so do customer 102's. Customer 103 keeps both rows, because one says retail and the other wholesale, and DISTINCT compares the entire selected row: a difference in any 1 of the 3 columns keeps a row alive. Drop segment from the select list and the same query returns 3 rows. The columns you list are the definition from the previous section, written into the query.
DISTINCT runs after the select list is computed, so it compares the expressions you wrote rather than the stored columns. Postgres compares case-sensitively by default, as do Snowflake and BigQuery, so there SELECT DISTINCT email keeps Ana@shopmail.com and ana@shopmail.com as 2 values; the MySQL and SQL Server defaults compare case-insensitively and fold them. SELECT DISTINCT LOWER(email) folds them on every engine, which is why the normalised expression is the one to write. The same rule makes SELECT DISTINCT * compare every stored column, including the ones that were never part of the key, which is the mistake the last section of this lesson takes apart.
GROUP BY folds the same rows
| customer_id | segment | copies | |
|---|---|---|---|
| 101 | ana@shopmail.com | retail | 2 |
| 102 | ben@shopmail.com | wholesale | 2 |
| 103 | cara@shopmail.com | retail | 1 |
| 103 | cara@shopmail.com | wholesale | 1 |
The 4 rows are the ones DISTINCT returned, because both constructs do the same work: every column in the list becomes part of a group key, and rows with equal keys fold into 1. Most optimizers produce the same hash aggregate plan for either form, so no performance argument separates them on exact copies. What GROUP BY adds is the aggregate column: copies shows that 101 and 102 arrived twice and 103 once per segment, which is the first evidence of how the overlap happened. If the import had also stamped a loaded_at on each row, GROUP BY could carry MIN(loaded_at) beside each survivor, a column outside the key that DISTINCT has no way to express.
One constraint follows from that. Under DISTINCT, an ORDER BY column has to appear in the select list, because the engine holds no single value of an unselected column for a folded row. GROUP BY lifts the limit for aggregates, so ORDER BY MIN(loaded_at) is legal after a group and has no equivalent under DISTINCT. When the interviewer asks for the earliest copy's load time beside each surviving row, only the second form can produce it.
3 forms that return the same rows
Pick the form that names the intent and say why: 'I'm using DISTINCT because it reads as deduplication; GROUP BY would read as aggregation, and I reach for it when I want a count beside each surviving row.' That sentence tells the interviewer you know the forms are equivalent and chose on purpose. Writing GROUP BY with no aggregate and no comment reads as not knowing DISTINCT exists, and writing UNION against a single source reads as an accident.
NULL collapses with NULL
Both forms treat 2 NULLs in the same column as equal for grouping, even though NULL = NULL evaluates to UNKNOWN in a WHERE or a join. 2 rows with a NULL email and the same customer_id and segment fold into 1 under DISTINCT and under GROUP BY. That is the behaviour you want for a cleanup, and it deserves one sentence out loud, because the same 2 rows would refuse to match in a self-join written to find them. The interviewer hears that you know where NULL behaves one way and where it behaves the other.
- The copies agree in every selected column
- Any copy can survive, so no ordering is needed
- The cleanup is a one-off on a file or an ad-hoc query
- You want the query to read as what it does
- You want a count of copies beside each surviving row
- A column outside the key needs an aggregate, such as the earliest load time
- The result feeds a HAVING filter on the count
- Deduplication is one step of a larger aggregation anyway
3 numbers before anything is removed
| distinct_keys | total_rows | extra_rows | repeating_keys |
|---|---|---|---|
| 3 | 5 | 2 | 1 |
The inner query folds orders to one row per key with its copy count; the outer one counts those rows and sums the copies, so total_rows is 5 and distinct_keys is 3 without a second scan of the table. extra_rows is 2: the dedup must delete 2 rows and return exactly 3, and if it returns 5 or 4 or 2 you know before the interviewer does. repeating_keys is 1, which says the problem is concentrated, a single key with 3 copies, rather than spread across the table. Postgres accepts COUNT(DISTINCT (customer_id, order_date)) as a one-line shortcut for the key count; most other engines reject the tuple, so the CTE form is the one to write when the dialect is unstated.
Which keys repeat
SELECTcustomer_id,order_date,COUNT(*) AS duplicate_countFROM ordersGROUP BY customer_id, order_dateHAVING COUNT(*) > 1
GROUP BY counts rows per key and HAVING keeps only the key with more than one
GROUP BY folds the 5 rows into 3 groups and counts each; HAVING then drops every group with a count of 1, leaving the key (1, 2024-01-01) with its count of 3. The filter has to be HAVING because WHERE runs before the fold and cannot see a count that does not exist yet; WHERE COUNT(*) > 1 is a syntax error on every engine and a common one under time pressure. Say the order as you write it: 'I group first, then filter on the count with HAVING, because WHERE cannot reference an aggregate.'
Look at the copies themselves
A window form shows the same rows without the join: COUNT(*) OVER (PARTITION BY customer_id, order_date) AS copies beside every column of orders, filtered to copies > 1 in an outer query. It reads shorter and introduces the partition clause the next section builds on; either form is accepted, and naming both marks you as someone who has done this more than once.
- ▸Count rows and distinct keys before removal; the difference is the number of rows to remove
- ▸List the repeating keys with
HAVING COUNT(*) > 1and look at a few of their rows - ▸Run the removal and count its output; it must equal the distinct key count, 3 here
- ▸Group the output by key and confirm
HAVING COUNT(*) > 1returns no rows - ▸A removal rate above a few percent is an upstream bug worth naming, beyond a cleanup
With the key agreed and the copies counted, the next mistake is a query that collapses the key alone. SELECT customer_id, order_date FROM orders GROUP BY customer_id, order_date returns the right 3 rows but has no order_id in them, and amount and created_at are gone as well. The interviewer asked for one row per key, and a row has 5 columns. The 3 columns that were never part of the key have to come from somewhere, and where they come from is the whole question.
Aggregating the other columns invents a row
The first instinct is to aggregate them. For this example, orders gains email as a sixth column, which the 10:00 write left NULL. Take the 4 rows dated 2024-01-01: customer 1's 3 writes, 100 at 09:00, 140 at 10:00 and 110 at 11:00, and customer 2's single write of 75 at 12:45. With one date in play, grouping by customer_id alone is the same as grouping by the agreed key. Wrap every non-key column in MAX and the query runs without complaint.
SELECTcustomer_id,MAX(created_at) AS last_created_at,MAX(amount) AS max_amount,MAX(email) AS emailFROM ordersGROUP BY customer_id
The newest created_at, the highest amount and the email come from different duplicate rows
Customer 1 comes out at 11:00 with an amount of 140 and the email ana@shopmail.com. No source row carries that combination: the time belongs to the 11:00 write, the amount to the 10:00 write, and the email to whichever write had one, since MAX skips NULL and so hides that a write lost it. The latest write of this order says 110, and the revenue report built on this result will say 140. Aggregating column by column assembles a record from pieces of different rows, and the record it assembles never existed. This is what the question tests: whether you know that keeping one row per key means keeping a row, with its columns travelling together, rather than a key with the best value of each column stapled on.
Rank the copies, keep rank 1
SELECTcustomer_id,order_date,created_at,ROW_NUMBER() OVER (PARTITION BY customer_id, order_dateORDER BY created_at DESC) AS rnFROM orders
Each duplicate key gets its own numbering, newest first, so rn = 1 marks the row to keep
PARTITION BY customer_id, order_date is the key from the first section written into the query: rows that share both columns form one partition and are numbered apart from every other partition. ORDER BY created_at DESC decides the numbering inside each one, newest first, so the 11:00 write of (1, 2024-01-01) gets rn 1 and the 09:00 write gets 3, with the 10:00 write between them at 2. Rows 1004 and 1005 sit alone in their partitions and get 1. The outer WHERE rn = 1 keeps 1003 along with those 2 singletons: 3 rows, the count the previous section said to expect, and row 1003 arrives whole, amount 110 beside its 11:00 timestamp. Change the 2 clauses and the same shape answers any key and any survivor rule.
Why the filter sits in an outer query
WHERE is evaluated before window functions, so in the SELECT that defines rn the column does not exist yet, and WHERE rn = 1 fails with an unknown column error. The CTE computes rn first, and the outer query filters a result in which rn is an ordinary column. Say it as you type the WITH: 'I'm wrapping the ROW_NUMBER in a CTE because the outer WHERE can't filter on a window function in the same select.' Interviewers hear that sentence as evidence you have written this against a real engine, because the error it avoids is the one everybody hits the first time.
QUALIFY is to window functions what HAVING is to aggregates: a filter that runs after the function has been computed. Postgres offers DISTINCT ON (customer_id, order_date) with an ORDER BY customer_id, order_date, created_at DESC, which keeps the first row of each key in that order. Both are shortcuts for one dialect. The CTE form runs everywhere, so write it unless the interviewer has named the engine, and name the shortcut as you skip it: 'on Snowflake this is a QUALIFY, on Postgres a DISTINCT ON, and this form is portable.'
- ▸
ROW_NUMBERnumbers every row apart, so each partition has exactly onernof 1 and the row count equals the key count - ▸Ties are broken arbitrarily, so 2 writes with the same
created_atcan swap survivors between runs; end with a unique column:ORDER BY created_at DESC, order_id DESC - ▸
RANKgives tied rows the same rank, so 2 writes with the samecreated_atboth pass a= 1filter and the duplicate survives - ▸The
ORDER BYis a business rule, latest here; ask which copy should survive and write that column in - ▸Verify by counting the output rows against the distinct key count from the previous section
Return to the move most candidates make first. SELECT DISTINCT * FROM orders compares all 5 columns, and order_id differs on every row, so 5 rows go in and 5 come out. The interviewer sees the unchanged count and asks what the query did, and the answer is nothing. Narrowing the select list helps only until a non-key column differs: drop order_id and created_at and the 3 copies of (1, 2024-01-01) still survive, because the 09:00 copy says 100 and the other 2 say 140 and 110.
| Select list under DISTINCT | Rows returned | What happened |
|---|---|---|
| * | 5 | `order_id` differs on every row, so nothing folds |
| customer_id, order_date, amount | 5 | the 3 copies carry 3 amounts, so all 3 stay |
| customer_id, order_date | 3 | the key folds, and `amount` is gone |
The table shows the bind. DISTINCT collapses the key if you select only the key and keeps the other columns if you select them. It can't do both, because a difference in any selected column keeps the row. That is the rule to state when the interviewer asks why the first attempt failed: 'DISTINCT compares the whole selected row, and these rows differ in columns outside the key, so the keyword sees 5 different rows.' The fix is the ranked CTE, which collapses on the key and carries the other columns of the chosen row along with it.
Differences you cannot see on the screen
A second family of failures hides inside values that look equal. Postgres compares strings case-sensitively and without padding, and so do Snowflake and BigQuery. On those engines 'Alice@Example.com ' with a trailing space and 'alice@example.com' are different strings, and on every engine a NULL address and an empty one differ. DISTINCT on a customers table with these rows returns every one of them there, and a reader looking at the sample sees 2 copies of Alice and cannot work out why the count did not drop. SQL Server and older MySQL collations ignore case and trailing spaces and may fold the emails, so an explicit LOWER(TRIM(...)) is what makes the key behave the same everywhere. The duplicate is real; the equality test is too strict.
SELECTcustomer_id,LOWER(TRIM(email)) AS email_normalized,COALESCE(address, '') AS address_normalizedFROM customers
LOWER and TRIM give both spellings one key, and COALESCE turns a NULL address into a matchable blank
LOWER(TRIM(email)) maps both of Alice's spellings and both of Bob's to one value each, and COALESCE(address, '') replaces the NULL with a blank that can be compared. Bob's 2 rows now agree in every column, and DISTINCT over this result folds them. Alice's do not: one still says 12 Oak St and the other says blank, so DISTINCT keeps both even though the normalised email proves they describe one person. Normalisation builds a key out of messy columns, and the key-based pattern from the previous section is what removes the copies.
Alice's 2 rows share the partition, the ORDER BY puts the row with an address first, and the result has 2 rows, one per person, with Alice's carrying 12 Oak St. It is the same shape as before with the key built from expressions instead of stored columns. Say that as you write it: 'the key is an expression, so I partition on the expression and keep the most complete copy.'
The diagnosis the interviewer wants to hear
DISTINCT kept both, ask for LENGTH(email) on each before changing the query: a trailing space shows up as 18 against 17 and settles the diagnosis in one line.- State the key and the grain in one sentence before writing SQL
- Count rows and distinct keys first; the difference is what you must remove
- Use DISTINCT only when the copies agree in every selected column
- Keep whole rows with ROW_NUMBER over the key, filtered in an outer query
- Lowercase and trim text keys, and map NULL to a comparable value, before comparing
- Run SELECT DISTINCT * and read an unchanged count as 'no duplicates'
- Aggregate non-key columns with MAX and call the result a row
- Filter rn = 1 in the same SELECT that defines it
- Use RANK where exactly one survivor per key is required
- Remove rows without a number for how many should disappear
> A retailer's order feed, consumed from Kafka with at-least-once delivery, double-writes a few hundred orders a day after broker restarts, and finance reports a daily order count 3% above the warehouse's. The platform team is asked to make the orders table safe to sum.
order_id. If the feed assigns a fresh order_id to each write, the key moves to customer_id with order_date, and the team says so before writing anything.DISTINCT on the key alone gives the right count and loses the amounts, so the removal is a ranked CTE partitioned by the key and ordered by created_at DESC, with its output counted against the key count.DISTINCT compares strings exactly, and so does the partition.Define the key, count what repeats, then keep one whole row per key: the order the interviewer is scoring.
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 4 hands-on challenges
Topics covered: What Counts as a Duplicate, DISTINCT vs GROUP BY, Finding Duplicates Before Removal, Keeping One Row Per Key, Why DISTINCT Alone Often Fails
Lesson Sections
- What Counts as a Duplicate (concepts: Deduplication)
Exact duplicates and duplicates by key The grain is the definition Where the copies come from
- DISTINCT vs GROUP BY (concepts: Deduplication, Grouping, Counting, Set Combination)
GROUP BY folds the same rows 3 forms that return the same rows NULL collapses with NULL
- Finding Duplicates Before Removal (concepts: Grouping, Group Filtering, Counting, Common Table Expressions, Inner Joins)
The interviewer's second question, whichever query you wrote first, is 'how do you know it worked?'. A candidate who removed rows without counting them has no answer, and the habit that separates the 2 answers is measuring the duplicates before touching them. 3 numbers describe the problem: how many rows the table holds, how many distinct keys it holds, and the difference, which is the number of rows any correct dedup must remove. 3 numbers before anything is removed Interviewers read these coun
- Keeping One Row Per Key (concepts: Window Deduplication, Row Numbering, Window Partitioning, Common Table Expressions)
Aggregating the other columns invents a row Rank the copies, keep rank 1 Why the filter sits in an outer query
- Why DISTINCT Alone Often Fails (concepts: Deduplication, Case Conversion, Whitespace Trimming, Null Defaulting, Window Deduplication, String Length)
Differences you cannot see on the screen The diagnosis the interviewer wants to hear When a dedup returns the same count it was given, 3 diagnoses are possible, and naming the right one in a sentence is what scores. The rows differ in a column outside the key, so the fix is partition-and-rank. The rows differ in formatting, so the fix is to normalise into a key first. Or the rows are not duplicates under the agreed key at all, so the overcount comes from a join downstream and the table is fine.