Skip to content
About
Main content

Bloom filter: measure the false-positive curve and the cache cost

Measure your machineNeeds Python with NumPy

Problem

A answers "is k in the set?" with "no" or "probably". It sets k bits per inserted key in an array of m bits; a lookup checks the same k bits. A key that was never inserted is reported present when all its bits happen to be set by other keys, at a rate of about (1 − e^(−kn/m))^k, which with the best k = (m/n) ln 2 depends only on bits per key. Each lookup touches k random bits, so a filter much larger than the CPU cache costs k cache misses per lookup; a blocked filter puts all k bits of a key in one 64-byte block, paying one miss and a slightly worse false-positive rate.

Insert 1,000,000 random keys with m/n = 5, 8, 10, 14.4 and 20 bits per key, and k = round((m/n) · ln 2). For each setting, test keys that were not inserted and count the fraction reported as present. Then time 10⁷ lookups with a filter of 10 MB and a filter of 1 GB, and with a blocked 1 GB filter.

The question. Does the false-positive rate follow the formula, and how much slower is a lookup once the filter no longer fits in cache?


Predict

How does a lookup in a 1 GB Bloom filter compare with one in a 10 MB filter?

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.

    bloom-filter-measure-the-false-positive.py72 linesDownload
    View the code
    bloom-filter-measure-the-false-positive.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Bloom filter: measure the false-positive rate, and the cost of size.
    
    1,000,000 keys at 5 to 20 bits per key with k = round(m/n ln 2); the
    false-positive rate on 1,000,000 absent keys. Then 10^7 lookups in a 10 MB
    and a 1 GB filter (NumPy), and in a blocked filter where all k bits of a key
    sit in one 64-byte block.
    """
    import argparse, json, time
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--keys", type=int, default=1_000_000, help="Keys inserted.")
    p.add_argument("--bits", type=lambda s: [float(v) for v in s.split(",")], default=[5, 8, 10, 14.4, 20], help="Bits per key.")
    p.add_argument("--lookups", type=int, default=10_000_000, help="Lookups for the timing part.")
    p.add_argument("--big-mb", type=int, default=1024, help="Size of the large filter, MB.")
    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)
    M1, M2 = np.uint64(0x9E3779B97F4A7C15), np.uint64(0xC2B2AE3D27D4EB4F)
    
    def hashes(keys):
        h1 = (keys * M1) ^ (keys >> np.uint64(29)); h2 = (keys * M2) | np.uint64(1)
        return h1, h2
    
    def positions(keys, k, m):
        h1, h2 = hashes(keys)
        return [((h1 + np.uint64(i) * h2) % np.uint64(m)).astype(np.int64) for i in range(k)]
    
    ins = rng.integers(1, 2**62, args.keys, dtype=np.uint64); absent = rng.integers(2**62, 2**63, args.keys, dtype=np.uint64)
    rows = []
    for b in args.bits:
        m = int(b * args.keys); k = max(1, round(b * np.log(2)))
        bits = np.zeros(m, dtype=bool)
        for pos in positions(ins, k, m): bits[pos] = True
        present = np.ones(args.keys, dtype=bool)
        for pos in positions(absent, k, m): present &= bits[pos]
        fp = float(present.mean())
        rows.append({"bits_per_key": b, "k": k, "fp_pct": fp * 100, "formula_pct": (1 - np.exp(-k / b)) ** k * 100})
        print(f"{b:5.1f} bits/key, k = {k:2d}: false positives {fp:7.3%}   (formula {rows[-1]['formula_pct']:6.3f}%)")
    
    timing = []
    q = rng.integers(1, 2**62, args.lookups, dtype=np.uint64)
    for label, mb, blocked in (("10 MB filter", 10, False), (f"{args.big_mb:,} MB filter", args.big_mb, False),
                               (f"{args.big_mb:,} MB blocked filter", args.big_mb, True)):
        m = mb * 8 * 2**20; bits = np.zeros(m // 8, dtype=np.uint8); k = 7
        h1, h2 = hashes(q)
        t = time.perf_counter()
        ok = np.ones(len(q), dtype=bool)
        if blocked:
            block = (h1 % np.uint64(m // 512)).astype(np.int64) * 512           # 64-byte block = 512 bits
            for i in range(k):
                pos = block + ((h2 >> np.uint64(9 * i)) % np.uint64(512)).astype(np.int64)
                ok &= (bits[pos >> 3] >> (pos & 7).astype(np.uint8)) & 1 == 1
        else:
            for i in range(k):
                pos = ((h1 + np.uint64(i) * h2) % np.uint64(m)).astype(np.int64)
                ok &= (bits[pos >> 3] >> (pos & 7).astype(np.uint8)) & 1 == 1
        dt = time.perf_counter() - t
        timing.append({"filter": label, "ns_per_lookup": dt / len(q) * 1e9}); print(f"{label:<26} {dt / len(q) * 1e9:6.1f} ns per lookup")
        del bits
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"rates": rows, "timing": timing}}, 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 bloom-filter-measure-the-false-positive.py
    uv run bloom-filter-measure-the-false-positive.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--keys1,000,000 Keys inserted.
--bits5, 8, 10, 14.4, 20 Bits per key.
--lookups10,000,000 Lookups for the timing part.
--big-mb1024 Size of the large filter, MB.
--seed0 Random seed.

Our run

False-positive rate against bits per keyApple M2 · 8 cores · 8 GB
MeasuredFormula
Data table
False-positive rate against bits per key
Bits per keyMeasuredFormula
59.219.18
82.142.16
100.8120.819
14.40.1020.099
200.0050.007
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

False positives

False positives
Bits per keykMeasuredFormula
539.21%9.18%
862.14%2.16%
1070.812%0.819%
14.4100.102%0.099%
20140.005%0.007%

Lookup time

Lookup time
Filterns per lookup
10 MB filter34.8
1,024 MB filter116
1,024 MB blocked filter67.2
Output · uv run bloom-filter-measure-the-false-positive.py
  5.0 bits/key, k =  3: false positives  9.212%   (formula  9.185%)
  8.0 bits/key, k =  6: false positives  2.136%   (formula  2.158%)
 10.0 bits/key, k =  7: false positives  0.812%   (formula  0.819%)
 14.4 bits/key, k = 10: false positives  0.102%   (formula  0.099%)
 20.0 bits/key, k = 14: false positives  0.005%   (formula  0.007%)
10 MB filter                 34.8 ns per lookup
1,024 MB filter             115.6 ns per lookup
1,024 MB blocked filter      67.2 ns per lookup

What we found

The measured rates sat on the formula: 9.21% at 5 bits, 0.812% at 10 bits and 0.102% at 14.4 bits, each 4.8 bits dividing the rate by about ten. A lookup in the 10 MB filter cost 34.8 ns and in the 1 GB filter 116 ns, several times slower; the blocked 1 GB filter brought it down to 67.2 ns.


Read it

The false-positive rate is set by bits per key alone, so a filter is sized from the error you can afford, not from the data size. Its speed is set by where those bits live. A filter that fits in cache costs a few nanoseconds; one that does not costs a cache miss per probed bit, and a blocked layout buys most of that back by keeping one key's bits on one line.

On a large filter the bottleneck is memory , which no tuning will fix.