Skip to content
About
Main content

Digest collisions and Bloom-filter data loss

Simulate a modelNeeds Python

Problem

Two error directions. A digest used as an identity collides with probability about 1 − exp(−n²/2^(b+1)) for n items and b bits: rare for 256 bits, likely for 32 bits at 100,000 items. A errs only one way, "maybe present" for something absent; used as a dedup ("drop if the filter says seen") it silently drops distinct rows. Used as a ("definitely new" if the filter says absent, check an exact set otherwise), it never drops anything and only saves lookups.

Part A: verify the birthday formula by shrinking SHA-256 to 32 bits, with n = 100,000 over 200 runs. Part B: build a Bloom filter (8 million bits, 4 hashes) and use it for dedup of 1,000,000 distinct keys with no real duplicates; count how many rows are dropped. Then use it as a negative oracle with an exact set as fallback, and count again.

The question. Does the measured collision rate match the formula, and how many distinct rows does each use of the Bloom filter drop?


Predict

Used naively as a dedup, what share of a million distinct keys does the Bloom filter drop?

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.

    digest-collisions-and-bloom-filter-data.py66 linesDownload
    View the code
    digest-collisions-and-bloom-filter-data.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Digest collisions, and a Bloom filter used for data.
    
    Part A: the birthday formula, by shrinking SHA-256 to 32 bits: the share of
    200 runs of 100,000 random digests that contain a collision, against
    1 − exp(−n²/2^33). Part B: a Bloom filter (8 million bits, 4 hashes) used to
    drop "duplicates" from 1,000,000 distinct keys, and the same filter used as a
    negative oracle in front of an exact set.
    """
    import argparse, hashlib, json, math, random
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--n", type=int, default=100_000, help="Part A: digests per run.")
    p.add_argument("--bits", type=int, default=32, help="Part A: digest width.")
    p.add_argument("--runs", type=int, default=200, help="Part A: runs.")
    p.add_argument("--keys", type=int, default=1_000_000, help="Part B: distinct keys.")
    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)
    
    def collides(n, bits):
        seen = set()
        for _ in range(n):
            d = int.from_bytes(hashlib.sha256(random.randbytes(16)).digest(), "big") >> (256 - bits)
            if d in seen: return True
            seen.add(d)
        return False
    measured = sum(collides(args.n, args.bits) for _ in range(args.runs)) / args.runs
    formula = 1 - math.exp(-args.n * args.n / 2 ** (args.bits + 1))
    
    class Bloom:
        def __init__(self, m_bits, k): self.m, self.k, self.bits = m_bits, k, bytearray(m_bits // 8 + 1)
        def _idx(self, key):
            h = hashlib.sha256(key.encode()).digest()
            return [int.from_bytes(h[4 * i:4 * i + 4], "big") % self.m for i in range(self.k)]
        def add(self, key):
            for i in self._idx(key): self.bits[i // 8] |= 1 << (i % 8)
        def might(self, key): return all(self.bits[i // 8] >> (i % 8) & 1 for i in self._idx(key))
    b, kept_naive = Bloom(8_000_000, 4), 0
    for i in range(args.keys):
        key = f"event-{i}"
        if not b.might(key): b.add(key); kept_naive += 1
    fill = sum(bin(x).count("1") for x in b.bits) / b.m
    b2, exact, kept_oracle, lookups = Bloom(8_000_000, 4), set(), 0, 0
    for i in range(args.keys):
        key = f"event-{i}"
        if b2.might(key):
            lookups += 1
            if key in exact: continue                                       # only the exact set may say "seen"
        b2.add(key); exact.add(key); kept_oracle += 1
    summary = {"collision_measured": measured, "collision_formula": formula, "kept_naive": kept_naive, "lost_naive": args.keys - kept_naive,
               "lost_pct": (args.keys - kept_naive) / args.keys * 100, "fill_pct": fill * 100, "final_fp_pct": fill ** 4 * 100,
               "kept_oracle": kept_oracle, "exact_lookups": lookups}
    print(f"A: {args.bits}-bit digests, n = {args.n:,}: collision in {measured:.0%} of {args.runs} runs (formula {formula:.0%})")
    print(f"B: Bloom filter as the dedup: kept {kept_naive:,} of {args.keys:,} distinct keys (lost {summary['lost_pct']:.2f}%); filter {fill:.0%} full, final false-positive rate {summary['final_fp_pct']:.1f}%")
    print(f"   Bloom filter in front of an exact set: kept {kept_oracle:,}; the exact set was consulted {lookups:,} times")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary}, 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 digest-collisions-and-bloom-filter-data.py
    uv run digest-collisions-and-bloom-filter-data.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n100,000 Part A: digests per run.
--bits32 Part A: digest width.
--runs200 Part A: runs.
--keys1,000,000 Part B: distinct keys.
--seed0 Random seed.

Our run

Reference runApple M2 · 8 cores · 8 GB
Output · uv run digest-collisions-and-bloom-filter-data.py
A: 32-bit digests, n = 100,000: collision in 64% of 200 runs (formula 69%)
B: Bloom filter as the dedup: kept 994,458 of 1,000,000 distinct keys (lost 0.55%); filter 39% full, final false-positive rate 2.4%
   Bloom filter in front of an exact set: kept 1,000,000; the exact set was consulted 5,542 times
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

32-bit digests collided in 0.645 of the runs against a formula value of 0.688. Used as a dedup, the Bloom filter kept 994K of a million distinct keys, dropping 0.554%; it ended 39.4% full with a final false-positive rate of 2.4%. As a negative oracle in front of an exact set, it kept 1M, and the exact set was consulted only 5.54K times.


Read it

This is the whole argument of the "error direction" rule: a structure that can be wrong must be wrong in the direction that costs only time. A Bloom filter may say "maybe" wrongly, so it can only ever save work, never decide to discard data.