Find five bad rows among a million with a Merkle tree
Simulate a modelNeeds Python
Problem
Comparing two copies of a large table row by row means moving one of them across the network. A Merkle tree compares them by hashes: leaves are hashes of key-sorted rows, each parent hashes its children, and two sides compare roots, then only the children whose hashes differ, down to the rows that changed. The cost grows with the number of differences, not with the table. The same idea needs care at the top: combining row hashes into one table hash must not depend on order, and XOR is order-independent but blind to rows added in identical pairs.
Build two lists of 1,000,000 (key, row_text) pairs. Change five rows in the second list. Build a Merkle tree with a branching factor of 16 over key-sorted rows. Compare the two trees from the root down, and count how many hashes are exchanged. Then do a second experiment: add every row twice to one table, and compare the table hash using XOR versus addition modulo 2⁶⁴.
The question. How many hashes must two sides exchange to find 5 changed rows among a million, and which table hash notices that every row was duplicated?
Predict
Expect a few hundred hashes, against 10⁶ rows moved by a full diff. The XOR hash will be 0 (the same as an empty table); the addition hash will differ.
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
find-five-bad-rows-among-a.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Find five bad rows among a million with a Merkle tree. Two lists of 1,000,000 (key, row) pairs, five rows changed in the second. A Merkle tree with branching factor 16 over key-sorted rows; compare from the root down and count the hashes exchanged. Then a table hash of every row added twice: XOR against addition modulo 2^64. """ import argparse, hashlib, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=1_000_000, help="Rows per side.") p.add_argument("--changed", type=int, default=5, help="Rows changed on one side.") p.add_argument("--fanout", type=int, default=16, help="Branching factor of the tree.") 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) H = lambda b: hashlib.blake2b(b, digest_size=8).digest() left = [f"{k:08d}|row {k} amount {k % 997}".encode() for k in range(args.rows)] right = list(left) bad = sorted(random.sample(range(args.rows), args.changed)) for k in bad: right[k] = right[k] + b" (edited)" def tree(rows): levels = [[H(r) for r in rows]] while len(levels[-1]) > 1: lv = levels[-1] levels.append([H(b"".join(lv[i:i + args.fanout])) for i in range(0, len(lv), args.fanout)]) return levels # levels[-1] is the root L, R = tree(left), tree(right) exchanged, frontier = 1, [0] # compare the roots first for depth in range(len(L) - 1, 0, -1): nxt = [] for i in frontier: if L[depth][i] == R[depth][i]: continue kids = range(i * args.fanout, min((i + 1) * args.fanout, len(L[depth - 1]))) exchanged += len(kids); nxt += [k for k in kids if L[depth - 1][k] != R[depth - 1][k]] frontier = nxt found = sorted(frontier) print(f"{args.rows:,} rows, {args.changed} changed: found rows {found} after exchanging {exchanged:,} hashes " f"(a full diff moves {args.rows:,} rows)") h64 = lambda r: int.from_bytes(H(r), "big") sample = left[:1000] xor = 0 for r in sample + sample: xor ^= h64(r) add_twice = sum(h64(r) for r in sample + sample) % (1 << 64); add_once = sum(h64(r) for r in sample) % (1 << 64) print(f"every row twice: XOR table hash = {xor} (same as an empty table); " f"addition mod 2^64 differs from the single copy: {add_twice != add_once}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"summary": [{"rows": args.rows, "changed": args.changed, "found": len(found), "exchanged": exchanged, "xor_twice": xor, "sum_differs": "yes" if add_twice != add_once else "no"}]}}, 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 find-five-bad-rows-among-a.py uv run find-five-bad-rows-among-a.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1,000,000 | Rows per side. |
| --changed5 | Rows changed on one side. |
| --fanout16 | Branching factor of the tree. |
| --seed0 | Random seed. |
Our run
Two copies of a million rows
| Rows changed | Rows found | Hashes exchanged | XOR hash, every row twice | Sum hash notices |
|---|---|---|---|---|
| 5 | 5 | 321 | 0 | yes |
1,000,000 rows, 5 changed: found rows [403958, 441001, 794772, 885440, 933488] after exchanging 321 hashes (a full diff moves 1,000,000 rows)
every row twice: XOR table hash = 0 (same as an empty table); addition mod 2^64 differs from the single copy: TrueWhat we found
The Merkle comparison found all 5 changed rows after exchanging 321 hashes, against the 1M rows a full diff would move, as expected. With every row added twice, the XOR table hash was 0, the same as an empty table; addition modulo 2⁶⁴ noticed the difference (yes).
Read it
This is exactly the anti-entropy machinery used to repair replicas in distributed databases. For the combiner, use the sum of row hashes modulo 2⁶⁴, not XOR: both ignore order, which the two sides cannot agree on, but only the sum sees a duplicated pair. Normalise values before hashing (number formats, time zones, trailing spaces), or the two sides differ everywhere.