Skip to content
About
Main content

Entity resolution: blocking and the threshold

Simulate a modelNeeds Python

Problem

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. 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

Blocking on surname initial plus postcode: what share of the true duplicate pairs does it keep?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    entity-resolution-blocking-and-the-threshold.py65 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

False merges and missed duplicates by thresholdApple M2 · 8 cores · 8 GB
False mergesMissed duplicates
Data table
False merges and missed duplicates by threshold
Similarity thresholdFalse mergesMissed duplicates
0.752104
0.7514104
0.81104
0.850104
0.90123
0.9501,160
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run entity-resolution-blocking-and-the-threshold.py
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 duplicates

What 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.