loke.dev
Header image for A Compact Signature for the Multi-Column Query

A Compact Signature for the Multi-Column Query

Discover how to index dozens of columns using a single, fixed-size signature that finally ends the storage tax of complex filtering.

· 4 min read

It’s funny how a table starts with three columns and somehow, six months later, it has forty-five. We’ve all been there—a "simple" entity table morphs into a giant bag of attributes, and suddenly, the product team wants users to be able to filter by any combination of them.

Standard B-Tree indexes are usually our best friends, but they scale poorly when you’re dealing with dozens of columns. You either create a massive multi-column index that only works if the user filters in a specific order, or you create twenty individual indexes and watch your storage costs (and write latency) spiral out of control.

There is a better way to handle this "index everything" nightmare in Postgres: the Bloom filter index.

The Storage Tax is Real

If you have a table with 20 columns and you want to support arbitrary filtering, a B-Tree is a blunt instrument. Each B-Tree index stores the actual data values, leading to massive pg_relation_size numbers.

The bloom extension, tucked away in Postgres’s contrib module, takes a different approach. It doesn't store the data itself. Instead, it stores a compact "signature" (a bitmask) of the row. It’s a lossy index, meaning it can occasionally give a "false positive" (thinking a row matches when it doesn't), but it never gives a false negative. Postgres handles the false positives automatically by re-checking the actual heap, so you get the right results, just much faster and in a fraction of the space.

Setting the Stage

First, you’ll need to enable the extension. It’s been bundled with Postgres for years, but it’s rarely used.

CREATE EXTENSION IF NOT EXISTS bloom;

Let's create a dummy table that mimics a messy "Users" or "Products" table where filters could come from any direction.

CREATE TABLE items (
    id serial PRIMARY KEY,
    category_id int,
    color_id int,
    size_id int,
    brand_id int,
    status_id int,
    is_active boolean,
    is_deleted boolean
);

-- Let's dump some random data in here
INSERT INTO items (category_id, color_id, size_id, brand_id, status_id, is_active, is_deleted)
SELECT 
    (random() * 10)::int,
    (random() * 50)::int,
    (random() * 5)::int,
    (random() * 100)::int,
    (random() * 5)::int,
    (random() > 0.5),
    (random() > 0.9)
FROM generate_series(1, 1000000);

Creating the Multi-Column Signature

Instead of seven different indexes, we create one. The magic of the Bloom index is that it treats all indexed columns as equals. The order doesn't matter.

CREATE INDEX idx_items_bloom ON items 
USING bloom (category_id, color_id, size_id, brand_id, status_id, is_active, is_deleted)
WITH (length = 128, col1 = 2, col2 = 2, col3 = 2, col4 = 4, col5 = 2, col6 = 1, col7 = 1);

Wait, what are those col numbers? Those are the bits allocated to each column in the signature.
- length: The total size of the signature in bits (up to 4096).
- col1...colN: How many bits to flip for each column. Columns with more unique values usually benefit from a few extra bits.

Why This Beats a B-Tree

If I query this table using just brand_id and is_active, a multi-column B-Tree would only be useful if those were the *first* columns in the index. With Bloom, it doesn't care.

-- This will use the Bloom index effectively
SELECT * FROM items 
WHERE brand_id = 42 
  AND is_active = true 
  AND status_id = 1;

Check the plan with EXPLAIN ANALYZE. You’ll see a Bitmap Index Scan. The database scans the compact signatures, finds the bits that match your criteria, and then only touches the actual disk pages for those potential matches.

The Catch (There's always a catch)

Before you go dropping all your B-Trees, let's talk about the "gotchas."

1. Equality only: Bloom indexes only support the = operator. If you need to do price > 100 or name LIKE 'A%', Bloom is useless. It’s strictly for "point" lookups.
2. Lossiness: If your length is too small or your data is too high-cardinality, you'll get too many false positives. Postgres will have to fetch too many rows from the heap to verify them, and performance will tank.
3. Write overhead: While the index is small, calculating the hash/signature still takes a bit of CPU during inserts.

Tuning the Signature

Finding the right length is a bit of a dark art. A signature that is too short causes collisions (slow queries); a signature that is too long wastes space (though still usually smaller than a B-Tree).

I usually start with a length of 128 or 256 for tables with 10-15 columns. If the query performance isn't where I want it, I check the Bitmap Heap Scan in the explain plan. If the "Rows Removed by Index Recheck" number is huge, it means my signature is too "blurry" and I need more bits.

Final Thoughts

The Bloom index is a specialized tool. It’s not for your primary keys or your frequently updated counters. But for those massive, wide tables used for "Advanced Search" sidebars where users can toggle twenty different filters? It's a lifesaver.

It turns a 500MB indexing nightmare into a 20MB signature that just works, regardless of which checkboxes the user clicks. That’s a win for your storage bill and your sanity.