GIN Indexes for FTS
GIN indexes make tsvector @@ tsquery fast at scale. Build on a stored vector column, tune fastupdate and gin_pending_list_limit, and plan maintenance after bulk imports.
Search across all documentation pages
GIN indexes make tsvector @@ tsquery fast at scale. Build on a stored vector column, tune fastupdate and gin_pending_list_limit, and plan maintenance after bulk imports.
-- Preferred: index stored tsvector column
CREATE INDEX CONCURRENTLY articles_fts_gin
ON articles USING gin (search_vector);
-- Verify index use
EXPLAIN (COSTS OFF)
SELECT id FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'postgresql index');
-- Bitmap Index Scan on articles_fts_ginWhen to reach for this: Search latency exceeds your SLO on sequential scans, or EXPLAIN shows Seq Scan on @@ filters.
-- Bulk load pattern with deferred indexing
BEGIN;
ALTER TABLE articles DROP CONSTRAINT IF EXISTS articles_pkey;
-- (use UNLOGGED staging table in real pipelines)
TRUNCATE articles RESTART IDENTITY;
COPY articles (title, body) FROM '/tmp/articles.csv' CSV HEADER;
-- search_vector is GENERATED STORED; rebuild index after bulk
COMMIT;
-- Rebuild GIN after large import (faster than incremental for some workloads)
REINDEX INDEX CONCURRENTLY articles_fts_gin;
ANALYZE articles;
SELECT pg_size_pretty(pg_relation_size('articles_fts_gin')) AS gin_size;What this demonstrates:
tsvector stays current during COPYREINDEX CONCURRENTLY avoids long write locks post-importANALYZE updates stats for bitmap vs seq scan choice| Access method | Build time | Query speed | Update cost | Typical FTS choice |
|---|---|---|---|---|
| GIN | Slower, larger | Faster reads | Higher insert cost | Default for search |
| GiST | Faster build | Slower reads | Lower insert cost | Rare for FTS |
PostgreSQL docs recommend GIN for tsvector unless write-heavy micro-update workload proves GiST better in benchmarks.
-- Single column
CREATE INDEX ON docs USING gin (doc_tsv);
-- Partial index for published content only
CREATE INDEX docs_published_fts ON docs USING gin (doc_tsv)
WHERE status = 'published';
-- jsonb + fts combined workloads: separate indexes, don't merge-- Per-index fastupdate (PostgreSQL 9.5+)
ALTER INDEX articles_fts_gin SET (fastupdate = on);
SHOW gin_pending_list_limit; -- cluster setting, default 4MBPending list batches small inserts before merging into main GIN tree. Large imports may benefit from fastupdate = off during load, then reindex.
-- After bulk import or version upgrade
REINDEX INDEX CONCURRENTLY articles_fts_gin;
-- Whole table FTS indexes
REINDEX TABLE CONCURRENTLY articles;
-- Bloat check (simplified)
SELECT indexrelid::regclass,
pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE indexrelid::regclass::text LIKE '%fts%';Heavy UPDATE on text columns regenerates tsvector and churns GIN pages. Tune autovacuum on search tables:
ALTER TABLE articles SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01
);to_tsvector wrappers fail index creation. Fix: Stored generated column or trigger-maintained column.CONCURRENTLY in production.ANALYZE after reindex; check enable_bitmapscan.random_page_cost tuning only after measurement.VACUUM or gin_clean_pending_list() on supported versions.REINDEX CONCURRENTLY still consumes I/O. Fix: Schedule maintenance window; throttle parallel workers.| Alternative | Use When | Don't Use When |
|---|---|---|
| GiST index | Extreme write rate, tiny corpus | Read-heavy search dominates |
| No index (seq scan) | < 50k rows, admin-only search | User-facing search at scale |
| Partitioned GIN per time range | Time-series archive search | Uniform query across all history |
| External search index | Corpus beyond single node FTS | Strong transactional requirements |
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PostGIS 3.5+, pgbouncer 1.x, and Patroni 3.x.
Reviewed by Chris St. John·Last updated Jul 18, 2026