{"article":{"slug":"one-hash-bloom-filters","title":"One Hash Bloom Filters","subtitle":null,"summary":"Jim Calabro walks through how bloom filters work, how Bluesky moved from multiple hash functions to double hashing to cut contention, and then explores the one-hash bloom filter from Lu et al., which splits the bit array into partitions with pairwise coprime sizes so a single hash can stand in for k independent ones.","content_type":"tutorial","language":"en","canonical_url":"https://www.calabro.io/ohbf","author":{"name":"Jim Calabro","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Jim Calabro","url":"https://www.calabro.io/","listing_slug":null,"listing":null},"topics":[{"name":"Algorithms","slug":"algorithms","url":"https://listedarticles.com/topics/algorithms"},{"name":"Data Structures","slug":"data-structures","url":"https://listedarticles.com/topics/data-structures"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1387,"reading_minutes":6,"published_at":"2026-10-17T00:00:00.000Z","added_at":"2026-10-19T17:10:30.325Z","updated_at":"2026-10-19T17:10:30.325Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/one-hash-bloom-filters","markdown_url":"https://listedarticles.com/articles/one-hash-bloom-filters.md","example":false,"citation":"Jim Calabro, Jim Calabro. \"One Hash Bloom Filters.\" 17 Oct 2026. https://www.calabro.io/ohbf (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://www.calabro.io/ohbf"},"body_markdown":"# One Hash Bloom Filters\n\nby Jim Calabro | Oct 17, 2026\n\n*Note: as always, all my public writing is written by hand by me.*\n\n#### Overview\n\nA [bloom filter](https://en.wikipedia.org/wiki/Bloom_filter) is a probabilistic data structure that allows you to quickly and cheaply answer the question *\"is key X in this set?\"* with either *\"definitely not\"* or *\"probably\"*.\n\nYou pick a false positive rate that is acceptable for your workload and hardware, add keys to the set, and check for their existence later on.\n\nBloom filters use a small amount of memory compared to the cardinality of the set, and are computationally very cheap. For these reasons, they're frequently used in high performance data systems.\n\nFor example, a database might keep an in-memory bloom filter and check it before actually going to disk when looking up rows. This saves substantial amounts of very slow disk IO.\n\nWe also use them all over the place in the [Bluesky](https://bsky.app) backend. They allow us to save *millions* of queries per second against our Scylla database when looking up blocks, mutes, labels, and more. For instance, we can often answer the question *\"does user X block user Y\"* without even hitting the database.\n\nHere's the last several days of some of our bloom filter positive hit rates:\n\n![Image of the bloom filter postitive rate we have in production](https://www.calabro.io/static/posts/bsky-bloom-filter-positive-rate.png)\n\nThe above yellow line has a roughly 1% positive rate, which means that 99% of the time, we can completely skip querying our database. This has been critical in our ability to scale the site and operate the site cheaply.\n\n#### Traditional Bloom Filters\n\nA bloom filter is an array of bits and *k* hash functions. You choose the size of the array and *k* before hand, and want to right-size it to your workload to achieve a specific false positive rate.\n\nTo add a key to the set, run it through the *k* hash functions and set the bits at each of those locations.\n\nTo check if a a key is in the set, hash it *k* times and check each resulting location in the bit array. If any bits are zero, the key was never added. If all bits are set to one, it might be in the set.\n\nHere's an example of a potential positive:\n\n![Image showing how if all hash function items are one, then the item may be in the set](https://www.calabro.io/static/posts/bsky-bloom-filter-traditional-all-one.png)\n\nAnd a definite negative:\n\n![Image showing how if any hash function items are zero, then the item is definitely not in the set](https://www.calabro.io/static/posts/bsky-bloom-filter-traditional-any-zero.png)\n\nPretty simple!\n\n#### Double Hashing\n\nWhile *much* cheaper than going to disk, computing *k* independent hashes per key is still a bit expensive for very high performance systems.\n\nIn 2008, Kirsch and Mitzenmacher wrote a [paper](https://www.eecs.harvard.edu/~michaelm/postscripts/rsa2008.pdf) demonstrating that two hashes can get you the same output. The math is far over my head, but if you derive the *i*-th location as `(hash_1 + i*hash_2) % n`, then the false positive rate is effectively the same as using *k* independent hashes. Phew!\n\n[bits-and-blooms/bloom](https://github.com/bits-and-blooms/bloom) maintained by [Daniel Lemire](https://lemire.me/blog/) (a huge inspiration of mine, you should buy his [book](https://www.amazon.com/dp/B0HF4JT75J)) is a fantastic double hash bloom filter implementation written in go. It runs 128-bit murmur3 twice to get four 64-bit values, then combines them to produce all *k* bit locations. We used this in production at Bluesky for a long time.\n\nFor the Bluesky backend, we essentially do a `SELECT * FROM blocks`, mutes, list items, etc. every time one the processes starts, and add all the items to the bloom filter. There's also a real time [atproto](https://atproto.com) firehose consumer inserting items in to bloom filters (even during process startup), so there is a race condition if you're not careful.\n\nThe problem with the `bits-and-blooms` library is that it's not internally thread-safe, so we needed to wrap all bloom filter access in a [mutex](https://pkg.go.dev/sync) lest we suffer a data race. [pprof](https://pkg.go.dev/net/http/pprof) showed that mutex contention was actually the bottleneck in launching the process. A single deploy used to take 90 minutes to roll out fully because of this!\n\nSo my colleague [Jasmine](https://jazco.dev/) forked the library and created [atomic-bloom](https://github.com/jazware/atomic-bloom), which uses [atomic](https://pkg.go.dev/sync/atomic) CPU instructions to do the same logic, but safely and without mutexes. This led to substantially less contention and was massively faster than the mutex-based approach. Our deploys dropped from 90 minutes to about 15, which enabled us to ship faster and with greater confidence. Awesome!\n\n#### One Hash\n\nCan we go further?\n\nFor Bluesky, the answer is **no**. Contention was our original problem, not the number of hash functions or other factors, thus by [Amdahl's Law](https://en.wikipedia.org/wiki/Amdahl%27s_law) we did not receive any further speedup resulting in a noticeable impact. However, it's still fun to go deeper and learn something new!\n\nAs part of our research on bloom filter techniques, we came across [this paper](https://yangtonghome.github.io/uploads/One_Hashing.pdf) from Lu et al. that uses *even more math* to show how to do bloom filters using only a single hash.\n\nHere's an overview of how it works:\n\n- Instead of one big bit array, split that into *k* array partitions of fixed sizes\n- Each partition has a different size, and all the sizes are [pairwise coprime](https://en.wikipedia.org/wiki/Coprime_integers), meaning that no two sizes share a common factor other than one\n- You hash the given key once to get *hash*\n- The bit position in partition *p* is `hash%size_p` (the size of the partition)\n- Since you have *k* partitions, you get *k* bit positions to check out of that single hash\n- You can use those *k* bit positions as you would with other bloom filter types!\n\nWith this technique in hand, I made [jcalabro/gloom](https://github.com/jcalabro/gloom), a one-hash bloom filter (OHBF) implementation in go. This has been live in the Bluesky backend for the past 9 months and has been holding up quite well. It's also used in [jetstream](https://github.com/bluesky-social/jetstream), our high performance sync service.\n\nHere's an example of how it's used:\n\n```\npackage main\n\nimport (\n    \"fmt\"\n    \"sync\"\n\n    \"github.com/jcalabro/gloom\"\n)\n\nfunc main() {\n    // Create a thread-safe atomic bloom filter with:\n    //    - Expected capacity of 1 million items\n    //    - Approximate false positive rate of 1%\n    f := gloom.NewAtomic(1_000_000, 0.01)\n\n    var wg sync.WaitGroup\n    defer wg.Wait()\n\n    for i := range 8 {\n        wg.Go(func() {\n            for j := range 100000 {\n                str := fmt.Sprintf(\"key-%d-%d\", i, j)\n\n                if f.TestString(str) {\n                    // always a false positive since the key isn't in the set\n                    fmt.Printf(\"found a false positive: %s\\n\", str)\n                }\n\n                f.AddString(str)\n            }\n        })\n    }\n}\n```\n\nNow, let's step through how it works under the hood in more detail. Here's some sample go code that shows how we select the block index as well as `h` for a given key we want to check:\n\n```\nhash := xxh3.Hash128(key) // calculate the 128-bit hash\n\nblock := hash.Hi % numBlocks // high 64 bits pick the block\nh := hash.Lo                 // low 64 bits are the hash we'll check\n```\n\nThen, you check your bloom filter as follows:\n\n![Image showing one hash bloom filters work](https://www.calabro.io/static/posts/bsky-bloom-filter-one-hash.png)\n\nIn addition to being a single hash, it also does some fun performance optimization techniques.\n\nFirst, we split the bloom filter into many 64-byte blocks to match common CPU cache line size. Next, since go's allocator doesn't ensure that those block allocations are [cache aligned](https://learn.arm.com/learning-paths/cross-platform/memory-latency/latency-and-cache-alignment/), we slightly over-allocate and use [unsafe](https://pkg.go.dev/unsafe) to ensure alignment. [See here](https://github.com/jcalabro/gloom/blob/41399c29b47251fe3822e64cbb934c609f7b29c8/bloom.go#L90-L99) for the implementation.\n\nThese two techniques combined guarantee that in the worst case, a bloom filter lookup only takes one cache miss. Nice!\n\nThere's another fun [optimization](https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/) from Daniel Lemire that allows you to select which block to check with a single `MUL` instruction rather than modulo, which requires an expensive division instruction.\n\nThe final interesting optimization I'll talk about is the [ShardedAtomicFilter](https://github.com/jcalabro/gloom/tree/bf26d4b6e5e9e0a86e48ad444dd4e85740a90ceb#high-throughput-concurrent-writes). At Bluesky, I managed a large fleet of top of the line bare metal servers. In order to eke the most performance out of those systems, it's good to avoid having multiple cores fight over the same data. `ShardedAtomicFilter` spreads writes over many backing filter shards, which reduces the likelihood of contention.\n\n#### Conclusion\n\nBloom filters (and [roaring bitmaps](https://lemire.me/en/publication/spe2016roaring/)) [make the world go around](https://bsky.app/profile/jaz.sh/post/3mizi7ns23s2u).\n\nHopefully this was an interesting little exploration in to how they work. If you're interested in fun low-level engineering details, consider [following me on Bluesky](https://bsky.app/profile/calabro.io/) or subscribing to this blog via atproto, RSS or similar.\n\nSide note: I've been working on a novel observability suite that pushes the state of the art. [Star it on github](https://github.com/microscope-dev/microscope) and keep an eye out for more discussion in the near future!\n","body_html":"<h1 id=\"one-hash-bloom-filters\">One Hash Bloom Filters</h1>\n<p>by Jim Calabro | Oct 17, 2026</p>\n<p><em>Note: as always, all my public writing is written by hand by me.</em></p>\n<h4 id=\"overview\">Overview</h4>\n<p>A <a href=\"https://en.wikipedia.org/wiki/Bloom_filter\" rel=\"nofollow ugc noopener\">bloom filter</a> is a probabilistic data structure that allows you to quickly and cheaply answer the question <em>&quot;is key X in this set?&quot;</em> with either <em>&quot;definitely not&quot;</em> or <em>&quot;probably&quot;</em>.</p>\n<p>You pick a false positive rate that is acceptable for your workload and hardware, add keys to the set, and check for their existence later on.</p>\n<p>Bloom filters use a small amount of memory compared to the cardinality of the set, and are computationally very cheap. For these reasons, they&#39;re frequently used in high performance data systems.</p>\n<p>For example, a database might keep an in-memory bloom filter and check it before actually going to disk when looking up rows. This saves substantial amounts of very slow disk IO.</p>\n<p>We also use them all over the place in the <a href=\"https://bsky.app\" rel=\"nofollow ugc noopener\">Bluesky</a> backend. They allow us to save <em>millions</em> of queries per second against our Scylla database when looking up blocks, mutes, labels, and more. For instance, we can often answer the question <em>&quot;does user X block user Y&quot;</em> without even hitting the database.</p>\n<p>Here&#39;s the last several days of some of our bloom filter positive hit rates:</p>\n<figure><img src=\"https://www.calabro.io/static/posts/bsky-bloom-filter-positive-rate.png\" alt=\"Image of the bloom filter postitive rate we have in production\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>The above yellow line has a roughly 1% positive rate, which means that 99% of the time, we can completely skip querying our database. This has been critical in our ability to scale the site and operate the site cheaply.</p>\n<h4 id=\"traditional-bloom-filters\">Traditional Bloom Filters</h4>\n<p>A bloom filter is an array of bits and *k* hash functions. You choose the size of the array and *k* before hand, and want to right-size it to your workload to achieve a specific false positive rate.</p>\n<p>To add a key to the set, run it through the *k* hash functions and set the bits at each of those locations.</p>\n<p>To check if a a key is in the set, hash it *k* times and check each resulting location in the bit array. If any bits are zero, the key was never added. If all bits are set to one, it might be in the set.</p>\n<p>Here&#39;s an example of a potential positive:</p>\n<figure><img src=\"https://www.calabro.io/static/posts/bsky-bloom-filter-traditional-all-one.png\" alt=\"Image showing how if all hash function items are one, then the item may be in the set\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>And a definite negative:</p>\n<figure><img src=\"https://www.calabro.io/static/posts/bsky-bloom-filter-traditional-any-zero.png\" alt=\"Image showing how if any hash function items are zero, then the item is definitely not in the set\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>Pretty simple!</p>\n<h4 id=\"double-hashing\">Double Hashing</h4>\n<p>While <em>much</em> cheaper than going to disk, computing *k* independent hashes per key is still a bit expensive for very high performance systems.</p>\n<p>In 2008, Kirsch and Mitzenmacher wrote a <a href=\"https://www.eecs.harvard.edu/~michaelm/postscripts/rsa2008.pdf\" rel=\"nofollow ugc noopener\">paper</a> demonstrating that two hashes can get you the same output. The math is far over my head, but if you derive the *i*-th location as <code>(hash_1 + i*hash_2) % n</code>, then the false positive rate is effectively the same as using *k* independent hashes. Phew!</p>\n<p><a href=\"https://github.com/bits-and-blooms/bloom\" rel=\"nofollow ugc noopener\">bits-and-blooms/bloom</a> maintained by <a href=\"https://lemire.me/blog/\" rel=\"nofollow ugc noopener\">Daniel Lemire</a> (a huge inspiration of mine, you should buy his <a href=\"https://www.amazon.com/dp/B0HF4JT75J\" rel=\"nofollow ugc noopener\">book</a>) is a fantastic double hash bloom filter implementation written in go. It runs 128-bit murmur3 twice to get four 64-bit values, then combines them to produce all *k* bit locations. We used this in production at Bluesky for a long time.</p>\n<p>For the Bluesky backend, we essentially do a <code>SELECT * FROM blocks</code>, mutes, list items, etc. every time one the processes starts, and add all the items to the bloom filter. There&#39;s also a real time <a href=\"https://atproto.com\" rel=\"nofollow ugc noopener\">atproto</a> firehose consumer inserting items in to bloom filters (even during process startup), so there is a race condition if you&#39;re not careful.</p>\n<p>The problem with the <code>bits-and-blooms</code> library is that it&#39;s not internally thread-safe, so we needed to wrap all bloom filter access in a <a href=\"https://pkg.go.dev/sync\" rel=\"nofollow ugc noopener\">mutex</a> lest we suffer a data race. <a href=\"https://pkg.go.dev/net/http/pprof\" rel=\"nofollow ugc noopener\">pprof</a> showed that mutex contention was actually the bottleneck in launching the process. A single deploy used to take 90 minutes to roll out fully because of this!</p>\n<p>So my colleague <a href=\"https://jazco.dev/\" rel=\"nofollow ugc noopener\">Jasmine</a> forked the library and created <a href=\"https://github.com/jazware/atomic-bloom\" rel=\"nofollow ugc noopener\">atomic-bloom</a>, which uses <a href=\"https://pkg.go.dev/sync/atomic\" rel=\"nofollow ugc noopener\">atomic</a> CPU instructions to do the same logic, but safely and without mutexes. This led to substantially less contention and was massively faster than the mutex-based approach. Our deploys dropped from 90 minutes to about 15, which enabled us to ship faster and with greater confidence. Awesome!</p>\n<h4 id=\"one-hash\">One Hash</h4>\n<p>Can we go further?</p>\n<p>For Bluesky, the answer is <strong>no</strong>. Contention was our original problem, not the number of hash functions or other factors, thus by <a href=\"https://en.wikipedia.org/wiki/Amdahl%27s_law\" rel=\"nofollow ugc noopener\">Amdahl&#39;s Law</a> we did not receive any further speedup resulting in a noticeable impact. However, it&#39;s still fun to go deeper and learn something new!</p>\n<p>As part of our research on bloom filter techniques, we came across <a href=\"https://yangtonghome.github.io/uploads/One_Hashing.pdf\" rel=\"nofollow ugc noopener\">this paper</a> from Lu et al. that uses <em>even more math</em> to show how to do bloom filters using only a single hash.</p>\n<p>Here&#39;s an overview of how it works:</p>\n<ul><li>Instead of one big bit array, split that into *k* array partitions of fixed sizes</li><li>Each partition has a different size, and all the sizes are <a href=\"https://en.wikipedia.org/wiki/Coprime_integers\" rel=\"nofollow ugc noopener\">pairwise coprime</a>, meaning that no two sizes share a common factor other than one</li><li>You hash the given key once to get <em>hash</em></li><li>The bit position in partition *p* is <code>hash%size_p</code> (the size of the partition)</li><li>Since you have *k* partitions, you get *k* bit positions to check out of that single hash</li><li>You can use those *k* bit positions as you would with other bloom filter types!</li></ul>\n<p>With this technique in hand, I made <a href=\"https://github.com/jcalabro/gloom\" rel=\"nofollow ugc noopener\">jcalabro/gloom</a>, a one-hash bloom filter (OHBF) implementation in go. This has been live in the Bluesky backend for the past 9 months and has been holding up quite well. It&#39;s also used in <a href=\"https://github.com/bluesky-social/jetstream\" rel=\"nofollow ugc noopener\">jetstream</a>, our high performance sync service.</p>\n<p>Here&#39;s an example of how it&#39;s used:</p>\n<pre><code>package main\n\nimport (\n    &quot;fmt&quot;\n    &quot;sync&quot;\n\n    &quot;github.com/jcalabro/gloom&quot;\n)\n\nfunc main() {\n    // Create a thread-safe atomic bloom filter with:\n    //    - Expected capacity of 1 million items\n    //    - Approximate false positive rate of 1%\n    f := gloom.NewAtomic(1_000_000, 0.01)\n\n    var wg sync.WaitGroup\n    defer wg.Wait()\n\n    for i := range 8 {\n        wg.Go(func() {\n            for j := range 100000 {\n                str := fmt.Sprintf(&quot;key-%d-%d&quot;, i, j)\n\n                if f.TestString(str) {\n                    // always a false positive since the key isn&#39;t in the set\n                    fmt.Printf(&quot;found a false positive: %s\\n&quot;, str)\n                }\n\n                f.AddString(str)\n            }\n        })\n    }\n}</code></pre>\n<p>Now, let&#39;s step through how it works under the hood in more detail. Here&#39;s some sample go code that shows how we select the block index as well as <code>h</code> for a given key we want to check:</p>\n<pre><code>hash := xxh3.Hash128(key) // calculate the 128-bit hash\n\nblock := hash.Hi % numBlocks // high 64 bits pick the block\nh := hash.Lo                 // low 64 bits are the hash we&#39;ll check</code></pre>\n<p>Then, you check your bloom filter as follows:</p>\n<figure><img src=\"https://www.calabro.io/static/posts/bsky-bloom-filter-one-hash.png\" alt=\"Image showing one hash bloom filters work\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>In addition to being a single hash, it also does some fun performance optimization techniques.</p>\n<p>First, we split the bloom filter into many 64-byte blocks to match common CPU cache line size. Next, since go&#39;s allocator doesn&#39;t ensure that those block allocations are <a href=\"https://learn.arm.com/learning-paths/cross-platform/memory-latency/latency-and-cache-alignment/\" rel=\"nofollow ugc noopener\">cache aligned</a>, we slightly over-allocate and use <a href=\"https://pkg.go.dev/unsafe\" rel=\"nofollow ugc noopener\">unsafe</a> to ensure alignment. <a href=\"https://github.com/jcalabro/gloom/blob/41399c29b47251fe3822e64cbb934c609f7b29c8/bloom.go#L90-L99\" rel=\"nofollow ugc noopener\">See here</a> for the implementation.</p>\n<p>These two techniques combined guarantee that in the worst case, a bloom filter lookup only takes one cache miss. Nice!</p>\n<p>There&#39;s another fun <a href=\"https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/\" rel=\"nofollow ugc noopener\">optimization</a> from Daniel Lemire that allows you to select which block to check with a single <code>MUL</code> instruction rather than modulo, which requires an expensive division instruction.</p>\n<p>The final interesting optimization I&#39;ll talk about is the <a href=\"https://github.com/jcalabro/gloom/tree/bf26d4b6e5e9e0a86e48ad444dd4e85740a90ceb#high-throughput-concurrent-writes\" rel=\"nofollow ugc noopener\">ShardedAtomicFilter</a>. At Bluesky, I managed a large fleet of top of the line bare metal servers. In order to eke the most performance out of those systems, it&#39;s good to avoid having multiple cores fight over the same data. <code>ShardedAtomicFilter</code> spreads writes over many backing filter shards, which reduces the likelihood of contention.</p>\n<h4 id=\"conclusion\">Conclusion</h4>\n<p>Bloom filters (and <a href=\"https://lemire.me/en/publication/spe2016roaring/\" rel=\"nofollow ugc noopener\">roaring bitmaps</a>) <a href=\"https://bsky.app/profile/jaz.sh/post/3mizi7ns23s2u\" rel=\"nofollow ugc noopener\">make the world go around</a>.</p>\n<p>Hopefully this was an interesting little exploration in to how they work. If you&#39;re interested in fun low-level engineering details, consider <a href=\"https://bsky.app/profile/calabro.io/\" rel=\"nofollow ugc noopener\">following me on Bluesky</a> or subscribing to this blog via atproto, RSS or similar.</p>\n<p>Side note: I&#39;ve been working on a novel observability suite that pushes the state of the art. <a href=\"https://github.com/microscope-dev/microscope\" rel=\"nofollow ugc noopener\">Star it on github</a> and keep an eye out for more discussion in the near future!</p>","headings":[{"level":1,"text":"One Hash Bloom Filters","id":"one-hash-bloom-filters"}]}}