18 Beyond the B-tree
Two indexes on the same column of the same two-million-row table. Both turn a 35,000-page sequential scan into a few hundred pages or less. One is an eighteen-hundredth the size of the other:
The small one is a BRIN index, and whether it’s brilliant or useless depends entirely on how your rows sit on disk. That’s the whole chapter in one example. Every index type answers one question. Ask it that question and it’s the fastest thing in the building. Ask it another and it’s dead weight you pay for on every write.
The B-tree answers “which rows have this value, in order?” Postgres ships five other index types:
| Access method | The question it answers |
|---|---|
| GIN | Which rows contain this element? (jsonb keys, array members, words, trigrams) |
| GiST | Which rows overlap, are near, or contain this shape? (ranges, geometry, similarity) |
| SP-GiST | Same family as GiST, for data that partitions cleanly (prefixes, points, IP ranges) |
| BRIN | Which page ranges could hold this value? (naturally ordered data) |
| Hash | Which rows equal this value? Nothing else. |
As in the B-tree chapter, the evidence is the Buffers: line: 8 KB pages (Buffers are the evidence).
GIN is fast to search and expensive to update
A B-tree has one entry per row, which is useless when you search inside a value: a key in a jsonb document, a tag in an array, a word in a comment. A GIN (Generalized Inverted iNdex) stores one entry per distinct element, followed by the rows that contain it, like the index at the back of a book:
The catch runs the other way: one row produces many entries, so GIN is fast to search and expensive to update.
For containment, jsonb_path_ops beats the default
payload holds documents like {"region": "syd", "status": "failed", "duration_ms": 4242}. Find one duration in one region:
@> means “contains”. GIN has two operator classes for jsonb, and the choice matters:
| Operator class | Index size | Pages, index only | Pages, whole query |
|---|---|---|---|
| no index | 35,173 | ||
jsonb_ops (default) | 20 MB | 987 | 1,068 |
jsonb_path_ops | 14 MB | 121 | 202 |
The default indexes keys and values separately, so this query reads the lists for "region" and "duration_ms", which appear in every row. jsonb_path_ops (using gin (payload jsonb_path_ops)) hashes each value with its path, so duration_ms = 4242 is one rare element.
Prove it · lab 17-more-indexes
The price: it serves containment and jsonpath (@>, @?, @@) but not key existence (? and friends). Keep the default only if you ask “does this key exist?”
Deep dive: What each jsonb operator class can and can’t serve
Without an index, the containment query is a parallel sequential scan (Buffers: shared hit=900 read=34273) for 81 rows. With events_payload_pathops in place, a jsonpath query uses the index:
A key-existence test can’t, and falls back to the sequential scan:
The default class’s numbers, measured:
Prove it · lab 17-more-indexes
A GIN index on payload can’t serve payload->>'status' = 'failed' or (payload->>'duration_ms')::int > 1000. Extract-then-compare needs a B-tree expression index on that exact expression. Write the test as containment (payload @> '{"status": "failed"}') to use GIN, or promote a hot field to a real column (You don’t need another database (yet)).
GIN doesn’t help unselective queries
An index is a shortcut to a few rows. payload @> '{"status": "failed", "region": "syd"}' matches about 100,000 events, 5% of the table, and they sat on 33,343 of its 35,173 pages (Heap Blocks: exact=33343). The “index scan” read nearly everything and was slower than the sequential scan it replaced. “Failures per region” wants a sequential scan, a summary table, or a partial B-tree on the slice you care about.
Prove it · lab 17-more-indexes
Arrays: spell it as containment
GIN works the same way on arrays: on a tags text[] column of projects, tags @> '{gdpr}' read 53 pages instead of 507. The trap is the spelling. 'gdpr' = any (tags) isn’t a GIN operator, so it’s a sequential scan. Write tags @> array['gdpr'] (or && for “any of”).
Deep dive: The same question, two spellings, two plans
Three index pages, then one heap page per tagged project. The = any spelling:
Prove it · lab 17-more-indexes
A full-text GIN search read 51 pages, not 6,671
Full-text search turns text into a tsvector, a sorted list of normalized words (lexemes: “The deploys failed” becomes 'deploy' 'fail'), and GIN indexes the lexemes. On a test table of a quarter-million short comments, a search for “deadlock” read 51 pages instead of all 6,671. The recipe is a stored tsvector column, a GIN index, and @@ to match. Parsing what users type (websearch_to_tsquery) and ranking (which reads every match) belong to Search without a search engine.
Deep dive: A full-text index in five lines
Two index pages; the other 49 are one heap page per matching comment. Pass the configuration ('english') explicitly, for both vector and query, so stemming matches and the generated column stays immutable.
Prove it · lab 17-more-indexes
The pending list makes one insert pay for many
GIN parks new entries in an unsorted pending list and merges them in bulk. The list is on by default (fastupdate) and grows to gin_pending_list_limit, 4 MB. Whichever insert overflows it pays to merge all four megabytes. The symptom: fast inserts, and the occasional one that takes hundreds of milliseconds. The fix: a smaller gin_pending_list_limit on that index, or fastupdate = off to pay a steady cost instead of a spiky one.
Deep dive: Watching the pending list grow
Insert 5,000 events (copies of the newest ones) and look at the jsonb_path_ops index with pgstatginindex from pgstattuple:
Every search now scans those 37 pages linearly as well: the containment query went from 121 index pages to 158. Vacuum merges the list, as does select gin_clean_pending_list('events_payload_pathops').
Prove it · lab 17-more-indexes
pg_trgm makes a leading wildcard indexable
A B-tree is sorted from the start of the string, so at best it serves a prefix like like 'user42%' (and only with the right operator class, as the next section shows), never like '%r4242@%'. The pg_trgm extension makes substring search indexable by breaking strings into trigrams, every three-character sequence (show_trgm('user4242')):
A string containing r4242@ must contain that fragment’s trigrams, so the index intersects their lists and rechecks the few candidates. Without it, searching by an email fragment read all 1,126 pages of users. With it:
Ten pages, and that was ilike (~~*): the same index serves case-insensitive matches and regular expressions. A pattern needs three fixed characters to have a trigram to look up.
Prefix search needs the right operator class
Even like 'user4242@%' couldn’t use a plain B-tree on email. Under a non-C collation like this database’s en_US.utf8, B-tree order isn’t byte order, so a prefix isn’t guaranteed to be one contiguous range. Build the index with text_pattern_ops (byte-wise order) and the prefix becomes a range:
That index can’t serve <, >, or order by in the database collation. Need both? Build both, or use the C collation on the column.
GIN filters trigrams; GiST ranks by similarity
pg_trgm also has a GiST operator class. For like and regex, GIN wins outright: the GiST index was three times the size (11 MB against 3,504 KB) and read 163 pages where GIN read 10.
But GiST does one thing GIN can’t: return rows in order of similarity (order by email <-> 'usr4242@exmaple.com' limit 5), the “did you mean” query. That’s a nearest-neighbor search, walking the tree in distance order (PostGIS does the same for geography). GIN for filtering, GiST for nearest-match ordering.
Deep dive: Did you mean: similarity ordering with GiST
<-> is trigram distance (1 minus similarity):
With the GiST index, the plan is an index scan ordered by distance:
Trigram index on users.email | Size | Build time (our machine) | Pages for like '%r4242@%' |
|---|---|---|---|
GIN (gin_trgm_ops) | 3,504 KB | 140 ms | 10 |
GiST (gist_trgm_ops) | 11 MB | 550 ms | 163 |
Without the GiST index, Postgres computes all 100,000 distances and sorts: about 90 ms against 15 ms on our machine. The index touched more pages (1,427) than a sequential scan of the 1,126-page table, because these synthetic emails look alike; it saved CPU, since computing similarity is the expensive part. Data with more variety should prune better.
Prove it · lab 17-more-indexes
GiST indexes values that have no order
A B-tree needs a total order, and many types don’t have one: which comes first, 9:00 to 17:00 or 12:00 to 13:00? GiST (Generalized Search Tree) indexes them anyway. Each internal entry stores a bounding value covering everything beneath it, and a search descends into every child whose bound could match.
Its best-known job is enforcing “no two of these may overlap”, the exclusion constraint, which builds a GiST index to check every insert against. Say projects can declare deploy freezes, at most one at a time per project:
That index hides a lesson. With 480,000 freezes loaded, “which projects were frozen at this moment?” read 7,188 pages through the constraint’s index, which leads with project_id: more than a sequential scan’s 4,548. A GiST index on during alone needed 460, only 7 of them in the index. The index behind a constraint isn’t automatically a good index for your queries.
Deep dive: Why the constraint’s index lost to a sequential scan
GiST has no strict “leading column”, but the first column still decides how entries group into pages, so a query on during alone explores nearly the whole index. With create index deploy_freezes_during_idx on deploy_freezes using gist (during):
GiST range operators include && (overlaps), @> (contains), <@ (contained by), and the adjacency and strictly-left/right operators. The checks below also confirm that the constraint does its job: a New Year freeze inside an existing holiday freeze is refused with exclusion_violation.
Prove it · lab 17-more-indexes
You’ll rarely need SP-GiST
Where SP-GiST fits, a B-tree or GiST usually does about as well, and you already know how those behave. SP-GiST (space-partitioned GiST) is for data that splits into non-overlapping regions (prefixes, points, IP networks), so a search follows one path. On users.email it answered a starts-with query (email ^@ 'user4242@') in 5 pages from a 4,672 KB index, about a B-tree’s size. For inet containment and ranges it’s an alternative to GiST; benchmark both.
BRIN keeps one summary per range of pages
Back to the opening. events is append-only: rows arrive in created_at order and never move. pg_stats.correlation measures how well a column’s order matches the physical row order: 1 for created_at and id, about 0.003 for project_id.
A BRIN (Block Range INdex) cashes in on it. It stores one summary per range of pages_per_range pages (128 by default): the smallest and largest value. Think of labels on moving boxes. A query for one day skips every box whose label can’t match, opens the one or two that might, and checks every row inside.
Each label covers about a day and a third of events. About 275 labels for 35,000 pages: hence 24 KB.
A thousandth of the size, nearly the same pages
A per-kind summary of one day of events:
Index on created_at | Size | Pages for one day |
|---|---|---|
| none | 0 | 35,272 |
| BRIN, 128 pages per range | 24 KB | 261 |
| BRIN, 32 pages per range | 48 KB | 133 |
| BRIN, 8 pages per range | 168 KB | 130 |
| B-tree | 43 MB | 115 |
The B-tree wins on pages, narrowly. But it’s 1,800 times larger than the default BRIN, competes with your data for cache, and every insert descends it. BRIN at 32 pages per range comes within 20 pages of it at under a nine-hundredth of the size. Smaller ranges stop paying: at 8 pages per range, the index itself read 26 pages instead of the 5 it read at 32 pages per range, nearly cancelling the 24 heap pages it saved.
Deep dive: Reading a BRIN plan: lossy pages and rows that aren’t rows
The day is created_at >= '2026-05-01' and created_at < '2026-05-02', grouped by kind. With create index events_created_brin on events using brin (created_at) and parallelism off:
BRIN bitmaps are always lossy (they name pages, never rows), so the heap scan reads every row on 256 pages and the recheck discards 9,078. The rows=2560 on the BRIN node isn’t a row count: BRIN reports ten per matching page as a placeholder (totalpages * 10 in brin.c). pageinspect shows the labels themselves (brin_page_items(get_raw_page('events_created_brin', 2), 'events_created_brin')):
Prove it · lab 17-more-indexes
BRIN is useless without physical order
On project_id, whose correlation is zero, every box holds nearly every project, so every box “might match”. Force a BRIN index there and it reads every page of the table anyway (Buffers: shared hit=50 read=35266).
Use BRIN for columns that track insertion order: timestamps on append-only tables, sequence ids, log offsets (Partition for the delete pairs it with time partitions). Protect the order: rows moved by updates, backfilled old data, and new rows reusing freed space all widen the labels until the index stops pruning.
Vacuum, or brin_summarize_new_values(), summarizes page ranges filled after the index was built; until then, those ranges are always scanned. On a busy append-only table, create BRIN indexes with (autosummarize = on).
A hash index wins only on equality over long keys
Use a hash index for equality on long random values like tokens and content hashes. Everywhere else, use a B-tree.
A hash index stores a 32-bit hash of each key and supports exactly one operator: =. No ranges, ordering, uniqueness, or index-only scans. In exchange, its size doesn’t depend on the key’s width. On a 128-character API key, the B-tree was 16 MB and the hash index 4,112 KB, and a lookup touched 3 pages instead of 5. On email, about 20 characters, the hash index was the same 4,112 KB and the B-tree smaller. Hash indexes have been crash-safe since PostgreSQL 10; their bad reputation is older.
Deep dive: How the hash index was measured
Prove it · lab 17-more-indexes
The operator picks the index type
| You query with | Use |
|---|---|
=, <, >, order by, prefix like | B-tree (text_pattern_ops for prefixes in non-C collations) |
jsonb @>, @? | GIN jsonb_path_ops |
jsonb key existence (?) | GIN jsonb_ops (the default) |
jsonb ->> with =, <, sort | B-tree expression index, or a real column |
array @>, && | GIN (not x = any (col)) |
full-text @@ | GIN on a stored tsvector |
like '%x%', ilike, regex | GIN gin_trgm_ops |
| “did you mean” | GiST gist_trgm_ops |
| range overlap, exclusion | GiST (plus btree_gist to mix in =) |
| time or id on append-only data | BRIN, if correlation is near 1 |
= on long random keys | hash |
Whichever you pick, measure pages before and after. Every one of these, GIN most of all, makes writes more expensive, so keep only the ones that earn an order of magnitude.
The short version
- GIN indexes the elements inside a value. Fast to search, expensive to update; spiky insert latency means the pending list.
- For jsonb containment use
jsonb_path_ops: 121 index pages instead of 987. GIN can’t servepayload->>'key' = 'x'. - For
like '%x%'use a trigram GIN index; trigram GiST only for similarity ordering. - GiST handles overlap and distance. The index behind a constraint isn’t automatically a good one for your queries.
- BRIN on a naturally ordered column is tiny (24 KB against a 43 MB B-tree) and nearly as good for ranges; on an uncorrelated column it reads the whole table.
- Hash indexes are worth it only for equality on long keys.
- Any index helps only selective queries: a GIN lookup matching 5% of the rows still read 95% of the pages.
Further reading
- Index Types, PostgreSQL documentation. The official overview of every access method and its operators.
- GIN Indexes, GiST Indexes, SP-GiST Indexes, BRIN Indexes, and Hash Indexes, PostgreSQL documentation. One reference page per type, including the built-in operator classes.
- src/backend/access/brin/README, PostgreSQL source. How the range map and summaries are laid out, and why a summarizing index can be so small.
- pg_trgm, PostgreSQL documentation. Trigram operators, similarity thresholds, and the GIN and GiST operator classes.
- jsonb Indexing, PostgreSQL documentation.
jsonb_opsversusjsonb_path_ops, with the operators each supports. - Operator Classes and Operator Families, PostgreSQL documentation. Where
text_pattern_opsand friends are explained.