Bloom Filter
Probabilistic set-membership structure: space-efficient, no false negatives, tunable false positive rate. Answers "definitely not present" or "maybe present" — never "definitely present."
Full Working Code
Both implementations below use the same trick: only two independent hashes (h1, h2) are ever actually computed per item. All k bit positions are derived from those two via double hashing (Kirsch-Mitzenmacher) instead of writing k separate hash functions.
graph LR
I["item bytes, e.g. 'apple'"] --> H1["h1 = FNV-1a hash"]
I --> H2["h2 = FNV hash"]
H1 --> D0["hash_0 = h1 + 0 * h2, mod m"]
H2 --> D0
H1 --> D1["hash_1 = h1 + 1 * h2, mod m"]
H2 --> D1
H1 --> D2["hash_2 = h1 + 2 * h2, mod m"]
H2 --> D2
D0 --> B0["set bit position 1 of k"]
D1 --> B1["set bit position 2 of k"]
D2 --> B2["set bit position 3 of k"]
Go uses fnv.New64a/fnv.New64 over a packed []uint64 bit array; Python uses two hashlib digests over a bytearray bit array (in production, prefer mmh3.hash64 with two seeds — faster, still well-distributed, see the code comment below).
package bloom
import (
"hash/fnv"
"math"
)
// Filter is a Bloom filter backed by a bit array and k independent hash
// functions (simulated via double hashing from two base hashes).
type Filter struct {
bits []uint64 // packed bit array, 64 bits per word
m uint // number of bits
k uint // number of hash functions
}
// NewFilter creates a Bloom filter sized for n expected elements at the
// given target false positive rate p (e.g. 0.01 for 1%).
func NewFilter(n uint, p float64) *Filter {
m := optimalM(n, p)
k := optimalK(m, n)
return &Filter{
bits: make([]uint64, (m+63)/64), // round up to whole words
m: m,
k: k,
}
}
// optimalM computes the number of bits needed: m = -(n * ln(p)) / (ln(2)^2)
func optimalM(n uint, p float64) uint {
m := -1 * float64(n) * math.Log(p) / (math.Ln2 * math.Ln2)
return uint(math.Ceil(m))
}
// optimalK computes the ideal number of hash functions: k = (m/n) * ln(2)
func optimalK(m, n uint) uint {
k := (float64(m) / float64(n)) * math.Ln2
if k < 1 {
return 1
}
return uint(math.Round(k))
}
// hashes returns two independent base hashes of data. All k hash functions
// are derived from these two via double hashing (Kirsch-Mitzenmacher),
// avoiding the need for k separate hash implementations.
func hashes(data []byte) (uint64, uint64) {
h1 := fnv.New64a()
h1.Write(data)
sum1 := h1.Sum64()
h2 := fnv.New64()
h2.Write(data)
sum2 := h2.Sum64()
return sum1, sum2
}
// Add inserts an element into the filter.
func (f *Filter) Add(data []byte) {
h1, h2 := hashes(data)
for i := uint(0); i < f.k; i++ {
pos := f.combine(h1, h2, i) % uint64(f.m)
f.setBit(pos)
}
}
// MightContain reports whether data may be in the set. False means
// definitely not present. True means present with probability (1 - false
// positive rate) — it might be a false positive.
func (f *Filter) MightContain(data []byte) bool {
h1, h2 := hashes(data)
for i := uint(0); i < f.k; i++ {
pos := f.combine(h1, h2, i) % uint64(f.m)
if !f.getBit(pos) {
return false
}
}
return true
}
// combine implements double hashing: hash_i(x) = h1(x) + i*h2(x)
func (f *Filter) combine(h1, h2 uint64, i uint) uint64 {
return h1 + uint64(i)*h2
}
func (f *Filter) setBit(pos uint64) {
word := pos / 64
bit := pos % 64
f.bits[word] |= 1 << bit
}
func (f *Filter) getBit(pos uint64) bool {
word := pos / 64
bit := pos % 64
return f.bits[word]&(1<<bit) != 0
}
// EstimatedFalsePositiveRate returns the current theoretical false positive
// rate given m, k, and how many elements n have actually been inserted so
// far (tracked externally by the caller, since the filter itself doesn't
// count insertions — duplicate Adds don't increase the "true" n).
func (f *Filter) EstimatedFalsePositiveRate(n uint) float64 {
exp := -float64(f.k) * float64(n) / float64(f.m)
return math.Pow(1-math.Exp(exp), float64(f.k))
}
from __future__ import annotations
import hashlib
import math
class BloomFilter:
"""Probabilistic set-membership structure backed by a bit array and k
independent hash functions (simulated via double hashing from two base
hashes). Space-efficient, no false negatives, tunable false positive
rate. `might_contain()` answers "definitely not present" (False) or
"maybe present" (True) -- never "definitely present."
"""
def __init__(self, n: int, p: float) -> None:
"""Size a filter for `n` expected elements at target false positive
rate `p` (e.g. 0.01 for 1%)."""
self.m: int = self._optimal_m(n, p)
self.k: int = self._optimal_k(self.m, n)
self.bits = bytearray((self.m + 7) // 8) # 1 bit per slot, packed into bytes
@staticmethod
def _optimal_m(n: int, p: float) -> int:
"""Number of bits needed: m = -(n * ln(p)) / (ln(2)^2)"""
m = -1 * n * math.log(p) / (math.log(2) ** 2)
return math.ceil(m)
@staticmethod
def _optimal_k(m: int, n: int) -> int:
"""Ideal number of hash functions: k = (m/n) * ln(2)"""
k = (m / n) * math.log(2)
return max(1, round(k))
@staticmethod
def _hashes(data: bytes) -> tuple[int, int]:
"""Two independent base hashes of `data`. All k hash functions are
derived from these via double hashing (Kirsch-Mitzenmacher), avoiding
the need for k separate hash implementations. Two different digest
algorithms stand in as the independent bases here to keep this
dependency-free -- in a production system, prefer
`mmh3.hash64(data, seed=0)` / `mmh3.hash64(data, seed=1)`
(MurmurHash3), which is faster and just as well-distributed."""
h1 = int.from_bytes(hashlib.md5(data, usedforsecurity=False).digest()[:8], "big")
h2 = int.from_bytes(hashlib.sha1(data, usedforsecurity=False).digest()[:8], "big")
return h1, h2
def _combine(self, h1: int, h2: int, i: int) -> int:
"""Double hashing: hash_i(x) = h1(x) + i*h2(x)"""
return h1 + i * h2
def _set_bit(self, pos: int) -> None:
byte_index, bit_index = divmod(pos, 8)
self.bits[byte_index] |= 1 << bit_index
def _get_bit(self, pos: int) -> bool:
byte_index, bit_index = divmod(pos, 8)
return bool(self.bits[byte_index] & (1 << bit_index))
def add(self, data: bytes) -> None:
"""Insert an element into the filter."""
h1, h2 = self._hashes(data)
for i in range(self.k):
pos = self._combine(h1, h2, i) % self.m
self._set_bit(pos)
def might_contain(self, data: bytes) -> bool:
"""Return whether `data` may be in the set. False means definitely
not present. True means present with probability (1 - false positive
rate) -- it might be a false positive."""
h1, h2 = self._hashes(data)
for i in range(self.k):
pos = self._combine(h1, h2, i) % self.m
if not self._get_bit(pos):
return False
return True
def estimated_false_positive_rate(self, n: int) -> float:
"""Theoretical false positive rate given m, k, and how many elements
n have actually been inserted so far (tracked externally by the
caller -- the filter itself doesn't count insertions, since duplicate
add() calls don't increase the "true" n)."""
exp = -self.k * n / self.m
return (1 - math.exp(exp)) ** self.k
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.BitSet;
/**
* Probabilistic set-membership structure backed by a bit array and k
* independent hash functions (simulated via double hashing from two base
* hashes). Space-efficient, no false negatives, tunable false positive
* rate. mightContain() answers "definitely not present" (false) or "maybe
* present" (true) -- never "definitely present."
*/
public class BloomFilter {
private final BitSet bits;
private final int m; // number of bits
private final int k; // number of hash functions
/** Size a filter for n expected elements at target false positive rate p (e.g. 0.01 for 1%). */
public BloomFilter(int n, double p) {
this.m = optimalM(n, p);
this.k = optimalK(m, n);
this.bits = new BitSet(m);
}
/** Bits needed: m = -(n * ln(p)) / (ln(2)^2) */
private static int optimalM(int n, double p) {
double m = -1.0 * n * Math.log(p) / (Math.log(2) * Math.log(2));
return (int) Math.ceil(m);
}
/** Ideal number of hash functions: k = (m/n) * ln(2) */
private static int optimalK(int m, int n) {
double k = ((double) m / n) * Math.log(2);
return Math.max(1, (int) Math.round(k));
}
/**
* Two independent base hashes of data. All k hash functions are derived
* from these via double hashing (Kirsch-Mitzenmacher), avoiding the need
* for k separate hash implementations. MD5 and SHA-1 stand in as the
* independent bases here to keep this dependency-free -- in a production
* system, prefer a faster non-cryptographic hash (e.g. Guava's
* Hashing.murmur3_128() with two seeds).
*/
private static long[] hashes(byte[] data) {
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
long h1 = firstEightBytesAsLong(md5.digest(data));
long h2 = firstEightBytesAsLong(sha1.digest(data));
return new long[]{h1, h2};
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e); // MD5/SHA-1 are guaranteed on every JVM
}
}
private static long firstEightBytesAsLong(byte[] digest) {
long value = 0;
for (int i = 0; i < 8; i++) {
value = (value << 8) | (digest[i] & 0xFF);
}
return value;
}
/** Double hashing: hash_i(x) = h1(x) + i*h2(x), unsigned mod m. */
private long combine(long h1, long h2, int i) {
return Long.remainderUnsigned(h1 + (long) i * h2, m);
}
/** Insert an element into the filter. */
public void add(byte[] data) {
long[] h = hashes(data);
for (int i = 0; i < k; i++) {
bits.set((int) combine(h[0], h[1], i));
}
}
/**
* Returns whether data may be in the set. False means definitely not
* present. True means present with probability (1 - false positive
* rate) -- it might be a false positive.
*/
public boolean mightContain(byte[] data) {
long[] h = hashes(data);
for (int i = 0; i < k; i++) {
if (!bits.get((int) combine(h[0], h[1], i))) {
return false;
}
}
return true;
}
/**
* Theoretical false positive rate given m, k, and how many elements n
* have actually been inserted so far (tracked externally by the caller,
* since the filter itself doesn't count insertions -- duplicate add()
* calls don't increase the "true" n).
*/
public double estimatedFalsePositiveRate(int n) {
double exp = -1.0 * k * n / m;
return Math.pow(1 - Math.exp(exp), k);
}
}
The Go and Python implementations only ever compute two hashes (h1, h2) per item, no matter how large k is. How do they still produce k different bit positions?
hash_i(x) = h1(x) + i*h2(x), for i = 0..k-1. Each of the k "virtual" hash functions is just a different linear combination of the same two base hashes, mod m — that's combine() in Go and _combine() in Python. This avoids implementing k separate hash functions while still spreading bits across the array well enough in practice.Test cases
Same double-hashing behavior, same test intent, in both languages:
package bloom
import "testing"
func TestNoFalseNegatives(t *testing.T) {
f := NewFilter(1000, 0.01)
inserted := []string{"apple", "banana", "cherry", "date", "elderberry"}
for _, s := range inserted {
f.Add([]byte(s))
}
for _, s := range inserted {
if !f.MightContain([]byte(s)) {
t.Fatalf("false negative for %q — Bloom filters must never do this", s)
}
}
}
func TestDefinitelyAbsent(t *testing.T) {
f := NewFilter(1000, 0.01)
f.Add([]byte("present"))
// Not a guarantee for every unrelated string (false positives are
// possible), but a filter sized for 1000 elements at 1% FP rate with
// only 1 element inserted should reject almost everything.
falsePositives := 0
trials := 1000
for i := 0; i < trials; i++ {
key := []byte{byte(i), byte(i >> 8)}
if f.MightContain(key) {
falsePositives++
}
}
// Sanity bound — should be well under the target rate given only 1
// real insertion, not a tight statistical assertion.
if falsePositives > trials/10 {
t.Fatalf("false positive rate too high: %d/%d", falsePositives, trials)
}
}
func TestFalsePositiveRateNearTarget(t *testing.T) {
n := uint(10000)
targetP := 0.01
f := NewFilter(n, targetP)
for i := uint(0); i < n; i++ {
f.Add([]byte{byte(i), byte(i >> 8), byte(i >> 16)})
}
// Test with keys guaranteed not to have been inserted.
falsePositives := 0
trials := 10000
for i := 0; i < trials; i++ {
key := []byte{byte(i), byte(i >> 8), byte(i >> 16), 0xFF} // distinct namespace
if f.MightContain(key) {
falsePositives++
}
}
observedRate := float64(falsePositives) / float64(trials)
t.Logf("observed FP rate: %.4f, target: %.4f", observedRate, targetP)
// Allow generous margin — this is a probabilistic structure.
if observedRate > targetP*3 {
t.Fatalf("observed FP rate %.4f far exceeds target %.4f", observedRate, targetP)
}
}
from bloom import BloomFilter
def test_no_false_negatives():
f = BloomFilter(1000, 0.01)
inserted = [b"apple", b"banana", b"cherry", b"date", b"elderberry"]
for item in inserted:
f.add(item)
for item in inserted:
assert f.might_contain(item), (
f"false negative for {item!r} -- Bloom filters must never do this"
)
def test_definitely_absent():
f = BloomFilter(1000, 0.01)
f.add(b"present")
# Not a guarantee for every unrelated string (false positives are
# possible), but a filter sized for 1000 elements at 1% FP rate with
# only 1 element inserted should reject almost everything.
false_positives = 0
trials = 1000
for i in range(trials):
key = bytes([i & 0xFF, (i >> 8) & 0xFF])
if f.might_contain(key):
false_positives += 1
# Sanity bound -- should be well under the target rate given only 1
# real insertion, not a tight statistical assertion.
assert false_positives <= trials / 10, (
f"false positive rate too high: {false_positives}/{trials}"
)
def test_false_positive_rate_near_target():
n = 10_000
target_p = 0.01
f = BloomFilter(n, target_p)
for i in range(n):
f.add(bytes([i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF]))
# Test with keys guaranteed not to have been inserted.
false_positives = 0
trials = 10_000
for i in range(trials):
key = bytes([i & 0xFF, (i >> 8) & 0xFF, (i >> 16) & 0xFF, 0xFF]) # distinct namespace
if f.might_contain(key):
false_positives += 1
observed_rate = false_positives / trials
print(f"observed FP rate: {observed_rate:.4f}, target: {target_p:.4f}")
# Allow generous margin -- this is a probabilistic structure.
assert observed_rate <= target_p * 3, (
f"observed FP rate {observed_rate:.4f} far exceeds target {target_p:.4f}"
)
import java.nio.charset.StandardCharsets;
import java.util.BitSet;
/** Tests for the Bloom filter. */
public class BloomFilterTest {
public static void main(String[] args) {
testNoFalseNegatives();
testDefinitelyAbsent();
testFalsePositiveRateNearTarget();
testStepperWalkthroughBitPositions();
System.out.println("ALL TESTS PASSED");
}
static void testNoFalseNegatives() {
BloomFilter f = new BloomFilter(1000, 0.01);
String[] inserted = {"apple", "banana", "cherry", "date", "elderberry"};
for (String s : inserted) {
f.add(s.getBytes(StandardCharsets.UTF_8));
}
for (String s : inserted) {
assertTrue(
f.mightContain(s.getBytes(StandardCharsets.UTF_8)),
"false negative for \"" + s + "\" -- Bloom filters must never do this"
);
}
System.out.println("PASS testNoFalseNegatives");
}
static void testDefinitelyAbsent() {
BloomFilter f = new BloomFilter(1000, 0.01);
f.add("present".getBytes(StandardCharsets.UTF_8));
// Not a guarantee for every unrelated string (false positives are
// possible), but a filter sized for 1000 elements at 1% FP rate with
// only 1 element inserted should reject almost everything.
int falsePositives = 0;
int trials = 1000;
for (int i = 0; i < trials; i++) {
byte[] key = {(byte) i, (byte) (i >> 8)};
if (f.mightContain(key)) {
falsePositives++;
}
}
// Sanity bound -- should be well under the target rate given only 1
// real insertion, not a tight statistical assertion.
assertTrue(
falsePositives <= trials / 10,
"false positive rate too high: " + falsePositives + "/" + trials
);
System.out.println("PASS testDefinitelyAbsent");
}
static void testFalsePositiveRateNearTarget() {
int n = 10_000;
double targetP = 0.01;
BloomFilter f = new BloomFilter(n, targetP);
for (int i = 0; i < n; i++) {
byte[] key = {(byte) i, (byte) (i >> 8), (byte) (i >> 16)};
f.add(key);
}
// Test with keys guaranteed not to have been inserted.
int falsePositives = 0;
int trials = 10_000;
for (int i = 0; i < trials; i++) {
byte[] key = {(byte) i, (byte) (i >> 8), (byte) (i >> 16), (byte) 0xFF}; // distinct namespace
if (f.mightContain(key)) {
falsePositives++;
}
}
double observedRate = (double) falsePositives / trials;
System.out.printf("observed FP rate: %.4f, target: %.4f%n", observedRate, targetP);
// Allow generous margin -- this is a probabilistic structure.
assertTrue(
observedRate <= targetP * 3,
"observed FP rate " + observedRate + " far exceeds target " + targetP
);
System.out.println("PASS testFalsePositiveRateNearTarget");
}
/**
* Reproduces the exact m=16, k=3 walkthrough from the guide's stepper:
* insert "cat" (bits 2,5,9), "dog" (bits 1,5,14), "fox" (bits 9,12,15),
* then confirm "bird" (never inserted) reads back as a false positive
* because its own 3 positions (1,9,12) were each set by a different
* earlier item. This exercises the same set/get-bit mechanics as
* BloomFilter.add()/mightContain() with the walkthrough's fixed
* positions standing in for real hash output, since the guide picks
* those positions for illustration rather than pulling them from any
* one language's live hash function.
*/
static void testStepperWalkthroughBitPositions() {
BitSet bits = new BitSet(16);
for (int pos : new int[]{2, 5, 9}) { // insert "cat"
bits.set(pos);
}
assertTrue(bitsEqual(bits, 2, 5, 9), "unexpected bit state after inserting cat");
for (int pos : new int[]{1, 5, 14}) { // insert "dog" -- bit 5 overlaps with cat
bits.set(pos);
}
assertTrue(bitsEqual(bits, 1, 2, 5, 9, 14), "unexpected bit state after inserting dog");
for (int pos : new int[]{9, 12, 15}) { // insert "fox" -- bit 9 overlaps with cat
bits.set(pos);
}
assertTrue(bitsEqual(bits, 1, 2, 5, 9, 12, 14, 15), "unexpected bit state after inserting fox");
// "bird" was never inserted, but its 3 hash positions each happen to
// already be set by a different earlier item -- a false positive.
boolean falsePositive = true;
for (int pos : new int[]{1, 9, 12}) {
falsePositive &= bits.get(pos);
}
assertTrue(
falsePositive,
"expected \"bird\" to read as a false positive, matching the guide's walkthrough"
);
System.out.println("PASS testStepperWalkthroughBitPositions");
}
static boolean bitsEqual(BitSet bits, int... expectedSetPositions) {
BitSet expected = new BitSet(16);
for (int pos : expectedSetPositions) {
expected.set(pos);
}
return bits.equals(expected);
}
static void assertTrue(boolean cond, String msg) {
if (!cond) {
throw new AssertionError(msg);
}
}
}
TestNoFalseNegatives / test_no_false_negatives asserts MightContain (Go) / might_contain (Python) returns true for every inserted item. Could this test ever legitimately fail without a bug in the filter?
Add/add time, and standard Bloom filters never clear bits (no deletion), so those same bits are guaranteed still set when MightContain/might_contain checks them later. If this test fails, that means an actual bug (e.g., a mismatch between how Add and MightContain compute positions), not statistical bad luck.False Positive Rate Math
Given:
m= number of bits in the arrayn= number of elements insertedk= number of hash functions
Optimal number of hash functions (minimizes false positive rate for given m, n):
k = (m/n) * ln(2)
False positive probability after inserting n elements:
p ≈ (1 - e^(-kn/m))^k
Required bits for target false positive rate p (solving for m given n and desired p):
m = -(n * ln(p)) / (ln(2))^2
Worked Example
Requirement: store 1,000,000 elements with a 1% (0.01) false positive rate.
n = 1,000,000
p = 0.01
Step 1 — compute m:
m = -(1,000,000 * ln(0.01)) / (ln(2))^2
= -(1,000,000 * -4.6052) / (0.6931)^2
= 4,605,200 / 0.4805
≈ 9,585,000 bits
≈ 1.14 MB
Step 2 — compute k:
k = (m/n) * ln(2)
= (9,585,000 / 1,000,000) * 0.6931
= 9.585 * 0.6931
≈ 6.64 -> round to 7 hash functions
Step 3 — verify p with rounded k=7, m=9,585,000, n=1,000,000:
p ≈ (1 - e^(-7*1,000,000/9,585,000))^7
≈ (1 - e^(-0.7302))^7
≈ (1 - 0.4819)^7
≈ (0.5181)^7
≈ 0.00963 (≈0.96%, close to the 1% target)
Verified with Python
Same three formulas, run instead of hand-computed — this removes the rounding error that accumulates across the by-hand steps above (0.96% by hand vs. the more precise figure below):
import math
n = 1_000_000
p = 0.01
m = math.ceil(-(n * math.log(p)) / (math.log(2) ** 2))
k = max(1, round((m / n) * math.log(2)))
fp = (1 - math.exp(-k * n / m)) ** k
print(f"m = {m:,} bits (~{m / 8 / 1024 / 1024:.2f} MB)")
print(f"k = {k}")
print(f"p ~= {fp:.5f}")
Output:
m = 9,585,059 bits (~1.14 MB)
k = 7
p ~= 0.01004
Matches the hand-worked m and k (the tiny difference is just hand-rounding m to 9,585,000 above vs. the exact 9,585,059), and confirms p lands almost exactly on the 1% target once the rounding error is removed.
Compare to the naive alternative: storing 1,000,000 actual keys (e.g., 8-byte hashes) in a hashset would take ~8 MB minimum, plus hashmap overhead (buckets, pointers) pushing it to 20-40 MB in practice. The Bloom filter does it in ~1.14 MB — roughly 20-30x less memory — at the cost of ~1% false positives and no ability to enumerate or delete elements (standard Bloom filters don't support deletion; that requires a Counting Bloom Filter variant with counters instead of bits).
Why can a Bloom filter produce false positives but never false negatives?
MightContain/might_contain only checks whether bits are set — it has no way to tell which item set a given bit. An item that was never inserted can still have all k of its hash positions happen to be set by other, unrelated items already in the filter — that's a false positive, and p ≈ (1 - e^(-kn/m))^k is exactly the probability of that overlap happening. A false negative would require a bit that a real member's own hashing set to somehow be unset later — and since bits are only ever set (never cleared, no deletion in a standard Bloom filter), that can't happen.Walking Through Inserts and a False Positive
A concrete trace on a tiny filter makes the bit-overlap mechanism concrete. Take m = 16 bits, k = 3 hash positions per item, all bits starting at 0. Insert "cat", "dog", "fox", then query "bird" — never inserted — and watch it come back a false positive purely because its 3 hash positions each happen to already be set by a different earlier item.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 0 1 0 0 1 0 0 0 1 0 0 0 0 0 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 1 1 0 0 1 0 0 0 1 0 0 0 0 1 0
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 1 1 0 0 1 0 0 0 1 0 0 1 0 1 1
MightContain only reads bit values, not who set them, so all three checks pass and it returns true — "bird" looks present even though it was never added. Three unrelated inserts each left one bit behind that, purely by coincidence, lined up with bird's own hash positions.
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 1 1 0 0 1 0 0 0 1 0 0 1 0 1 1
In the walkthrough above, querying "bird" (never inserted) returns a false positive. Why did that happen even though bird's own hash positions were computed correctly?
MightContain only reads bit values, not who set them, so a real item never inserted can still pass the check purely by coincidental overlap. This gets less likely as the array gets bigger relative to the number of items (larger m/n) — exactly what optimalM/optimalK (_optimal_m/_optimal_k in Python) are tuning for.Try It Yourself: Live Bloom Filter
Same scale as the walkthrough (16 bits, k=3), but using a real hash function on whatever you type instead of the walkthrough's hand-picked positions — so cat/dog/fox will land on different bits here than above. Insert a handful of words, then search for one you never added; with only 16 bits it won't take long to hit a false positive.
Use Cases in Infra Engineering
1. Pre-check before an expensive DB lookup
// Avoid a disk-seeking DB query for keys that provably don't exist.
// Example: checking if a username is taken before hitting the DB.
func UsernameExists(filter *Filter, db *sql.DB, username string) (bool, error) {
if !filter.MightContain([]byte(username)) {
return false, nil // definitely not taken — skip the DB entirely
}
// Might exist (or false positive) — fall through to the authoritative check.
var exists bool
err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)", username,
).Scan(&exists)
return exists, err
}
This is the pattern behind Cassandra's and RocksDB's SSTable read paths: a Bloom filter per SSTable lets a read skip disk I/O entirely for SSTables that provably don't contain the key, only paying the real read cost when the filter says "maybe."
2. Deduplication in stream processing
// Drop duplicate events in a Kafka consumer without storing every seen
// event ID forever. Trade: a small % of unique events may be incorrectly
// dropped as "duplicates" (false positive), but never the reverse.
func ProcessEvent(filter *Filter, event Event, handler func(Event)) {
id := []byte(event.ID)
if filter.MightContain(id) {
return // likely a duplicate — skip (small false-positive-drop risk)
}
filter.Add(id)
handler(event)
}
Used for at-least-once delivery systems where occasional duplicate suppression false positives (dropping a genuinely new event because it collided with a filter bit pattern) are acceptable, but exact deduplication via a full seen-set would be too memory-expensive at scale (e.g., billions of event IDs/day).
In the stream-dedup use case, what's the actual failure mode of using a Bloom filter instead of a full seen-set, and why is it considered acceptable there?
ProcessEvent treat a genuinely new event as a duplicate and silently drop it — the same bit-overlap failure mode as everywhere else in this filter, not a crash or data corruption. It's acceptable in at-least-once systems processing billions of events/day because a small, tunable percentage of dropped-as-duplicate events is a better tradeoff than the memory cost of storing every seen event ID forever in a full set.3. Other infra-relevant use cases worth mentioning in an interview
| Use case | Why Bloom filter fits |
|---|---|
| CDN edge cache "definitely not cached" check | Skip origin round-trip metadata lookup for objects never seen at that edge |
| Chrome Safe Browsing (malicious URL check) | Local filter check before an expensive network call to the full blocklist |
| Distributed cache "cache miss" fast-path | Many cache clients query a shared Bloom filter to avoid hammering a cache cluster with guaranteed-miss requests |
| Bitcoin SPV clients | Filter transactions relevant to a wallet without downloading the full chain |