Filter concepts by levelShowing all levels.

PostgreSQL · Section 22

Other Index Types

Level
advanced
Read
32 min
Concepts
5

The four index types beyond the B-tree default: Hash (equality only, rarely worth choosing over B-tree), GiST and SP-GiST (extensible frameworks for spatial data and nearest-neighbor queries a B-tree cannot express), GIN (an inverted index over components — array elements, JSONB keys, full-text lexemes — that makes containment and search operators fast), and BRIN (tiny page-range summaries that pay off specifically when a column correlates with physical row order). Closes with the explicit operator-first, workload-first decision procedure for choosing among all five index types rather than defaulting to habit.

PostgreSQL overview

What is true here

  1. A Hash index supports only equality — B-tree already covers that plus range/sort, so B-tree remains the default even for equality-only workloads.
  2. GiST/SP-GiST support geometric data and nearest-neighbor (nearest-first ORDER BY) queries a B-tree structurally cannot express.
  3. GIN indexes components rather than whole values — the mechanism behind fast array/JSONB containment and full-text search.
  4. BRIN summarizes min/max per page range, not per row — tiny, but only useful with real physical correlation to row order.
  5. Choose an index type by the operator a query actually needs and the real workload, verified with EXPLAIN — never by habit alone.

What you will be able to do

  • Explain why Hash indexes are rarely chosen over B-tree despite supporting the same equality comparisons
  • Recognize when a nearest-neighbor or spatial query needs GiST/SP-GiST instead of a B-tree
  • Choose GIN for array/JSONB containment or full-text search, and explain why a B-tree cannot serve the same queries
  • Judge whether BRIN's physical-correlation requirement actually holds for a given column before choosing it
  • Apply the operator-first, workload-first decision procedure to justify an index type choice explicitly
From B-tree's limits to the right specialized type
match the operator to atype that supports itverify,don't assume

B-tree can't do this operator

containment, distance, or pure equality at scale

A specialized type fits

GiST/SP-GiST, GIN, or BRIN

Confirm with EXPLAIN

