PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 17: Other index types

Specialised index extensions: btree_gin, bloom and intarray

Level 4: Performance25 min

Contrib ships index tools for cases the built-in operator classes do not cover well: combining scalar columns with JSONB or arrays in one GIN index, indexing many columns for arbitrary equality filters, and fast boolean queries over integer arrays.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Build multicolumn GIN indexes that mix scalar and JSONB or array columns with btree_gin
  • Use a bloom index for ad hoc equality filters across many columns
  • Query integer arrays with intarray operators and its GIN operator class

conceptThe concept

btree_gin adds GIN operator classes for scalar types (integers, text, timestamps, and more). Its main use is a single GIN index on (customer_id, payload jsonb_path_ops) so one index serves "this customer's events with this JSON property". (btree_gist, covered earlier, does the same for GiST and exclusion constraints.)

bloom is an index access method that stores a small signature per row built from several columns. A query with equality filters on any subset of those columns scans the whole (small) index and then rechecks candidate rows. It replaces many separate B-tree indexes for wide tables filtered by changing combinations of columns. It supports only equality, only int4 and text out of the box, and is lossy.

intarray adds functions and operators for int[] without NULLs: & (intersection), | (union), sort, uniq, icount, and the query_int type for boolean queries like tag_ids @@ '3 & (7 | 9) & !11', indexed by gin__int_ops or gist__int_ops.

whyWhy it matters

Without these, teams either create a pile of overlapping indexes (slow writes, bloat) or accept sequential scans. One well-chosen specialised index can replace several general ones.

Use caseReal-world scenario

An events table is queried by customer plus JSON attributes, so a btree_gin index combines both. A product search page filters on any 3 of 10 numeric attributes; one bloom index replaces ten B-trees. A content site stores tag ids as int[] and answers "has tag A and (B or C) but not D" from a GIN intarray index.

Try itExamples

Run and edit these in the interactive lesson.

One GIN index for a scalar and JSONB
BEGIN;
CREATE EXTENSION btree_gin;
CREATE INDEX events_cust_payload ON events USING gin (customer_id, payload jsonb_path_ops);
SET LOCAL enable_seqscan = off;
EXPLAIN (COSTS OFF)
SELECT count(*) FROM events
WHERE customer_id = 42 AND payload @> '{"device": "mobile"}';
ROLLBACK;
Both conditions are in one Index Cond. Without btree_gin, an integer column cannot be part of a GIN index.
Bloom index for any combination of columns
BEGIN;
CREATE EXTENSION bloom;
CREATE TABLE listing AS
SELECT g AS id, (g * 7) % 1000 AS a, (g * 13) % 1000 AS b, (g * 17) % 1000 AS c, (g * 19) % 1000 AS d
FROM generate_series(1, 20000) g;
CREATE INDEX listing_bloom ON listing USING bloom (a, b, c, d)
  WITH (length = 80, col1 = 2, col2 = 2, col3 = 2, col4 = 2);
ANALYZE listing;
EXPLAIN (COSTS OFF) SELECT * FROM listing WHERE c = 17 AND d = 323;
EXPLAIN (COSTS OFF) SELECT * FROM listing WHERE a = 7 AND b = 13;
SELECT pg_size_pretty(pg_relation_size('listing_bloom')) AS bloom_size;
ROLLBACK;
The same index serves filters on c and d, and on a and b. A B-tree on (a, b, c, d) could not serve c and d without a.
intarray operators and boolean queries
BEGIN;
CREATE EXTENSION intarray;
SELECT '{1,2,3,4}'::int[] & '{3,4,5}'::int[] AS intersection,
       uniq(sort('{3,1,1,2}'::int[])) AS sorted_unique,
       icount('{1,2,3}'::int[]) AS n;
