Skip to content
About
Main content

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 compares them by : 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 to find 5 changed rows among a million, and which table hash notices that every row was duplicated?


Predict

How many hashes must the two sides exchange to find 5 changed rows among a million?

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.

    find-five-bad-rows-among-a.py62 linesDownload
    View 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)
    
  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 find-five-bad-rows-among-a.py
    uv run find-five-bad-rows-among-a.py --help     # every knob
Knobs
Knob · defaultWhat 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

Two copies of a million rows
Rows changedRows foundHashes exchangedXOR hash, every row twiceSum hash notices
553210yes
Reference runApple M2 · 8 cores · 8 GB
Output · uv run find-five-bad-rows-among-a.py
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: True
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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