Negative cache and Bloom filter
Measure your machineNeeds Python
Problem
A cache 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 negative cache remembers absences. A Bloom filter 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.
- 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.
- Add a Bloom filter (use the
pybloom_livepackage 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). - 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
About 1% of absent lookups (about 1,000) pass the filter and reach the database. Total time falls by roughly two orders of magnitude. The filter never reports a present ID as absent.
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
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)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
| Knob · default | What 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
| Bits per key | k | Reached the database | Share | Database only | Filter first | False negatives |
|---|---|---|---|---|---|---|
| 9.59 | 7 | 1,020 | 1.02% | 484 ms | 145 ms | 0 |
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: TrueWhat 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.