Deduplication: Intermediate
What you will be able to do
The numbers duplicates can move
| row_count | order_count | revenue |
|---|---|---|
| 48,200 | 46,800 | 2,410,200.00 |
Aggregates that ignore repeats, and ones that add them
- COUNT(DISTINCT key): repeats collapse before counting
- MIN and MAX: a second copy of a value changes neither
- APPROX_COUNT_DISTINCT: the sketch ignores values it has seen
- EXISTS and IN tests: a match is a match at any copy count
- SUM(amount): every copy adds its amount again
- COUNT(*) and COUNT(col): every copy is a row
- AVG(amount): drifts toward the duplicated orders
- Ratios built on these, such as revenue per order
When the copies disagree
Deduplicate at the order grain, then sum
| order_count | revenue |
|---|---|
| 46,800 | 2,340,000.00 |
The answer that loses the point here is SUM(DISTINCT amount). It deduplicates amounts, while the thing that repeated is orders. Any 2 different orders that each cost 49.99 collapse into 1, and the total comes out low. On a day with 46,800 orders and a few hundred price points, it undercounts heavily. Put DISTINCT inside a subquery at the order grain, never inside the SUM.
The sentence that opens the answer
- ▸'Can a join do the same damage?' Yes. Joining orders to order_items fans 1 order into 1 row per item; distinct order counts survive, order-level sums do not.
- ▸'The table has 4 billion rows and COUNT(DISTINCT) is slow.' APPROX_COUNT_DISTINCT also ignores repeats; Snowflake documents about 1.6% average relative error.
- ▸'How many extra rows are there?' Subtract order_count from row_count, which gives 1,400 here, measured before anything is removed.
The ORDER BY is the business rule
| ORDER BY | Survivor | What it asserts |
|---|---|---|
| source_lsn DESC | Latest committed version | Commit order in the source is the truth |
| source_lsn ASC | First version | The original record matters: first touch, signup state |
| CASE WHEN src = 'billing' THEN 1 ELSE 2 END, updated_at DESC | Billing's newest copy | One system is authoritative for this entity |
| updated_at DESC | Newest by application clock | Source clocks agree and never repeat a value |
| ingested_at DESC | Last copy to arrive | Arrival order matches commit order, which replays and late batches break |
Replays of a commit, versions of an order
SELECTorder_id,source_lsn,status,ingested_at,ROW_NUMBER() OVER (PARTITION BY order_id, source_lsnORDER BY ingested_at) AS rnFROM orders_raw
Rows sharing an order_id and source_lsn are 1 commit delivered twice; rn = 1 marks the earliest arrival to keep
Ties make the survivor change between runs
| order_id | updated_at | status | source_lsn |
|---|---|---|---|
| 5002 | 2024-03-15 10:12:00 | shipped | 81301 |
| 5002 | 2024-03-15 10:12:00 | cancelled | 81302 |
Swap ROW_NUMBER for RANK and the tie becomes visible: both rows rank 1, and WHERE rnk = 1 keeps 2 rows for order 5002. That is wrong for deduplication and useful as a test. Run the RANK version and count keys with more than 1 survivor. You then know whether the ORDER BY is deterministic before it ships.
The join-back alternative
- ▸'Why source_lsn over ingested_at?' Arrival time records when the loader saw a row. A replay or a delayed batch arrives after newer data.
- ▸'Can you drop the CTE?' On Snowflake and BigQuery, QUALIFY ROW_NUMBER() OVER (...) = 1 works in the same SELECT. Databricks accepts it too.
- ▸'Finance wants each order as first placed.' Flip to source_lsn ASC. The partition stays the same and the claim about the data changes.
When no single copy is complete
One rule per column
| Rule | Use it for | How in SQL |
|---|---|---|
| Latest non-null | Attributes updated piecemeal: status, amount, address | Rank NULLs last, newest first, read rn = 1 |
| Earliest | First-seen facts: created_at, first channel | MIN(updated_at) |
| Maximum or any-true | Values that only grow: last_lsn, ever_refunded | MAX(...) |
| Authoritative source | Columns one system owns: email from the CRM | Rank by source priority, then newest |
| Sum across rows | Rows that are parts of a whole, never copies | SUM(...) |
Each window ranks the versions for 1 column. The CASE expression pushes NULLs to the bottom, and then the newest commit wins. The outer query folds each order to 1 row, and MAX(CASE WHEN status_rn = 1 THEN status END) reads the single value that won. The MAX is there only because GROUP BY needs an aggregate, and it sees 1 non-null input. Engines with an IGNORE NULLS option on FIRST_VALUE can replace each ranking with 1 window over the same ordering. Snowflake and BigQuery have that option, and so does Oracle.
2 sources, 1 owner per column
The shortcut that builds the wrong record
The tempting version is GROUP BY order_id with MAX on every column. On text, MAX is alphabetical. 'shipped' beats 'placed' by luck, and 'cancelled' loses to both, so a cancelled order reports as whichever earlier status sorts after it: shipped if it shipped, placed if it never did. Interviewers plant a status that sorts early to see who reaches for this.
- ▸'How do you tell unchanged from cleared?' A NULL meaning 'not sent' looks like one meaning 'set to empty'. The connector must say which, via a changed-columns list or a full row image.
- ▸'CRM and billing disagree on email. Who wins?' The system that owns that column, by a priority ranking, with the newer copy as tie-breaker.
- ▸'Why not COALESCE?' COALESCE picks across columns within 1 row; a merge picks across rows within 1 column, which takes a window or an aggregate.
From a query to a write
The delete that keeps nothing
When each row has an identifier
When nothing tells the copies apart
The NOT IN version that deletes nothing
A popular variant keeps the lowest id per key: DELETE FROM orders WHERE row_id NOT IN (SELECT MIN(row_id) FROM orders GROUP BY order_id). Suppose every copy of any 1 order has a NULL row_id. That group's MIN is NULL, the list contains a NULL, and NOT IN evaluates to unknown for every row that matches no listed id. The statement succeeds and deletes 0 rows. NOT EXISTS, or the ranked IN form above, has no such hole.
What a delete costs on columnar storage
- ▸Expected counts: how many rows get staged and deleted, how many go back in, and the final total for the date.
- ▸The DELETE and INSERT share 1 transaction, so no reader sees an order with 0 copies.
- ▸The loader is paused for the fix, so a new copy cannot land between the staging step and the delete.
- ▸The undo path: Snowflake Time Travel, or Delta Lake RESTORE, back to the version before the delete.
- ▸Rerunning is safe: on a clean table the staging query finds 0 keys and nothing changes.
A key the warehouse records and never checks
The line 'order_id is the primary key' is part of the test. Snowflake records PRIMARY KEY and UNIQUE in the catalog, along with FOREIGN KEY, and enforces none of them; the constraint it enforces is NOT NULL. BigQuery makes you write it down: PRIMARY KEY (order_id) NOT ENFORCED. Redshift and Databricks treat keys as informational. A second order 5000 inserts cleanly on every one of them.
What trusting the declaration costs
Where uniqueness actually comes from
Uniqueness comes from the keyed MERGE and is verified at the gate; the declared PRIMARY KEY on orders never enters the path.
The MERGE depends on its source, orders_merged, holding exactly 1 row per order_id, which keeps it legal and deterministic. The guard s.last_lsn > t.last_lsn lets a replayed or older batch match and change nothing, so running the same load twice leaves orders as it was. Had Thursday's retry gone through this MERGE instead of an INSERT, revenue would never have been 3% high.
Keep the declaration anyway
- Ask which aggregates duplicates can touch before deduplicating anything
- Order the ROW_NUMBER by commit order and add a tie-breaker that is unique
- Take columns that change together from the same winning version
- State expected row counts before a delete and verify them after
- Back every declared key with a keyed MERGE and a uniqueness test
- Don't write SUM(DISTINCT amount) to fix an inflated total
- Don't treat arrival time as the order of truth
- Don't use MAX on a text column to mean latest
- Don't delete by key with HAVING COUNT(*) > 1; every copy goes
- Don't let the optimizer rely on a key nothing checks
> An ecommerce company's finance team finds Thursday's revenue 3% high after a CDC connector failover and a retried load. The data engineer has to give finance a correct number that afternoon, rebuild the current-state orders table from the versioned CDC feed, remove the extra copies already stored, and make sure the next retry cannot repeat the incident.
Which totals duplicates break, which copy survives, and why a declared warehouse key never stopped them.
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 25 minutes
- Challenges
- 2 hands-on challenges
Topics covered: When a Distinct Count Is Enough, Choosing Which Duplicate to Keep, Merging Duplicates Into One Record, Deleting Duplicates in Place, Unenforced Primary Keys
Lesson Sections
- When a Distinct Count Is Enough (concepts: Distinct Counting, Sums & Averages, Deduplication, Approximate Aggregates, Join Cardinality)
The numbers duplicates can move The prompt rarely contains the word duplicate. It arrives as 'revenue looks high' or 'how many orders did we take on Thursday', and the first thing scored is whether you can say which of those numbers repeated rows are able to move. A distinct count collapses repeats before it counts, so a key that appears 3 times contributes 1. Sums and averages read every row, and so does a plain row count, so each extra copy adds its value again. Read the result back the way yo
- Choosing Which Duplicate to Keep (concepts: Window Deduplication, Row Numbering, Window Partitioning, Sorting Results)
The ORDER BY is the business rule Every keep-one query answers 2 questions. PARTITION BY says which rows are copies of the same thing, and ORDER BY says which copy is the truth. Once the key is agreed, interviewers spend almost no time on the partition and most of their follow-ups on the sort. The sort is where a candidate shows they asked what the data means. The source_lsn column is the log sequence number the source database assigned at commit, so sorting it descending keeps the most recently
- Merging Duplicates Into One Record (concepts: Conditional Aggregation, Multiple WINDOWs, Min & Max, Set Combination, Conditional Logic)
When no single copy is complete Keeping 1 whole row assumes some row carries every column correctly. A CDC connector configured to send only changed columns breaks that, since its status update arrives with amount and shipping_address empty. So do 2 systems describing the same order or customer, because each tends to be right about different columns: the CRM owns the email, billing owns the payment address. In both cases the surviving record is assembled column by column. Apply a keep-the-latest
- Deleting Duplicates in Place (concepts: Window Deduplication, Membership Subqueries, Group Filtering, Storage Optimization)
From a query to a write 'Clean the table' means the copies leave storage, so every downstream reader gets the right answer without knowing a dedup happened. SQL makes this harder than it sounds. DELETE removes every row its WHERE clause matches, and 2 identical copies match every predicate identically. To keep 1, something has to tell the copies apart. The delete that keeps nothing This is the most common wrong answer, and it reads as correct. On Thursday's data it deletes 2,800 rows, both copie
- Unenforced Primary Keys (concepts: Group Filtering, Grouping)
A key the warehouse records and never checks This is how Thursday happened. The loader's INSERT committed and then the loader timed out. The orchestrator retried, so the same 1,400 rows went in again under a declared key that accepted them. A candidate who argues from the constraint ('the key would have rejected them') loses the thread right here. Postgres backs a primary key with a unique index, so the second INSERT there fails with 'duplicate key value violates unique constraint'. An engineer