Skip to content
About
Main content

Negative cache and Bloom filter

Measure your machineNeeds Python

Problem

A stores answers to keys that exist. Lookups for keys that do not exist (a mistyped ID, a deleted article, a scraper probing random IDs) miss every time and go to the database, which then does the work of proving the key is absent. A remembers absences. A does it compactly: a bit array and k hash functions that answer "definitely absent" or "maybe present", with a false-positive rate set by the bits per key and never a false negative.

Measure the effect of absent-key lookups and the false-positive rate of a Bloom filter.

  1. Create a table of 1,000,000 article IDs. Send 100,000 lookups for IDs that do not exist, in a loop, and time them against the database.
  2. Add a Bloom filter (use the pybloom_live package or write one with a bit array and k hash functions) loaded with all valid IDs, sized for a 1% false-positive rate (about 9.6 bits per key and k = 7).
  3. Query the filter first for the same 100,000 absent IDs. Count how many reach the database. Then insert one new ID into both the table and the filter and confirm that it is found.

The script writes its own filter and uses SQLite, in-process, as the database.

The question. How many absent lookups get past a 1% Bloom filter, and how much time does the filter save?


Predict

How much faster does the 1% Bloom filter make the absent lookups?

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.

    negative-cache-and-bloom-filter.py93 linesDownload
    View the code
    negative-cache-and-bloom-filter.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Negative cache and Bloom filter.
    
    Lookups for IDs that do not exist, sent straight to SQLite, then through a
    Bloom filter loaded with every valid ID and sized for a 1% false-positive rate.
    """
    import argparse, hashlib, json, math, os, random, sqlite3, tempfile, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--ids", type=int, default=1_000_000, help="Valid article IDs in the table.")
    p.add_argument("--lookups", type=int, default=100_000, help="Lookups for IDs that do not exist.")
    p.add_argument("--fp-rate", type=float, default=0.01, help="Target false-positive rate of the Bloom filter.")
    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)
    
    # Valid IDs are even numbers; absent IDs are odd, so they are certain to be missing
    valid = [2 * i for i in range(args.ids)]
    absent = [2 * random.randrange(args.ids * 4) + 1 for _ in range(args.lookups)]
    
    path = os.path.join(tempfile.gettempdir(), "bloom-lab.db")
    import atexit; atexit.register(lambda: os.path.exists(path) and os.remove(path))   # leave nothing behind
    if os.path.exists(path): os.remove(path)
    db = sqlite3.connect(path)
    db.execute("CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT)")
    db.executemany("INSERT INTO articles VALUES (?, ?)", ((i, f"article {i}") for i in valid))
    db.commit()
    db_queries = 0
    def lookup_db(i):
        global db_queries
        db_queries += 1
        return db.execute("SELECT title FROM articles WHERE id = ?", (i,)).fetchone()
    
    
    class Bloom:
        """m bits, k hash functions from two halves of one digest (double hashing)."""
        def __init__(self, n, fp):
            self.m = math.ceil(-n * math.log(fp) / math.log(2) ** 2)   # ≈ 9.6 bits per key at 1%
            self.k = round(self.m / n * math.log(2))                     # ≈ 7 at 1%
            self.bits = bytearray((self.m + 7) // 8)
        def _idx(self, x):
            d = hashlib.blake2b(str(x).encode(), digest_size=16).digest()
            a, b = int.from_bytes(d[:8], "little"), int.from_bytes(d[8:], "little")
            return ((a + i * b) % self.m for i in range(self.k))
        def add(self, x):
            for j in self._idx(x): self.bits[j >> 3] |= 1 << (j & 7)
        def __contains__(self, x):
            return all(self.bits[j >> 3] >> (j & 7) & 1 for j in self._idx(x))
    
    
    # 1. Absent lookups straight to the database
    t = time.perf_counter()
    for i in absent: lookup_db(i)
    t_db = time.perf_counter() - t
    
    # 2. A Bloom filter loaded with all valid IDs
    bloom = Bloom(args.ids, args.fp_rate)
    for i in valid: bloom.add(i)
    
    # 3. The same absent lookups, filter first
    db_queries = 0
    t = time.perf_counter()
    for i in absent:
        if i in bloom: lookup_db(i)
    t_bloom = time.perf_counter() - t
    passed = db_queries
    
    # No false negatives: every valid ID in a sample is reported present; a new ID is found once added to both
    sample = random.sample(valid, 100_000)
    false_negatives = sum(1 for i in sample if i not in bloom)
    new_id = 2 * args.ids
    db.execute("INSERT INTO articles VALUES (?, ?)", (new_id, "a new article")); db.commit(); bloom.add(new_id)
    found_new = new_id in bloom and lookup_db(new_id) is not None
    
    print(f"Bloom filter: {bloom.m / args.ids:.1f} bits per key, k = {bloom.k}, {bloom.m / 8 / 1e6:.2f} MB")
    print(f"absent lookups straight to the database: {args.lookups:,} queries in {t_db:.2f} s")
    print(f"through the filter: {passed:,} reached the database ({passed / args.lookups:.2%}) in {t_bloom:.2f} s "
          f"-> {t_db / t_bloom:.0f}x less time")
    print(f"false negatives among {len(sample):,} valid IDs: {false_negatives}   new ID found after insert: {found_new}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"summary": [{
                "bits_per_key": bloom.m / args.ids, "k": bloom.k, "filter_mb": bloom.m / 8 / 1e6,
                "db_seconds": t_db, "bloom_seconds": t_bloom, "speedup": t_db / t_bloom, "passed": passed,
                "passed_pct": passed / args.lookups * 100, "false_negatives": false_negatives,
                "found_new": "yes" if found_new 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 negative-cache-and-bloom-filter.py
    uv run negative-cache-and-bloom-filter.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--ids1,000,000 Valid article IDs in the table.
--lookups100,000 Lookups for IDs that do not exist.
--fp-rate0.01 Target false-positive rate of the Bloom filter.
--seed0 Random seed.

Our run

Absent lookups, with and without the filter

Absent lookups, with and without the filter
Bits per keykReached the databaseShareDatabase onlyFilter firstFalse negatives
9.5971,0201.02%484 ms145 ms0
Reference runApple M2 · 8 cores · 8 GB
Output · uv run negative-cache-and-bloom-filter.py
Bloom filter: 9.6 bits per key, k = 7, 1.20 MB
absent lookups straight to the database: 100,000 queries in 0.48 s
through the filter: 1,021 reached the database (1.02%) in 0.14 s -> 3x less time
false negatives among 100,000 valid IDs: 0   new ID found after insert: True
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

The filter used 9.59 bits per key with k = 7, 1.2 MB for a million IDs. It let 1,020 of the 100,000 absent lookups through (1.02%), as designed, and reported no valid ID as absent (0 false negatives). The new ID was found after it was added to both: yes.

The time saved was much smaller than expected: the filter made the absent lookups only 3.34× faster (484 ms against 145 ms). The expectation assumes a database across a network, where each lookup costs a round trip of a millisecond or so. Here SQLite runs inside the same process and answers an absent primary-key lookup in a few microseconds, about the cost of the filter's own hashing in Python. The saving is the cost of one database lookup times 99% of absent lookups, so it is only as large as that cost.


Read it

A negative cache moves the cost of absence from the database to a small structure in memory. A Bloom filter is the space-efficient form: about 10 bits per key for 1%, independent of how big the keys are, and it never lies about presence, so it is safe to put in front of the database.

Two things it cannot do. It cannot delete (a standard filter has no way to remove a key without risking false negatives for others), so deletes need a counting filter or a periodic rebuild. And it must be updated in the same path as the table, or a newly inserted ID will be reported absent: the last step checks exactly that.