Deduplication: Beginner
"Remove Duplicates" Requires a Definition
Recognize that deduplication questions require you to define what constitutes a duplicate (exact, fuzzy, time-window).
Here is the question every entry-level interview asks in some form: 'this orders table has duplicate rows; keep one row per (customer_id, order_date) and pick the most recent one.' The candidate who writes SELECT DISTINCT * gets the wrong answer because the rows are not byte-for-byte identical; they differ in created_at or some downstream-only column. The candidate who writes a GROUP BY collapses the row count but loses the per-row data. The candidate who reaches for ROW_NUMBER OVER (PARTITION BY customer_id, order_date ORDER BY created_at DESC) and filters to rn = 1 keeps exactly one row per key, picking the most recent by the tiebreaker column.
- ▸Are the rows byte-for-byte identical, or duplicate by key?
- ▸When duplicates exist, which copy should we keep?
- ▸First question picks the tool (DISTINCT vs ROW_NUMBER)
- ▸Second question picks the tiebreaker (ORDER BY)
Two kinds of duplicate: exact vs by key
Why companies care
- Reaches for DISTINCT without checking whether the rows are identical
- Returns the same row count as the input because the rows differ in some column
- Has to be corrected when the interviewer asks 'why didn't dedup work?'
- Spends time recovering from the wrong tool choice
- Asks 'are the duplicates byte-for-byte identical, or just duplicate by key?'
- Picks DISTINCT for the first case; ROW_NUMBER + filter for the second
- Names the tiebreaker column when ROW_NUMBER is the right tool
- Has the right query in twelve lines without backtracking
DISTINCT vs GROUP BY for Whole Rows
Write the standard pattern: ROW_NUMBER() OVER (PARTITION BY dedup_key ORDER BY tiebreaker) = 1.
The DISTINCT pattern
DISTINCT vs UNION vs GROUP BY (the three forms that do the same thing)
- the readable form. Names the intent.
- valid but reads as aggregation, not dedup.
- UNION without ALL deduplicates; rarely the right reach for single-source dedup.
DISTINCT and NULLs
- The rows are byte-for-byte identical across all SELECT columns
- The dedup is a one-time cleanup (imported CSV, ad-hoc query)
- No tiebreaker is needed; any copy is as good as any other
- Readability matters; DISTINCT names the intent
- Rows are duplicates by key but differ in some non-key column
- You need to pick a specific copy (latest, highest version, most complete)
- The dedup is part of a larger query that needs to preserve the picked row's other columns
- The data has timestamps or version markers that should drive the choice
The first follow-up: count the dupes before removing
Identifying the Duplicate Key
Know when each deduplication method applies: DISTINCT for exact rows, GROUP BY for aggregation, ROW_NUMBER for keeping specific rows.
The canonical query
Reading the pattern
PARTITION BY customer_id, order_date defines the dedup key: rows with the same (customer_id, order_date) are duplicates of each other. ORDER BY created_at DESC picks the tiebreaker: among duplicates, the row with the most recent created_at gets rn = 1, the next most recent gets rn = 2, and so on. The outer WHERE rn = 1 keeps exactly one row per (customer_id, order_date) group: the most recent. The pattern generalizes to any key and any tiebreaker; change the PARTITION BY and ORDER BY clauses to match the question.
Why a CTE is mandatory here
- ▸SQL evaluates WHERE before window functions
- ▸The rn column does not exist when WHERE runs
- ▸Wrap in a CTE; filter rn = 1 in the outer SELECT
- ▸On Snowflake / BigQuery, use QUALIFY to skip the CTE wrapper
Walk through a small example
| customer_id | order_date | created_at | amount | rn | Kept? |
|---|---|---|---|---|---|
| 1 | 2024-01-01 | 09:00 | 100 | 3 | no |
| 1 | 2024-01-01 | 10:00 | 105 | 2 | no |
| 1 | 2024-01-01 | 11:00 | 110 | 1 | YES |
DISTINCT ON, the Postgres shortcut
The QUALIFY clause shortcut
The ROW_NUMBER pattern is the workhorse for dedup. Memorize the shape; you will write it in every other interview that involves dirty data. Practice writing it in twelve lines without referencing anything; if you stumble on the syntax, the interviewer is reading you as inexperienced even if the technique is correct.
Keeping One Row Per Key
Handle near-duplicates: events within N seconds, case-insensitive matching, phonetic similarity.
The tiebreaker is a business decision
- ORDER BY created_at DESC: latest row wins (most common)
- ORDER BY version DESC: highest version wins (SCD type 2 dims)
- ORDER BY source_priority ASC, created_at DESC: trust-then-latest
- ORDER BY CASE WHEN status='active' THEN 0 ELSE 1 END, created_at DESC: active-then-latest
- Latest wins: CDC streams, event retries, simple dedup
- Highest version: explicit versioning is in the schema
- Trust-then-latest: multiple upstream sources of varying quality
- Active-then-latest: status-aware dedup; prefer in-state rows
Deterministic tiebreaking
The 'most complete row' tiebreaker
The 'merge instead of pick' alternative
The tiebreaker conversation is what the interviewer is reading you for at the entry level. The SQL pattern (ROW_NUMBER + filter) is mechanical; the choice of tiebreaker is the design call. Ask the question; pick the tiebreaker; defend the choice with the consumer's question. That sequence is the move.
Why DISTINCT Alone Is Often Wrong
Discuss idempotent deduplication in ETL: dedup-on-write vs dedup-on-read, MERGE semantics, and exactly-once guarantees.
Detection: finding the duplicates
The most common dedup gotchas
Dedup as an idempotent transform
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see 'remove duplicates' | "SELECT DISTINCT." | "Two questions first: are the rows byte-for-byte identical, or duplicate by key? And which copy do we keep when there are duplicates?" |
| The interviewer says 'keep the most recent' | "ORDER BY created_at DESC." | "ROW_NUMBER over (PARTITION BY key ORDER BY created_at DESC, tiebreaker_id ASC), filtered to rn = 1. The secondary tiebreaker makes the result deterministic on tied timestamps." |
| The data has NULLs in the key | "I'll filter them out." | "NULL never equals NULL in three-valued logic, so NULLs in the key partition each NULL into its own group. If they should dedupe together, COALESCE to a sentinel before PARTITION BY." |
| The interviewer asks 'how do you verify' | "I'll trust the query." | "GROUP BY the key with HAVING COUNT(*) > 1 to find the duplicate keys before removal. Compare the source row count to the deduped count; an unexpected drop is a signal to investigate before deduplicating." |
| The data is dirty (case, whitespace) | "I'll dedupe as-is." | "Normalize first: LOWER, TRIM, COALESCE. Two strings that differ only by case or whitespace should dedupe together; comparing as-is misses them." |
The closing summary
> You are in a data engineering phone screen at a logistics company. The interviewer asks: 'This orders table has duplicate rows from an upstream CDC stream. Keep one row per (customer_id, order_date), picking the most recent one.'
DISTINCT answers only the first case; anything where the rows differ in a column you care about needs an explicit survivor rule.ROW_NUMBER() OVER (PARTITION BY key ORDER BY tiebreaker) filtered to rn = 1, wrapped in a CTE because WHERE runs before window functions and cannot see the rn column.ORDER BY created_at DESC, order_id ASC, or ties on the primary tiebreaker let the engine pick arbitrarily and the same query returns different rows on different runs.GROUP BY the dedup key with HAVING COUNT(*) > 1 tells you which keys duplicate and how badly, and ten thousand duplicates on one key is an upstream join bug, not something to quietly delete.LOWER(TRIM(email)) collapses case and whitespace variants that the raw string comparison treats as distinct, and NULL in the partition key never matches another NULL unless you COALESCE it to a sentinel first.QUALIFY on Snowflake, BigQuery, and Databricks filters window output without the CTE wrapper, and DISTINCT ON is the Postgres form. The CTE version is what travels everywhere.Real data has duplicates; the interview tests whether you can define "duplicate"
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: "Remove Duplicates" Requires a Definition, DISTINCT vs GROUP BY for Whole Rows, Identifying the Duplicate Key, Keeping One Row Per Key, Why DISTINCT Alone Is Often Wrong
Lesson Sections
- "Remove Duplicates" Requires a Definition (concepts: sqlWindowDedup)
Two kinds of duplicate: exact vs by key An exact duplicate is a row where every column has the same value as another row. Two rows with the same customer_id, same order_date, same amount, same created_at: byte-for-byte identical. DISTINCT removes these cleanly. A duplicate by key is a row where some subset of columns has the same value but other columns differ. Two rows with the same customer_id and order_date, but different created_at and different amount (because one is a correction). DISTINCT
- DISTINCT vs GROUP BY for Whole Rows (concepts: sqlWindowDedup)
The simplest case: the rows are byte-for-byte identical, and you want one copy of each. SELECT DISTINCT is the one-line answer. It is the right tool when every column has the same value across duplicate rows, which happens with CSV imports where someone duplicated a region, with UNION ALL queries that should have been UNION, and with simple lookup tables. Anything more complex needs the next section's tool. The DISTINCT pattern DISTINCT considers all columns in the SELECT list together. Two rows
- Identifying the Duplicate Key (concepts: sqlWindowDedup)
The common case: rows are duplicates by some key (customer_id, order_date) but differ in other columns (created_at, amount, status). DISTINCT does not help. The canonical tool is ROW_NUMBER OVER (PARTITION BY key ORDER BY tiebreaker) filtered to rn = 1. The partition defines the key; the order defines which copy is picked first; the filter keeps only the picked copy. This is the pattern that handles 80% of real-world dedup questions. The canonical query Reading the pattern Why a CTE is mandatory
- Keeping One Row Per Key (concepts: sqlWindowDedup)
ROW_NUMBER + filter only works if you can tell the engine which copy to pick. The tiebreaker in the ORDER BY is the business decision. Different tiebreakers produce different answers. The candidate at this level is being scored on whether they ask the tiebreaker question before writing the query, and whether they pick a deterministic tiebreaker that produces the same result on every run. The tiebreaker is a business decision 'Keep the most recent row' is the default for many dedup questions, but
- Why DISTINCT Alone Is Often Wrong (concepts: sqlWindowDedup)
Past correctness, the interviewer wants to know whether you treat dedup as a one-step operation or as a multi-step investigation. Detecting duplicates is a different query from removing them. Counting duplicates is a different query from either. Each is useful at a different point in the workflow; mixing them up loses the diagnostic signal that drives the decision. Detection: finding the duplicates Before deduplicating, find the duplicates. The query: GROUP BY the dedup key, HAVING COUNT(*) > 1.