Entity resolution: blocking and the threshold
Simulate a modelNeeds Python
Problem
Entity resolution decides which records describe the same real thing. Comparing every record with every other is n(n − 1)/2 comparisons, which is hopeless beyond a few thousand records, so systems first block: only records that share a cheap key (a surname initial and a postcode) are compared. Blocking loses every true duplicate whose key differs, for example one with a mistyped postcode. Then a similarity score and a threshold decide matches, and the threshold sets the exchange rate between two harms: merging two different people, and missing a duplicate.
Generate 20,000 synthetic customers, copy 2,000 of them with typos in the name (swap letters, drop a letter), and score pairs with a simple string similarity (difflib.SequenceMatcher is enough). First count all pairs: n(n−1)/2 for n = 22,000 is about 242 million. Then block on the first letter of the surname plus the postcode and count pairs again. Measure how many true duplicates the blocking loses (recall). Finally, sweep the threshold from 0.7 to 0.95 and plot false merges against missed duplicates.
The script also mistypes the postcode in 5% of the copies, as real data entry does.
The question. How many comparisons does blocking save, what share of true duplicates does it lose, and how do false merges and missed duplicates trade off as the threshold moves?
Predict
About 242 million pairs without blocking, far fewer with it, and a recall below 100%. As the threshold rises, false merges fall and missed duplicates rise.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
entity-resolution-blocking-and-the-threshold.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Entity resolution: blocking and the threshold. 20,000 synthetic customers, 2,000 copied with typos. Comparing all pairs is n(n-1)/2; blocking on the surname's first letter plus the postcode cuts that and loses some true duplicates (recall). Then a threshold sweep: false merges against missed duplicates. """ import argparse, json, random, string from collections import defaultdict from difflib import SequenceMatcher p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--customers", type=int, default=20_000, help="Distinct customers.") p.add_argument("--duplicates", type=int, default=2_000, help="Copies with typos.") p.add_argument("--postcodes", type=int, default=400, help="Distinct postcodes.") p.add_argument("--postcode-typo", type=float, default=0.05, help="Share of copies whose postcode is also mistyped.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) FIRST = ["Anna", "Ben", "Chloe", "David", "Elena", "Farid", "Grace", "Hugo", "Ines", "Jonas", "Kira", "Liam", "Maya", "Noah", "Olga", "Pavel", "Quinn", "Rosa", "Sami", "Tara", "Umar", "Vera", "Wen", "Yara", "Zoe"] def surname(): return "".join(random.choice(string.ascii_lowercase) for _ in range(random.randint(5, 9))).capitalize() def typo(s): i = random.randrange(1, len(s) - 1) return s[:i] + s[i + 1] + s[i] + s[i + 2:] if random.random() < 0.5 else s[:i] + s[i + 1:] # swap or drop a letter people = [{"id": i, "first": random.choice(FIRST), "last": surname(), "pc": f"P{random.randrange(args.postcodes):04d}"} for i in range(args.customers)] recs = [dict(r, rid=r["id"]) for r in people] for r in random.sample(people, args.duplicates): pc = r["pc"] if random.random() > args.postcode_typo else f"P{random.randrange(args.postcodes):04d}" recs.append({"id": r["id"], "first": r["first"], "last": typo(r["last"]), "pc": pc, "rid": len(recs)}) n = len(recs) all_pairs = n * (n - 1) // 2 blocks = defaultdict(list) for r in recs: blocks[(r["last"][0], r["pc"])].append(r) cand = [(a, b) for g in blocks.values() for i, a in enumerate(g) for b in g[i + 1:]] true_pairs = args.duplicates found_true = sum(1 for a, b in cand if a["id"] == b["id"]) print(f"{n:,} records: {all_pairs:,} pairs to compare without blocking; {len(cand):,} with blocking " f"({all_pairs / max(1, len(cand)):,.0f}x fewer)") print(f"blocking keeps {found_true:,} of {true_pairs:,} true duplicate pairs: recall {found_true / true_pairs:.1%}") def sim(a, b): return SequenceMatcher(None, f"{a['first']} {a['last']}", f"{b['first']} {b['last']}").ratio() scored = [(sim(a, b), a["id"] == b["id"]) for a, b in cand] rows = [] for t in [0.70, 0.75, 0.80, 0.85, 0.90, 0.95]: false_merges = sum(1 for s, same in scored if s >= t and not same) missed = true_pairs - sum(1 for s, same in scored if s >= t and same) rows.append({"threshold": t, "false_merges": false_merges, "missed": missed}) print(f"threshold {t:.2f}: {false_merges:>5} false merges {missed:>5} missed duplicates") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"thresholds": rows, "summary": [{"records": n, "all_pairs": all_pairs, "blocked_pairs": len(cand), "reduction": all_pairs / max(1, len(cand)), "recall_pct": found_true / true_pairs * 100}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run entity-resolution-blocking-and-the-threshold.py uv run entity-resolution-blocking-and-the-threshold.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --customers20,000 | Distinct customers. |
| --duplicates2000 | Copies with typos. |
| --postcodes400 | Distinct postcodes. |
| --postcode-typo0.05 | Share of copies whose postcode is also mistyped. |
| --seed0 | Random seed. |
Our run
Data table
| Similarity threshold | False merges | Missed duplicates |
|---|---|---|
| 0.7 | 52 | 104 |
| 0.75 | 14 | 104 |
| 0.8 | 1 | 104 |
| 0.85 | 0 | 104 |
| 0.9 | 0 | 123 |
| 0.95 | 0 | 1,160 |
22,000 records: 241,989,000 pairs to compare without blocking; 25,553 with blocking (9,470x fewer)
blocking keeps 1,896 of 2,000 true duplicate pairs: recall 94.8%
threshold 0.70: 52 false merges 104 missed duplicates
threshold 0.75: 14 false merges 104 missed duplicates
threshold 0.80: 1 false merges 104 missed duplicates
threshold 0.85: 0 false merges 104 missed duplicates
threshold 0.90: 0 false merges 123 missed duplicates
threshold 0.95: 0 false merges 1161 missed duplicatesWhat we found
Without blocking, 22K records make 242M pairs. Blocking on surname initial plus postcode left 25.6K, 9,470× fewer, and kept 94.8% of the true duplicate pairs; the ones it lost are mostly the copies with a mistyped postcode, which no threshold can recover. Inside the blocks, the threshold traded the two harms as expected: at 0.70 it made 52 false merges, at 0.85 none, and at 0.95 it missed 1,160 duplicates.
Read it
Note that the threshold sets the exchange rate between the two harms.
Which harm is worse is a business decision, not a technical one: merging two patients' records is far worse than leaving a duplicate customer, and the threshold should be set from that judgement and written down next to it. Blocking caps recall before the threshold is even applied, so measure it separately; real systems use several blocking keys (or approximate methods such as MinHash) so that one typo cannot hide a duplicate.