Filter concepts by levelShowing all levels.

PostgreSQL · Other Index Types

Other Index Types

Hash, GiST/SP-GiST, GIN and BRIN — what each is actually for, and the operator-first, workload-first decision procedure for choosing between all five index types

  • Hash: equality onlyB-tree already does this, plus more
  • GiST/SP-GiST: spatial + nearest-neighborORDER BY ... <-> ..., a B-tree cannot express
  • GIN: inverted indexone entry per component — arrays, JSONB, full-text
  • BRIN: tiny, approximateonly pays off with real physical correlation

1.Hash Indexes

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.

Key features

  • Hash indexes support only the = operator — no <, <=, >=, >, no BETWEEN, no ORDER BY support
  • B-tree already supports equality efficiently, in addition to range/sort — Hash offers no capability B-tree lacks, only a narrower one
  • Hash indexes cannot be declared UNIQUE — only B-tree indexes can
  • Since PostgreSQL 10, Hash indexes are WAL-logged (crash-safe) and usable in replication, which was not always true historically — worth knowing if reasoning from older material
  • The practical guidance is straightforward: default to B-tree even for pure-equality workloads; reach for Hash only with a specific, measured reason (rare in practice)

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

Command

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

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.

2.GiST and SP-GiST Use Cases

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.

Key features

  • GiST is a general-purpose extensible framework — its actual operators depend entirely on the operator class chosen for the specific data type
  • GiST supports geometric data (points, boxes, polygons) with operators like <<, &&, @>, <@, and crucially supports nearest-neighbor queries via ORDER BY ... <-> ...
  • SP-GiST supports non-balanced, disk-based partitioning structures — quadtrees, k-d trees, radix trees (tries) — suited to hierarchical or space-partitioned data
  • Both frameworks support nearest-neighbor (distance-ordered) searches when the chosen operator class supports it — a capability neither B-tree nor Hash has any equivalent for
  • PostGIS (the geospatial extension) builds heavily on GiST for its spatial indexing — a common, real-world example of GiST's 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

Command

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;

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.

GIN (Generalized Inverted Index) stores one index entry per COMPONENT value rather than one entry per row — for an array, that means one entry per element; for JSONB, one entry per key/value; for full-text search, one entry per lexeme (word). This inverted structure is exactly what makes "does this array contain X" (@>), "does this JSONB have key Y", and "does this document contain word Z" fast, none of which a B-tree (one entry per whole row value) can answer efficiently.

Key features

  • GIN supports array containment/overlap operators (@>, <@, &&, =) — "does this array contain all of these," "any of these," "overlaps with"
  • GIN supports JSONB containment (@>, <@) and key-existence (?, ?|, ?&) operators — querying nested document structure efficiently
  • GIN supports full-text search via tsvector/tsquery — indexing individual lexemes (normalized words) for fast "contains this word" matching
  • GIN indexes are typically larger than an equivalent B-tree, and per-entry updates can be more expensive — a real write-cost trade-off for the containment-query capability
  • GIN never supports index-only scans (a structural limitation, covered in B-Tree Indexes' index-only-scan-requirements concept) — every GIN-satisfied query still visits the heap

GIN's three primary use cases

GIN's three primary use cases
DataKey operatorsQuestion it answers fast
Array@>, <@, &&does this array contain/overlap with these values?
JSONB@>, <@, ?, ?|, ?&does this document contain this key/value/structure?
tsvector (full-text)@@ (match against tsquery)does this document contain this word/phrase?
GIN inverts the index: components point back at rows

array element

one entry per value inside the array

JSONB key/value

one entry per key, not per document

lexeme (word)

one entry per normalized search term

  • array element — one entry per value inside the array
  • JSONB key/value — one entry per key, not per document
  • lexeme (word) — one entry per normalized search term

Command

sql
CREATE INDEX idx_tags ON products USING gin (tags);              -- array
SELECT * FROM products WHERE tags @> ARRAY['sale'];

CREATE INDEX idx_metadata ON products USING gin (metadata);      -- jsonb
SELECT * FROM products WHERE metadata @> '{"color": "red"}';

CREATE INDEX idx_search ON articles USING gin (to_tsvector('english', body));  -- full-text
SELECT * FROM articles WHERE to_tsvector('english', body) @@ to_tsquery('postgresql');

GIN indexes components (array elements, JSONB keys/values, full-text lexemes) rather than whole row values — an inverted index, the same idea as a search engine's or a book's back index. It is what makes @>/<@/&& containment queries and full-text search fast, at the cost of larger indexes, slower writes, and never supporting index-only scans.

4.BRIN for Very Large, Physically Correlated Tables

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.

Key features

  • BRIN summarizes a RANGE of pages (not one entry per row) with the min and max value seen in that range
  • It supports the same operators as B-tree (<, <=, =, >=, >) but with much coarser precision — it identifies candidate PAGE RANGES, not exact rows
  • BRIN indexes are dramatically smaller than an equivalent B-tree — often orders of magnitude, since it stores a handful of bytes per page range instead of an entry per row
  • BRIN is effective specifically when the column correlates with physical insertion order — an append-only log or time-series table with an ever-increasing timestamp is the textbook case
  • A BRIN index on a column with NO physical correlation (e.g. a randomly-assigned UUID) provides little to no benefit, since min/max per page range would span almost the entire value range regardless of which page range is checked

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
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

Command

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

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.

5.Choosing Indexes by Operator Class and Workload, Not Habit

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.

Key features

  • Different index types support different operators — pg_indexes / \d on a table, or the index-type-specific operator class tables, tell you definitively which operators a given index type actually supports
  • The same query can sometimes be served by more than one index type (e.g. equality by both B-tree and Hash) — in that case, workload considerations (write cost, need for range queries too, uniqueness) decide between them
  • A table's size and write pattern matter as much as the operator — GIN and GiST cost more to maintain than B-tree; BRIN trades precision for tiny size, worthwhile mainly at large scale with real physical correlation
  • "This is the index type I always use" is not a valid justification on its own — the operator classes actually needed by the query should drive the choice, checked explicitly rather than assumed
  • EXPLAIN is the way to confirm, after the fact, whether the chosen index type is actually being used for the query it was intended to help

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

Command

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

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.

Remember

  • 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.
  • GiST/SP-GiST support geometric data and nearest-neighbor (ORDER BY ... <-> ...) queries a B-tree structurally cannot express.
  • GIN indexes components (array elements, JSONB keys, lexemes), not whole values — the mechanism behind fast @>/<@/&& containment and full-text search.
  • BRIN summarizes min/max per page RANGE, not per row — tiny, but only useful when the column correlates with physical row order.
  • Choose an index type by the operator a query actually needs and the real workload — never by habit or convention alone.

Pro tip

  • A B-tree on a jsonb/array column supports whole-value equality only — containment operators (@>) need GIN specifically.
  • Verify BRIN's physical-correlation assumption before choosing it purely for its small size — on uncorrelated data it provides little benefit.
  • EXPLAIN is the final check for whether a chosen index type is actually being used the way it was intended.

Next up

  • Partial, Expression and Covering Indexes
  • Query Planning and EXPLAIN
  • PostgreSQL Query Planner

Sources

  • Index Types — postgresql.org/docs/17/indexes-types.html
  • Every example executed on PostgreSQL 17.11 · checked 2026-08-24

Spotted a mistake on this page? Tell us.