How PostgreSQL Selects Between B-Tree and GIN Indexes
When PostgreSQL runs a query, its query planner, the part of PostgreSQL that chooses how to retrieve rows, decides how to find…
When a PostgreSQL query searches inside a JSON document, array, or full-text document, a standard B-tree index often does not fit the search. B-trees work well with single values, called scalar values, such as an ID or date, and with queries that return rows in sorted order. GIN is a strong starting point when a query looks for parts of a value. GiST fits searches that use a tree of summaries, including range overlap, geometric conditions, and supported nearest-neighbor searches.
Choose an index based on the operators your queries use and the operator class attached to it. An operator class tells PostgreSQL how an index type handles a particular data type and its operators. GIN and GiST differ in write costs and storage use. They also differ in how much work PostgreSQL needs to check candidate rows. Match the index to your query, then test it with representative data.
GIN, short for Generalized Inverted Index, links parts of a value to the table rows that contain them. For example, a GIN index can link a word to documents that contain it, or an array element to rows that contain that element. PostgreSQL describes GIN as an inverted index and recommends it for full-text search.
For full-text search, start with GIN on a tsvector, PostgreSQL’s representation of searchable document text. A generated column can combine fields and store the prepared text for indexing:
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector(
'english',
coalesce(title, '') || ' ' || coalesce(body, '')
)
) STORED;
CREATE INDEX articles_search_gin
ON articles USING GIN (search_vector);
Use the same text-search configuration to prepare the searchable text and run the query. A generated column keeps the prepared text available for searches. An expression index on to_tsvector(...) is another option; it avoids storing a separate tsvector column but requires the query expression to match the indexed expression. PostgreSQL documents these approaches in its guidance on full-text tables and indexes.
GIN does not store the weight labels that full-text search can assign to words. If a query filters by those weights, PostgreSQL checks the matching table rows again. That recheck preserves correct results, but adds work beyond the index lookup. PostgreSQL’s full-text index documentation describes this behavior and identifies GIN as the preferred starting point.
For jsonb, choose the GIN operator class to match your query predicates. The default jsonb_ops class supports key-existence operators such as ?, ?|, and ?&, along with containment and JSONPath matching. jsonb_path_ops supports containment and JSONPath matching, but not key-existence operators.
-- Supports the broader set of JSONB GIN operators
CREATE INDEX api_jdoc_gin
ON api USING GIN (jdoc);
-- Focuses on containment and JSONPath matching
CREATE INDEX api_jdoc_path_gin
ON api USING GIN (jdoc jsonb_path_ops);
PostgreSQL describes jsonb_path_ops indexes as usually smaller and their supported searches as typically faster than the default class. Because jsonb_path_ops supports fewer operators, queries using a key-existence operator need jsonb_ops. Also, jsonb_path_ops creates no index entries for structures with no values, such as {"a": {}}, so searches for such structures can require a full index scan. See PostgreSQL’s JSONB indexing documentation before choosing the class.
A whole-document GIN index offers flexibility across JSON keys. If a frequent query applies an operator to a particular extracted value, a targeted expression index may fit better. For example, an index on (jdoc -> 'tags') can support a query that applies a key-existence operator to that same expression. The query must use the indexed expression for PostgreSQL to match it to the index.
GIN also supports array searches such as overlap (&&) and containment (@>). Use GIN for queries that test whether an array contains particular elements or overlaps another array. Check that each query uses an operator supported by the index.
GiST, short for Generalized Search Tree, is a framework for building indexes with different search strategies. Its operator class tells PostgreSQL how to navigate the index tree and which operators the index supports. GiST suits searches where the index uses summaries or bounds in a tree to narrow the results, including geometric searches and queries that check whether values fall within a range. PostgreSQL’s index type documentation describes these use cases.
GiST also supports nearest-neighbor searches when the operator class provides the required distance support. A query can order rows by a supported distance operator and return the closest results. That ability depends on the operator class and data type; creating a GiST index alone does not make every distance query indexable. Check the GiST documentation for the required support.
The pg_trgm extension is another case where both GiST and GIN can fit. It provides trigram operators for text similarity and pattern searches, with operator classes for both index types. Choose based on the query: for example, whether it filters by a similarity threshold or sorts results by distance. Check the query plan and measure performance for the trigram workload, since the best index depends on the query. PostgreSQL documents the operators and classes in its pg_trgm reference.
An index recheck means PostgreSQL uses the index to find candidate rows, then evaluates the original condition against those rows. Some index strategies are lossy: they narrow the search but cannot decide every match exactly. GiST supports both exact and lossy operator classes, so a recheck depends on the particular class and query rather than on GiST alone.
Full-text GiST uses a fixed-size signature to represent each document. The default signature is 124 bytes, and the maximum is 2,024 bytes. Because words are hashed into the signature, different words can set the same bits and produce false candidates that PostgreSQL must recheck. Longer signatures use more index space and improve filtering precision. For full-text search, GIN remains the usual starting point; consider GiST when its signature-based structure fits the workload and validate the recheck cost.
Because GIN indexes each value’s individual components, a single inserted or updated row can add many index entries. The fastupdate setting lets PostgreSQL place some new entries in a pending list and merge them into the main index later. This can speed up many writes, but searches also inspect the pending list, and cleanup can make some writes slower when the list grows. PostgreSQL discusses fastupdate, pending-list cleanup, and maintenance in its GIN index documentation.
For a large bulk load, you can build the GIN index after loading the data instead of maintaining it for every inserted row. PostgreSQL also notes that maintenance_work_mem affects GIN build time. Before choosing this approach, consider whether the index needs to remain available during loading and whether you can maintain it.
| Query pattern | Starting point | What to verify |
|---|---|---|
| Scalar equality, ranges, or sorted output | B-tree | The query uses a scalar comparison or ordering the B-tree supports. |
Full-text search on tsvector |
GIN | The search configuration matches; weighted queries require row rechecks. |
| JSONB key existence | GIN with jsonb_ops |
The query uses a supported key-existence operator. |
| JSONB containment or JSONPath matching | GIN with jsonb_ops or jsonb_path_ops |
Choose broader operator coverage or the narrower class’s storage and search trade-offs. |
| Array element, containment, or overlap search | GIN | The predicate uses a supported array operator. |
| Geometric or range predicates | GiST, with a suitable operator class | The class supports the operators used by the query. |
| Distance-ordered nearest-neighbor search | GiST, when supported by the operator class | The class supplies distance support for the data type and operator. |
Before adding an index, confirm that its operator class supports the query. Also confirm that the query applies its operator to the indexed column or to an expression that matches the indexed expression. Compare read performance with index size and insert or update costs. Run EXPLAIN on representative queries to see whether PostgreSQL uses the index and plans a recheck. Lossy strategies require rechecks; this does not mean the index returns incorrect results.
Keep B-tree indexes for queries that compare scalar values or need rows in a particular order. A GIN index on a JSON document does not automatically serve every query on a scalar value extracted from that document. It also does not replace an index designed to return rows in scalar order. PostgreSQL’s indexing guidance notes that indexes add overhead, so create them for query patterns you observe.
Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.
Take a look at vroni.com