CREATE TABLE post (id int PRIMARY KEY, tag_ids int[] NOT NULL);
INSERT INTO post SELECT g, ARRAY[g % 50, (g * 7) % 50, (g * 11) % 50] FROM generate_series(1, 5000) g;
CREATE INDEX post_tags ON post USING gin (tag_ids gin__int_ops);
ANALYZE post;
EXPLAIN (COSTS OFF) SELECT count(*) FROM post WHERE tag_ids @@ '(3 | 7) & !21';
SELECT count(*) FROM post WHERE tag_ids @@ '(3 | 7) & !21';
ROLLBACK;
query_int expresses and, or and not in one indexable predicate. With plain arrays you would combine several && and NOT @> conditions.

internalsUnder the hood

GIN indexes store each key once with a posting list of row ids, so multicolumn GIN with btree_gin keeps separate key spaces per column and intersects posting lists at query time. A bloom signature sets a few bits per column value in a bit array of length bits; a query computes the same bits and scans every signature, so false positives are rechecked against the heap (Recheck Cond). intarray operators shadow the built-in &&, @> and <@ for int[] once the extension is installed, which can change which index a query uses.

Avoid / preferDo this, not that

Avoid
CREATE INDEX ON listing (a);
CREATE INDEX ON listing (b);
CREATE INDEX ON listing (c);
CREATE INDEX ON listing (d);   -- and every pair, "just in case"
Prefer
CREATE INDEX ON listing USING bloom (a, b, c, d);

For ad hoc equality filters across many columns, one bloom index is far smaller and cheaper to maintain than a B-tree per column or per combination. Keep B-trees for the few hot, selective filters and for range or ORDER BY queries.

Avoid
CREATE EXTENSION intarray;   -- on a database where int[] queries use a GIN array_ops index
Prefer
-- check plans for @> and && on int[] before and after installing intarray,
-- and build gin__int_ops indexes if you adopt it

intarray replaces the operators the existing GIN array_ops index matches, so queries can silently stop using that index.

PitfallsCommon mistakes

  • Expecting a bloom index to serve range conditions, ORDER BY or uniqueness; it only answers equality.
  • Using intarray on arrays that contain NULLs; its functions reject them.
  • Putting a low-selectivity scalar first in a B-tree when a btree_gin multicolumn GIN would serve the combined filter better.
  • Forgetting that bloom indexes support only int4 and text without extra operator classes.

LimitsWhen not to use it

For a few predictable filter combinations, ordinary composite B-tree indexes are faster and support ranges and sorting. For tagging in a normalised schema, a join table with a B-tree index is often simpler than int[] plus intarray.

SecuritySecurity notes

These are indexes, so the risk is operational: every extra index slows writes and grows backups, and GIN indexes can delay writes through their pending list. Review index additions like schema changes and drop unused ones.

ChallengeTry it yourself

With intarray, count posts tagged 5 and 15 but not 25, and show that the count is served by a GIN intarray index.

Show one solution
One solution
BEGIN;
CREATE EXTENSION intarray;
CREATE TABLE post (id int PRIMARY KEY, tag_ids int[] NOT NULL);
INSERT INTO post SELECT g, ARRAY[g % 50, (g * 7) % 50, (g * 11) % 50] FROM generate_series(1, 5000) g;
CREATE INDEX post_tags ON post USING gin (tag_ids gin__int_ops);
ANALYZE post;
EXPLAIN (COSTS OFF) SELECT count(*) FROM post WHERE tag_ids @@ '5 & 15 & !25';
SELECT count(*) FROM post WHERE tag_ids @@ '5 & 15 & !25';
ROLLBACK;

InterviewQuestions you should be able to answer

When would you choose a bloom index?
For a wide table queried with equality filters on varying combinations of many columns, where building a B-tree for every combination is impractical. A bloom index stores a compact signature per row and serves any subset of its columns with one index scan plus heap recheck. It does not support ranges, sorting or uniqueness, and it reads the whole index each time, so it suits ad hoc filtering rather than hot single-column lookups.
What does btree_gin give you?
GIN operator classes for scalar types, so a single multicolumn GIN index can combine a scalar such as customer_id or tenant_id with a JSONB, array or tsvector column. The planner can then satisfy the scalar equality and the containment condition in one index scan instead of combining two indexes or filtering after one.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation