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

GIN, GiST, BRIN and hash indexes

Level 4: Performance25 min

B-trees cover ordered scalar data. For documents, arrays, text, geometry, ranges and huge append-only tables, PostgreSQL offers other index types, each built for a specific shape of data and query.

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
  • Match GIN, GiST, SP-GiST, BRIN and hash indexes to their use cases
  • Use BRIN for very large, naturally ordered tables
  • Find unused and duplicate indexes

conceptThe concept

GIN (generalized inverted index) maps each element (a jsonb key or value, an array element, a lexeme, a trigram) to the rows containing it. Great for "contains" queries; slower to update. GiST is a balanced tree of bounding predicates: ranges, geometry, nearest-neighbour (ORDER BY x <-> y) and exclusion constraints. SP-GiST suits space-partitioned data such as IP addresses or points.

BRIN (block range index) stores only a min/max summary per range of pages. It is tiny and effective only when values correlate with physical order, such as timestamps in an append-only log. The planner reads the matching page ranges and rechecks every row in them.

Hash indexes support only equality and are WAL-logged and crash-safe since 10; they can be smaller than a B-tree on long keys but cannot enforce uniqueness across multiple columns or support ranges or sorting, so B-tree is usually still the default choice.

whyWhy it matters

A 2 TB event table with a B-tree on its timestamp carries a B-tree of tens of gigabytes. A BRIN index on the same column can be a few megabytes and still prune almost everything for a one-day query.

Use caseReal-world scenario

The click-stream events table only ever grows in time order, so a BRIN index on at fits. Product attributes use GIN, and the maintenance calendar uses GiST for its exclusion constraint.

Try itExamples

Run and edit these in the interactive lesson.

Is the table ordered by this column?
SELECT tablename, attname, round(correlation::numeric, 3) AS correlation
FROM pg_stats
WHERE schemaname = 'public' AND attname IN ('at', 'created_at', 'customer_id')
ORDER BY tablename, attname;
BRIN on an append-only time column
BEGIN;
CREATE INDEX events_at_brin ON events USING brin (at) WITH (pages_per_range = 8);
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events WHERE at >= '2025-12-01' AND at < '2025-12-02';
SELECT pg_size_pretty(pg_relation_size('events_at_brin')) AS brin_size,
       pg_size_pretty(pg_relation_size('events_pkey')) AS btree_pk_size;
ROLLBACK;
Heap Blocks: lossy and Rows Removed by Index Recheck show BRIN's trade-off: it narrows the search to a few page ranges, then rechecks every row in them.
GIN for arrays
BEGIN;
CREATE INDEX products_tags_gin ON products USING gin (tags);
SET LOCAL enable_seqscan = off;
EXPLAIN SELECT name FROM products WHERE tags @> '{eco,sale}';
ROLLBACK;
Which indexes are never used?
SELECT s.relname AS table_name, s.indexrelname AS index_name, s.idx_scan,
       pg_size_pretty(pg_relation_size(s.indexrelid)) AS size,
       i.indisunique AS is_unique
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
ORDER BY s.idx_scan, pg_relation_size(s.indexrelid) DESC;
On a real server, look at this after a full business cycle and on every replica before dropping anything. Unique indexes enforce rules even when idx_scan is 0.

internalsUnder the hood

pg_stats.correlation near 1 or -1 means physical order follows the column, which is what BRIN needs. Updates and deletes followed by inserts into freed space scramble the order and weaken BRIN over time. pages_per_range trades index size for precision.

Avoid / preferDo this, not that

Avoid
CREATE INDEX ON products (attrs);
SELECT * FROM products WHERE attrs @> '{"brand": "Acme"}';
Prefer
CREATE INDEX ON products USING gin (attrs jsonb_path_ops);

A B-tree on a jsonb column only supports equality of the whole document. Containment needs GIN.

Avoid
CREATE INDEX ON customers USING brin (email);
Prefer
CREATE INDEX ON events USING brin (at);  -- physically ordered column

BRIN stores min and max per block range, so it only helps when values correlate with physical order, as append-only timestamps do.

PitfallsCommon mistakes

  • Using BRIN on a column whose values are scattered across the table. It will read most of the table anyway.
  • Expecting a GIN index to support ordering or ranges. It answers membership questions only.
  • Dropping an index because idx_scan is 0 on the primary while a replica uses it for reporting. Statistics are per server.

LimitsWhen not to use it

Hash indexes rarely beat B-tree in practice: they only support equality, cannot be unique on multiple columns, and offer no ordering. Choose them only after measuring.

SecuritySecurity notes

Indexes hold copies of the indexed values. Dropping unused indexes on sensitive columns reduces the places that data lives, and indexes on encrypted columns should be on hashes, not plaintext.

ChallengeTry it yourself

Refresh statistics for events, then list its columns by how closely their values follow the physical row order. Highly correlated columns are BRIN candidates.

Show one solution
One solution
ANALYZE events;
SELECT attname, round(correlation::numeric, 3) AS correlation
FROM pg_stats
WHERE tablename = 'events'
ORDER BY abs(correlation) DESC NULLS LAST;

InterviewQuestions you should be able to answer

When would you use BRIN instead of B-tree?
For very large tables where the indexed column is strongly correlated with physical row order, typically an append-only timestamp or sequence. BRIN stores min/max per block range, so it is orders of magnitude smaller and almost free to maintain, and it prunes non-matching ranges well. It is lossy, so matching ranges are fully rechecked; it is poor for point lookups or randomly ordered data.
GIN versus GiST?
GIN is an inverted index: fast lookups for "which rows contain element X" (jsonb, arrays, full-text, trigrams), larger and slower to update, with a pending list to batch inserts. GiST is a tree of overlapping summaries: supports ranges, geometric types, nearest-neighbour ordering and exclusion constraints, generally faster to build and update but slower for containment searches over many elements. For full text, GIN is typical for read-heavy search and GiST for frequently updated data.

QuizCheck your understanding

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

Go deeperOfficial documentation