Week 2 · Clean and trust the data › Day 12
Day 12. Duplicates, keys and entity resolution
By the end of today you can
- Tell true duplicates from repeat lines and corrected versions
- Keep the latest version per business key with window functions
- Build surrogate and natural keys deliberately
- Match messy names (stores, guests, products) with fuzzy matching and know its limits
1. Why this matters
Double-counted revenue is the bug that ends trust in a dashboard. Duplicates come in three kinds and each needs a different fix. Mix them up and you'll either double-count or delete real sales.
2. Three kinds of "duplicate"
| Kind | Example | Fix |
|---|---|---|
| Exact re-send | POS re-sent a batch; all columns identical | distinct on all business columns (keep raw lineage separately) |
| New version | Hotel booking 512004 appears again with status=cancelled |
Keep the latest version per business key |
| Genuine repeat | Two separate Classic Burgers on one order | Not a duplicate. Needs a line number or aggregation |
-- latest version per booking (DuckDB / Postgres / T-SQL all support this)
select * from (
select *, row_number() over (partition by booking_id order by file_received_at desc, src_row desc) rn
from raw_hotel_bookings
) where rn = 1;
-- DuckDB shortcut
select * from raw_hotel_bookings qualify row_number() over (partition by booking_id order by file_received_at desc) = 1;
Maths and principle
Principle: deduplication picks one representative from each equivalence class of rows.
Equation: define \(r \sim r'\) iff \(K(r) = K(r')\). The deduplicated table is one row from each class in \(T/\!\sim\), chosen by an order (latest received). \(|T/\!\sim| = |\pi_K(T)|\).
Analogy: reducing fractions: 2/4, 3/6 and 1/2 are one class with representative 1/2. Where it breaks: if \(K\) is too coarse (order_id only), you merge rows that aren't equivalent, which is how real sales disappear.
Try it: 1,000 booking rows, 880 distinct booking_ids. After keeping the latest per id, how many rows? (880.)
3. Keys you create
- Natural key: from the business (
booking_id,store_code,sku). Can change, collide across systems, or be reused. - Surrogate key: made by you, meaningless, stable. In a warehouse an
int identityor a hash of the natural key plus source:md5(source || '|' || booking_id). - Line number: when the source has none, create one deterministically:
row_number() over (partition by order_id order by src_row). Deterministic means a rerun gives the same numbers.
4. Entity resolution
"Northgate Market #104", "NORTHGATE MKT 104" and "Northgate - Store 104" are the same store in three systems. Steps: normalise (case, punctuation, abbreviations), block (only compare candidates sharing a region or number), score (string similarity), decide with a threshold, and keep a manual mapping table that overrides the algorithm.
from rapidfuzz import process, fuzz
canon = stores["store_name"].tolist()
match, score, idx = process.extractOne("NORTHGATE MKT 104", canon, scorer=fuzz.token_sort_ratio)
For people (guest records across hotels, loyalty members) use a probabilistic matcher like Splink (open source, runs on DuckDB), which learns weights for each field.
The 5-year lens
The manual mapping table is the real deliverable. Algorithms suggest; a person confirms; the table (map_store_alias(alias, store_code, confirmed_by, confirmed_on)) is version-controlled and wins over any score. Nobody ever got blamed for a reviewed mapping. Also: any matching of people raises privacy questions (PIPEDA in Canada). Match on what you're allowed to use, and document it.
Lab
- In
qsr-clean, find the re-sent POS batches: count rows that are identical on all business columns. Remove them; log how many per restaurant per day. - Prove the "two burgers" case is real: find orders with the same
menu_item_idtwice that are not part of a re-sent batch (hint: compare against the file's original row positions). Add a deterministicline_no. - Hotel: from all PMS files, build
stg_bookings_latest(latest per booking_id). Count how many bookings changed status at least once. - Make a messy alias list for the 12 retail stores (ask Claude Code to generate 40 variants with typos). Resolve them with rapidfuzz, pick a threshold, measure precision and recall against the truth, then write
map_store_alias.csvfor the misses. - Write tests for each rule.
Ship
feat(clean): dedup rules, deterministic line numbers, store alias resolution.
Check yourself
1. A booking_id appears in three files with statuses confirmed, confirmed, cancelled. What does staging keep?
It's a change feed. Latest wins in staging; history stays in raw (and in SCD2 tables in Week 5).
2. Why must a generated line number be deterministic?
Unstable keys break incremental loads and joins.
3. Fuzzy matching gives "Northgate 110" a 92% match to store #101. What's the safe process?
High scores can still be wrong on the one character that matters (110 vs 101).