How many rows must you sample, and what does blocking cost?
Simulate a modelNeeds Python with NumPy
Problem
Sampling finds a defect only if the sample happens to include a defective row. For a defect rate p, a sample of n rows contains at least one with probability 1 − (1 − p)ⁿ, and finding it 95% of the time takes about n ≈ 3/p rows (the rule of three). Rare defects therefore need large samples, and the rarest are often the costliest. The same "you cannot find what you never compare" applies to duplicate search: blocking decides which pairs are ever compared, and its keys cap recall.
Part (a): create 1,000,000 rows with a defect rate p of 10⁻³, 10⁻⁴, 10⁻⁵. For sample sizes 300, 3,000, 30,000, 300,000, run 1,000 trials and measure the fraction of trials that find at least one defect. Compare with 1 − (1 − p)ⁿ and with the rule n ≈ 3.0 ÷ p for 95%. Part (b): take 100,000 synthetic customer names with typos in 3% of the postcodes. Block by postcode, then by postcode plus a second key (phone prefix). Measure the recall of true duplicate pairs and the number of candidate pairs for each blocking plan.
The question. How often does a sample of each size find a defect at each rate, and how do recall and candidate pairs change when blocking adds a second key?
Predict
The measured detection rates follow 1 − (1 − p)ⁿ, and n ≈ 3/p gives about 95%. You should see that recall is capped by the blocking key, and that each extra pass raises both recall and the candidate count.
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
how-many-rows-must-you-sample.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """How many rows must you sample? (a) 1,000,000 rows with a defect rate p; for sample sizes from 300 to 300,000, the share of 1,000 trials that find at least one defect, against 1 - (1 - p)^n and the rule n = 3 / p for 95%. (b) 100,000 customers with 3% postcode typos, blocked by postcode, then by postcode or phone prefix. """ import argparse, json, random from collections import defaultdict import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=1_000_000, help="Rows in the table.") p.add_argument("--rates", type=lambda s: [float(v) for v in s.split(",")], default=[1e-3, 1e-4, 1e-5], help="Defect rates p.") p.add_argument("--samples", type=lambda s: [int(v) for v in s.split(",")], default=[300, 3000, 30000, 300000], help="Sample sizes n.") p.add_argument("--trials", type=int, default=1000, help="Trials per (p, n).") p.add_argument("--customers", type=int, default=100_000, help="Part (b): customers.") p.add_argument("--typo", type=float, default=0.03, help="Part (b): share of duplicates with a postcode typo.") 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() rng = np.random.default_rng(args.seed); random.seed(args.seed) rows = [] for rate in args.rates: defects = rng.random(args.rows) < rate for n in args.samples: found = np.mean([defects[rng.integers(0, args.rows, n)].any() for _ in range(args.trials)]) rows.append({"p": rate, "n": n, "found_pct": float(found * 100), "formula_pct": (1 - (1 - rate) ** n) * 100}) print(f"p = {rate:g}: " + " ".join(f"n={r['n']:,}: {r['found_pct']:5.1f}% (formula {r['formula_pct']:5.1f}%)" for r in rows if r["p"] == rate) + f" rule 3/p = {3 / rate:,.0f}") # (b) blocking caps recall people = [{"id": i, "pc": f"{random.randrange(5000):04d}", "phone": f"{random.randrange(1000):03d}"} for i in range(args.customers)] dups = [] for r in random.sample(people, args.customers // 10): pc = r["pc"] if random.random() > args.typo else f"{random.randrange(5000):04d}" dups.append({"id": r["id"], "pc": pc, "phone": r["phone"]}) recs = people + dups plans = {"postcode": [("pc",)], "postcode, then phone prefix": [("pc",), ("phone",)]} blocking = [] for name, passes in plans.items(): cand = set() for keys in passes: groups = defaultdict(list) for i, r in enumerate(recs): groups[tuple(r[k] for k in keys)].append(i) for g in groups.values(): for a in range(len(g)): for b in range(a + 1, len(g)): cand.add((g[a], g[b])) true = sum(1 for a, b in cand if recs[a]["id"] == recs[b]["id"]) blocking.append({"plan": name, "recall_pct": true / len(dups) * 100, "candidate_pairs": len(cand)}) print(f"blocking by {name:<28} recall {true / len(dups):6.1%} candidate pairs {len(cand):,}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"sampling": rows, "blocking": blocking}}, 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 how-many-rows-must-you-sample.py uv run how-many-rows-must-you-sample.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1,000,000 | Rows in the table. |
| --rates0.001, 0.0001, 1e-05 | Defect rates p. |
| --samples300, 3000, 30,000, 300,000 | Sample sizes n. |
| --trials1000 | Trials per (p, n). |
| --customers100,000 | Part (b): customers. |
| --typo0.03 | Part (b): share of duplicates with a postcode typo. |
| --seed0 | Random seed. |
Our run
Share of 1,000 trials that found a defect
| Defect rate p | Sample n | Found | 1 − (1 − p)ⁿ |
|---|---|---|---|
| 0.001 | 300 | 26.2% | 25.9% |
| 0.001 | 3K | 94.3% | 95% |
| 0.001 | 30K | 100% | 100% |
| 0.001 | 300K | 100% | 100% |
| 0 | 300 | 4.2% | 2.96% |
| 0 | 3K | 29.2% | 25.9% |
| 0 | 30K | 96.3% | 95% |
| 0 | 300K | 100% | 100% |
| 0 | 300 | 0% | 0.3% |
| 0 | 3K | 1.6% | 2.96% |
| 0 | 30K | 19% | 25.9% |
| 0 | 300K | 87.9% | 95% |
Duplicate search on 100,000 customers
| Blocking | Recall | Candidate pairs |
|---|---|---|
| postcode | 96.9% | 1.22M |
| postcode, then phone prefix | 100% | 7.26M |
p = 0.001: n=300: 26.2% (formula 25.9%) n=3,000: 94.3% (formula 95.0%) n=30,000: 100.0% (formula 100.0%) n=300,000: 100.0% (formula 100.0%) rule 3/p = 3,000
p = 0.0001: n=300: 4.2% (formula 3.0%) n=3,000: 29.2% (formula 25.9%) n=30,000: 96.3% (formula 95.0%) n=300,000: 100.0% (formula 100.0%) rule 3/p = 30,000
p = 1e-05: n=300: 0.0% (formula 0.3%) n=3,000: 1.6% (formula 3.0%) n=30,000: 19.0% (formula 25.9%) n=300,000: 87.9% (formula 95.0%) rule 3/p = 300,000
blocking by postcode recall 96.9% candidate pairs 1,217,865
blocking by postcode, then phone prefix recall 100.0% candidate pairs 7,262,836What we found
Detection followed the formula: at p = 10⁻³ a sample of 3,000 (= 3/p) found a defect 94.3% of the time, at p = 10⁻⁴ a sample of 30,000 did 96.3%, and at p = 10⁻⁵ the 300,000-row sample managed 87.9%, below the formula's 95% because a million rows at that rate hold only about ten defects. Blocking by postcode alone reached a recall of 96.9%; adding a pass on phone prefix raised it to 100%, and raised the candidate pairs from 1.22M to 7.26M, as expected.
Read it
Sampling finds defects at a rate of about 1 in n, and severity usually grows as 1 over prevalence, so sample by exposure, not by row: stratify along whatever defects correlate with, and verify the few rows that hold most of the value. For duplicate search, run several blocking passes on independent keys, so that one typo cannot hide a pair.