operator + workload drove the choice, not habit

  • B-tree can't do this operator — containment, distance, or pure equality at scale
    • leads to A specialized type fits (match the operator to a type that supports it)
  • A specialized type fits — GiST/SP-GiST, GIN, or BRIN
    • leads to Confirm with EXPLAIN (verify, don't assume)
  • Confirm with EXPLAIN — operator + workload drove the choice, not habit

The narrower option

Hash indexes, and why B-tree usually wins even for pure equality.

Hash Indexes

standardintermediate

A Hash index stores a hash code of the indexed value and supports ONLY equality (=) comparisons — no ranges, no ORDER BY, no BETWEEN. Because a B-tree already handles equality just as well while also supporting range queries and sorting, Hash indexes are rarely the right choice in practice; B-tree is preferred even for pure-equality workloads unless a specific, measured reason says otherwise.

Think of it as

A Hash index is a narrower tool than a B-tree in every dimension except one: for pure equality lookups on very simple types, its lookup can theoretically be marginally cheaper since a hash comparison is simpler than a tree traversal. In practice, this theoretical edge rarely translates into a compelling reason to give up everything a B-tree offers (range queries, sorting, uniqueness) for a type of comparison a B-tree already handles well — which is exactly why Hash indexes are the least commonly reached-for index type in ordinary schema design.

sql
CREATE INDEX idx_hash_status ON orders USING hash (status);

SELECT * FROM orders WHERE status = 'pending';  -- usable
SELECT * FROM orders WHERE status > 'pending';  -- NOT usable -- Hash has no range support

What we're doing: Confirm a Hash index is rejected for a range query that a B-tree on the same column would happily serve.

hash_no_range_support.sqlsql
CREATE INDEX idx_hash_status ON orders USING hash (status);

EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- Index Scan using idx_hash_status -- equality works fine

EXPLAIN SELECT * FROM orders WHERE status > 'pending';
-- Seq Scan -- the Hash index CANNOT be used for a range comparison,
-- regardless of how the planner would like to use it
3–4
Exactly the one thing a Hash index supports — equality — works as expected.
6–8
A range comparison, something B-tree handles trivially, has no possible plan using this Hash index at all.
Output
-- equality: Index Scan using the hash index
-- range: Seq Scan -- no hash-index plan exists for this query shape

Why this works: This demonstrates the Hash index's real limitation directly: it is not merely less efficient for a range query, it is structurally incapable of answering one at all — there is no plan node that could use a Hash index for anything other than an equality comparison, which is exactly the trade-off that makes B-tree the safer default even for equality-heavy workloads.

Choosing a Hash index for a column expecting to need range queries later

Wrong

sql
-- current queries only ever check equality:
CREATE INDEX idx_hash_status ON orders USING hash (status);
-- weeks later, a new feature needs "status > 'processing'" style
-- filtering -- the Hash index cannot serve it at all

Better

sql
-- default to B-tree, which loses nothing for the current equality
-- use case and remains available if a range query is ever needed:
CREATE INDEX idx_status ON orders (status);

What you see: A new query pattern (a range filter, or an ORDER BY) that a B-tree would have served automatically instead requires an entirely new index to be created on the same column, because the existing Hash index cannot be adapted or reused for it.

Why: Choosing Hash instead of B-tree gives up range and sort support with no offsetting benefit for most real workloads, which is exactly why B-tree is the recommended default even when the CURRENT query pattern is equality-only — a B-tree costs nothing extra for that case while staying flexible for query patterns that may emerge later.

Hash vs B-tree, for an equality-only workload

Hash vs B-tree, for an equality-only workload
PropertyHashB-tree
Equality (=)yesyes
Range (<, >, BETWEEN)noyes
ORDER BY supportnoyes
Can be UNIQUEnoyes

Remember: A Hash index supports only equality (=) — no ranges, no ORDER BY, and it cannot be UNIQUE. B-tree already handles equality just as well while also supporting everything Hash cannot, which is why B-tree remains the default even for equality-only workloads — reach for Hash only with a specific, measured reason.

See also: the default b tree index · gist and sp gist use cases

Advertisement

What B-tree cannot express

Spatial/nearest-neighbor queries, containment across multi-valued data, and enormous physically-correlated tables.

GiST and SP-GiST Use Cases

standardadvanced

GiST (Generalized Search Tree) and SP-GiST (Space-Partitioned GiST) are both extensible index FRAMEWORKS rather than one fixed comparison strategy — they support geometric/spatial data, nearest-neighbor searches (ORDER BY distance), and other specialized operator classes that a B-tree's simple linear ordering cannot express. SP-GiST specifically supports non-balanced, space-partitioning structures (like quadtrees or tries) suited to hierarchical or unevenly-distributed data; GiST is the more general-purpose of the two.

Think of it as

Both exist because some data does not have a single, natural, total ordering the way numbers or strings do — a 2D point, a geometric shape, an IP address range — so a plain B-tree's "is this bigger or smaller" comparison model does not apply. GiST and SP-GiST are frameworks specifically designed to plug in a custom notion of "nearness" or "containment" for exactly this kind of data, which is what makes nearest-neighbor queries (ORDER BY location <-> point(...)) possible at all — a B-tree has no way to express "closest to this point."

sql
CREATE INDEX idx_location ON places USING gist (location);

-- nearest-neighbor query -- a capability unique to GiST/SP-GiST-class indexes
SELECT name FROM places ORDER BY location <-> point '(101,456)' LIMIT 10;

What we're doing: Show a nearest-neighbor query using a GiST index, and confirm via EXPLAIN that the index (not a sort over a full scan) is what makes it efficient.

gist_nearest_neighbor.sqlsql
CREATE TABLE places (id SERIAL PRIMARY KEY, name text, location point);
CREATE INDEX idx_location ON places USING gist (location);

EXPLAIN SELECT name FROM places
 ORDER BY location <-> point '(101,456)' LIMIT 10;
-- Limit
--   ->  Index Scan using idx_location on places
--         Order By: (location <-> '(101,456)'::point)
2
A B-tree could not do this -- there's no meaningful "less than" relationship between two 2D points to sort by, only a distance from a reference point.
4–7
The plan shows the GiST index directly driving the ORDER BY ... <-> ... distance sort, not a separate sort step over a full table scan.
Output
-- plan shows "Index Scan using idx_location" with "Order By: (location <-> ...)"
-- -- the index itself is answering the nearest-neighbor question

Why this works: This is the concrete demonstration of what B-tree fundamentally cannot do: there is no total ordering of 2D points that a B-tree could sort by, but GiST's operator class for point data specifically understands distance, which is what lets the planner push the ORDER BY ... <-> ... directly into the index scan instead of computing distances for every row and sorting afterward.

Trying to answer a nearest-neighbor query with a B-tree index, or no index at all

Wrong

sql
-- no spatial index at all:
SELECT name FROM places
 ORDER BY location <-> point '(101,456)' LIMIT 10;
-- forces a full sequential scan computing the distance for EVERY row,
-- then sorting all of them, just to return the closest 10

Better

sql
CREATE INDEX idx_location ON places USING gist (location);
-- the same query now uses the index to find the nearest points
-- directly, without computing distance for every row in the table

What you see: A "find the 10 nearest locations" query scales badly as the table grows, with EXPLAIN showing a full sequential scan and a sort over the entire table just to return a handful of results.

Why: Without a GiST (or SP-GiST) index specifically built for the data's spatial structure, PostgreSQL has no way to avoid computing the distance for every single row before it can determine which ones are closest — the index is what lets it navigate directly toward nearby points instead, which is the entire reason this index family exists for this use case.

GiST vs SP-GiST

GiST vs SP-GiST
PropertyGiSTSP-GiST
Structurebalanced tree, extensible per operator classnon-balanced, space-partitioned (quadtree/trie-like)
Good fitgeometric data, general extensibilityhierarchical/unevenly-distributed data
Nearest-neighbor supportyes, per operator classyes, per operator class

Remember: GiST and SP-GiST are extensible index frameworks, not fixed comparison strategies — both support geometric/spatial data and nearest-neighbor (ORDER BY ... <-> ...) queries a B-tree cannot express at all. SP-GiST specifically suits non-balanced, hierarchical/space-partitioned data (quadtrees, tries); GiST is the more general-purpose of the two, and underlies PostGIS.

See also: hash indexes · gin for arrays jsonb and full text search

BRIN for Very Large, Physically Correlated Tables

coreadvanced

BRIN (Block Range INdex) stores only a min/max summary per range of physical pages, rather than one entry per row — dramatically smaller than a B-tree, but only useful when the indexed column's values correlate with physical row order (like an ever-increasing timestamp in an append-only log table). It trades precision for size: a BRIN lookup narrows down to which page RANGES might contain a match, then still has to check those pages directly.

Think of it as

A B-tree makes a promise: exact, precise navigation to any matching row. BRIN makes a much cheaper, coarser promise instead: "rows with this value live somewhere in this range of pages" — useful specifically because for physically-correlated data (rows inserted in roughly increasing timestamp order, for instance), that coarse promise is enough to skip huge swaths of the table entirely, at a tiny fraction of a B-tree's storage cost. The trade only pays off when correlation genuinely holds — a column with no relationship to physical row order gives BRIN nothing useful to summarize.

sql
-- effective: an append-only log, timestamp roughly matches insertion order
CREATE INDEX idx_brin_created_at ON event_log USING brin (created_at);

SELECT * FROM event_log WHERE created_at > now() - interval '1 hour';
-- BRIN quickly skips page ranges that couldn't possibly contain matches

What we're doing: Compare BRIN's index size dramatically against an equivalent B-tree on the same large, physically-correlated column.

brin_size_comparison.sqlsql
CREATE TABLE event_log (id BIGSERIAL PRIMARY KEY, created_at timestamptz, payload text);
INSERT INTO event_log (created_at, payload)
  SELECT now() - (n || ' seconds')::interval, 'data'
  FROM generate_series(1, 10000000) n;  -- 10 million rows, chronological order

CREATE INDEX idx_btree_created_at ON event_log USING btree (created_at);
SELECT pg_size_pretty(pg_relation_size('idx_btree_created_at'));
-- 214 MB

CREATE INDEX idx_brin_created_at ON event_log USING brin (created_at);
SELECT pg_size_pretty(pg_relation_size('idx_brin_created_at'));
-- 152 kB -- roughly 1400x smaller
1–4
A large, append-only table where created_at closely tracks physical insertion order — exactly BRIN's intended case.
6–7
The B-tree, one entry per row, costs real, substantial storage at this scale.
9–10
BRIN, one summary per page range, costs a tiny fraction of that — while still supporting the same range queries usefully, BECAUSE the correlation holds.
Output
pg_size_pretty
----------------
214 MB

pg_size_pretty
----------------
152 kB

Why this works: This size difference is BRIN's entire value proposition made concrete — at 10 million rows, a B-tree costs a substantial, real amount of disk space, while BRIN, relying on the fact that created_at values are physically clustered by insertion order, achieves useful filtering for a tiny fraction of that cost.

Using BRIN on a column with no physical correlation to row order, expecting the same benefit

Wrong

sql
-- user_id is essentially RANDOM relative to physical row order --
-- rows for any given user could be scattered anywhere in the table
CREATE INDEX idx_brin_user_id ON event_log USING brin (user_id);
SELECT * FROM event_log WHERE user_id = 42;
-- BRIN provides almost NO benefit -- nearly every page range's
-- min/max spans the full range of user_id values, so almost no
-- page ranges can be safely skipped

Better

sql
-- for a column with no physical correlation, a B-tree remains
-- the appropriate choice despite its larger size:
CREATE INDEX idx_btree_user_id ON event_log (user_id);
-- or reconsider whether user_id needs a dedicated index at all,
-- based on actual query patterns

What you see: A BRIN index created "for its small size" provides little to no query speedup, because the indexed column's values are scattered essentially randomly across the table's physical pages, leaving BRIN's per-page-range min/max summaries too broad to skip anything useful.

Why: BRIN's entire benefit depends on physical correlation between the indexed column and row placement — without it, nearly every page range ends up containing close to the full spread of possible values, meaning BRIN cannot rule out almost any page range as a non-match, which defeats its purpose while still costing index maintenance on every write.

BRIN summarizes a page range, not a row

page range 1

min/max: 00:00–00:05

page range 2

min/max: 00:05–00:10

query for 00:04

skips range 2 entirely — only range 1 could match

  1. page range 1 — min/max: 00:00–00:05
  2. page range 2 — min/max: 00:05–00:10
  3. query for 00:04 — skips range 2 entirely — only range 1 could match

BRIN vs B-tree

BRIN vs B-tree
PropertyBRINB-tree
Entry granularityone summary per page RANGEone entry per row
Precisionapproximate — candidate page ranges onlyexact
Index sizevery smallmuch larger
Requiresphysical correlation with the indexed columnnothing special — works for any data

Remember: BRIN stores a min/max summary per RANGE of physical pages, not one entry per row — dramatically smaller than a B-tree, but only useful when the indexed column correlates with physical row order (an append-only log's timestamp is the textbook case). On uncorrelated data, BRIN provides little to no benefit — verify correlation before choosing it purely for its small size.

See also: gin for arrays jsonb and full text search · choosing indexes by operator class and workload

Advertisement

Choosing deliberately

The explicit operator-first, workload-first decision procedure that ties the whole section together.

Choosing Indexes by Operator Class and Workload, Not Habit

standardadvanced

The right index type is determined by which OPERATORS a query actually needs (equality only? range? containment? distance?) and the real workload shape (write frequency, table size, correlation with physical order) — never by defaulting to whatever index type was used last time, or by choosing the type with the most impressive-sounding capabilities.

Think of it as

Every index type in this section exists because it makes a specific class of operator fast at a specific cost — B-tree for equality/range/sort, Hash for pure equality (rarely worth it over B-tree), GiST/SP-GiST for spatial/nearest-neighbor, GIN for containment across multi-valued data, BRIN for enormous, physically-correlated tables where size is the binding constraint. The actual decision procedure is: identify the operator the query genuinely needs (=, range, @>, <->, ...), then check which index type(s) support that operator at all, THEN weigh the remaining candidates by workload (write cost, table size, correlation) — not the reverse of picking a familiar type first and hoping it fits.

sql
-- the decision procedure, made explicit:
-- 1. what operator does the query actually use?      (=, range, @>, <->, ...)
-- 2. which index type(s) support that operator?       (check indexes-types docs)
-- 3. of those, which fits the workload?                (write cost, size, correlation)
CREATE INDEX idx_tags ON products USING gin (tags);  -- @> needs GIN, not habit

What we're doing: Walk through the decision procedure for a real query, rejecting a habitual B-tree choice in favor of the index type the actual operator requires.

operator_driven_choice.sqlsql
-- query in question:
-- SELECT * FROM products WHERE tags @> ARRAY['sale'];

-- STEP 1: the operator is @> (array containment)
-- STEP 2: which index types support @> on an array? -- GIN (and some GiST cases)
--          -- B-tree does NOT support @> on an array at all
-- STEP 3: workload -- moderate write frequency, containment queries common
--          -- GIN is the appropriate choice; no further alternative needed here

CREATE INDEX idx_tags ON products USING gin (tags);  -- follows directly
                                                       -- from the operator, not habit
3–3
Identifying the actual operator is step one — everything else follows from it.
4–6
Checking which index types even support that operator rules out B-tree immediately, regardless of habit.
9
The final choice is a direct, traceable consequence of the operator the query needs — not a default reached for out of familiarity.
Output
-- the index choice is justified by the operator (@>), not habit

Why this works: Working through operator → supporting index types → workload fit as an explicit sequence, rather than reaching for a familiar default, is what actually prevents the mismatched-index mistakes covered throughout this section (a B-tree that cannot do containment, a Hash index that cannot do range, a BRIN index with no physical correlation to exploit) — each of those mistakes is exactly what skipping this procedure produces.

Defaulting to B-tree for every index "because that's what we always use," even when the query's operator needs something else

Wrong

sql
-- team convention: "we always use B-tree indexes here"
CREATE INDEX idx_tags ON products (tags);  -- B-tree, applied to an array column
SELECT * FROM products WHERE tags @> ARRAY['sale'];
-- Seq Scan -- B-tree cannot support @> at all, regardless of convention

Better

sql
-- check what operator the query actually needs FIRST:
CREATE INDEX idx_tags ON products USING gin (tags);  -- @> needs GIN
SELECT * FROM products WHERE tags @> ARRAY['sale'];
-- Index Scan (via bitmap) -- because the index type actually matches
-- the operator the query uses

What you see: A newly-added index provides zero query benefit and EXPLAIN continues to show a sequential scan, because the index type was chosen out of habit rather than by checking whether it actually supports the operator the query relies on.

Why: An index's usefulness is entirely gated on whether its type supports the specific operator a query needs — no amount of habit, convention, or general familiarity with one index type changes what operators it can actually accelerate, which is exactly why this concept's guidance (operator and workload first, habit never) is the correct decision procedure rather than a stylistic preference.

A quick operator-to-index-type map

A quick operator-to-index-type map
Operator/needIndex type(s) that support it
= onlyB-tree (default choice), Hash (rarely worth it)
<, <=, >=, >, BETWEEN, ORDER BYB-tree
Nearest-neighbor (<->)GiST, SP-GiST
Array/JSONB containment (@>, <@, &&)GIN, sometimes GiST
Full-text search (@@)GIN
Range query on a huge, physically-correlated columnBRIN

Remember: Choose an index type by working through: (1) what operator does the query actually need, (2) which index type(s) support that operator at all, (3) which of those fits the workload (write cost, size, correlation). "This is the type we always use" is never sufficient justification on its own — verify the operator support explicitly, and confirm the result with EXPLAIN.

See also: gin for arrays jsonb and full text search · brin for large physically correlated tables

Advertisement