A GIN index stores searchable keys extracted from values and maps those keys to rows. Full-text documents, arrays, and JSONB can all use this access method, but each indexed expression needs an appropriate operator class. GIN is a family of supported searches, not one index that automatically accelerates unrelated columns, data types, or predicates.

Index the search representation

For full-text search, to_tsvector converts text into normalized lexemes, and a tsquery describes the requested match. A GIN index on that vector can narrow the candidate rows without parsing every document for every request. Common terms can still produce large candidate sets; reading, checking, ranking, and sorting those rows may dominate total work even when the index lookup is fast.

The configuration is part of the meaning. English stemming can normalize “outage” to a lexeme different from the simple configuration’s output. An expression index on to_tsvector('english', body) therefore needs a query using the matching expression. Omitting the configuration makes behavior depend on a session setting and does not match that index. The explicit two-argument form also makes the indexed conversion independent of the default configuration.

Other GIN uses have different operators and tradeoffs. JSONB’s default and path operator classes do not support identical operator sets; an array containment index is not a text-search index. Choose the indexed expression and operator class from the actual predicate. Adding GIN to a column without checking query compatibility can spend write and storage capacity while improving nothing.

Pending entries remain searchable

One document can contribute many keys, making index maintenance expensive. With fastupdate enabled, new entries can enter a pending list before being merged into the main index. Searches examine that list as well as the main structure. This trades maintenance scheduling and possible search overhead, not search freshness: visible inserted rows must remain findable before pending-list cleanup.

Vacuum, autoanalyze, explicit cleanup, or a pending-list size threshold can trigger consolidation. A large pending list slows searches, and foreground cleanup can create write-latency spikes. Raising the threshold simply moves that tradeoff; it does not remove the work. Evaluate ingest rate, query latency, autovacuum behavior, and maintenance windows together before changing it.

When it breaks

A query can silently lose its intended access path after a refactor changes the indexed expression or text-search configuration. Check EXPLAIN for the predicate and index actually used. A sequential scan on a tiny table is not proof of mismatch, but a different configuration cannot use the English expression index for that expression. Compare semantics before forcing planner settings.

Another failure is diagnosing bloat from index-to-table size alone. Documents with many distinct keys naturally require substantial index space. Growth after churn, dead entries, pending-list behavior, and observed query costs provide stronger evidence than a universal size ratio. Reindexing consumes resources and may affect availability; measure the issue and plan the operation instead of prescribing periodic rebuilds without a workload reason.

Check it quickly

Save this SQL as gin.sql and run psql -X -v ON_ERROR_STOP=1 -f gin.sql against a disposable PostgreSQL 18 database. The temporary table has ten rare-term documents among ten thousand rows, then receives one more matching document after index creation.

\set ON_ERROR_STOP on
BEGIN;
SET LOCAL statement_timeout = '5s';
CREATE TEMP TABLE docs (id integer PRIMARY KEY, body text);
INSERT INTO docs
SELECT g, CASE WHEN g % 1000 = 0 THEN 'outage incident' ELSE 'routine maintenance' END
FROM generate_series(1,10000) AS g;
CREATE INDEX docs_body_gin ON docs USING gin (to_tsvector('english', body));
ANALYZE docs;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM docs
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', 'outage');
INSERT INTO docs VALUES (10001, 'outage recovery');
SELECT count(*) AS matches_before_pending_cleanup FROM docs
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', 'outage');
SELECT gin_clean_pending_list('docs_body_gin'::regclass);
SELECT count(*) AS matches_after_pending_cleanup FROM docs
WHERE to_tsvector('english', body) @@ plainto_tsquery('english', 'outage');
EXPLAIN (COSTS OFF)
SELECT id FROM docs
WHERE to_tsvector('simple', body) @@ plainto_tsquery('simple', 'outage');
DO $$ BEGIN
  IF (SELECT count(*) FROM docs WHERE to_tsvector('english',body)
      @@ plainto_tsquery('english','outage')) <> 11 THEN
    RAISE EXCEPTION 'search lost a visible document';
  END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 selected a bitmap index scan on docs_body_gin for the English expression. The result count became eleven immediately after insertion and stayed eleven after pending-list cleanup. The simple-configuration expression used a sequential scan. Cleanup’s returned page count, buffer totals, timings, and plan choices are observations, not portable constants; the matching-row counts are deterministic assertions of search behavior.

The final rollback removes the temporary table and index. This example does not benchmark bulk ingestion or prove that GIN is optimal for every search. For production, include common terms, empty queries, document updates, and ranking or pagination requirements in the evaluation, and confirm both correctness and latency under representative concurrency.

Sources