Choosing GIN Indexes for PostgreSQL JSONB: A Decision Guide
A decision guide for choosing PostgreSQL GIN indexes on JSONB columns: compare operator classes, see when partial indexes shrink storage, and learn how to validate index usage with EXPLAIN and pg_stat_user_indexes.
14 Aug 2025, 06:47 UTC

The Problem: JSONB Query Performance at Scale
PostgreSQL's JSONB column type stores structured documents efficiently, but without the right index, containment queries like WHERE data @> '{"status": "active"}' scan every row. A GIN (Generalized Inverted Index) solves this by indexing the keys and values inside each JSONB document, turning document searches into index lookups. The trade-off: GIN indexes consume more disk and add write overhead. This guide helps you decide when GIN fits, which operator class to pick, and how to validate the choice in production.
Decision Constraints
- Query pattern: You run containment (
@>), existence (?), or path-existence (?|,?&) predicates on JSONB columns. - Data volatility: Write-heavy tables (frequent INSERT/UPDATE on the JSONB column) pay higher maintenance cost.
- Selectivity: If only a fraction of rows match common predicates, a partial GIN index can shrink the index dramatically.
- Disk budget: GIN typically uses 2–4× the space of a B-tree on the same column.
Supported GIN Operator Classes for JSONB
| Operator Class | Operators Supported | Best For | Space Overhead |
|---|---|---|---|
jsonb_ops (default) | @>, ?, ?|, ?&, @?, @@ | General-purpose JSONB containment and key-existence queries | Higher (indexes all keys and values) |
jsonb_path_ops | @> only | Workloads dominated by @> containment checks | Lower (indexes only hashed paths, no key entries) |
jsonb_path_ops builds a smaller, faster index for pure containment queries but cannot serve key-existence operators (?, ?|). Choose it only when your WHERE clauses exclusively use @>.
Trade-offs at a Glance
| Factor | GIN jsonb_ops | GIN jsonb_path_ops | No Index (Seq Scan) |
|---|---|---|---|
| Containment query speed | Fast | Fastest | Slow (full scan) |
| Key-existence query speed | Fast | Not supported | Slow |
| Index size | Largest | ~30–50% smaller | None |
| Write latency impact | Highest | High | None |
| Partial index support | Yes | Yes | N/A |
Concrete Implementation: Creating a Partial GIN Index
Suppose a events table stores JSONB in a payload column, and 80% of queries filter for payload @> '{"type": "purchase"}'. A partial index indexes only the relevant rows:
CREATE INDEX CONCURRENTLY idx_events_purchase
ON events USING GIN (payload jsonb_path_ops)
WHERE payload @> '{"type": "purchase"}';
Where to run: Any PostgreSQL 12+ session with CREATE privilege on the table. CONCURRENTLY avoids an exclusive lock, letting reads and writes proceed during the build (requires no active transaction block).
Permissions: The role must own the table or have CREATE on the schema plus INDEX on the table.
Placeholders: Replace events, payload, and the WHERE clause with your table, column, and selective predicate.
Expected check: After completion, verify with \d events in psql or query pg_indexes. The index should appear with gin in the indexdef column.
Risk: CONCURRENTLY can fail if a conflicting schema change occurs mid-build. If it fails, drop the invalid index and retry.
Validating Index Usage
Run the target query with EXPLAIN (ANALYZE, BUFFERS) to confirm the planner chooses the GIN index:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, payload
FROM events
WHERE payload @> '{"type": "purchase", "amount": 100}';
Look for Index Scan using idx_events_purchase in the plan. If you see Seq Scan on events, the planner estimated the predicate as unselective—check statistics with ANALYZE events or adjust the partial index predicate.
Monitor ongoing effectiveness via pg_stat_user_indexes:
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'events';
-- idx_scan rising means the index is being used.
-- idx_tup_fetch << idx_tup_read suggests many false positives (rechecks).
Managing Fragmentation and Bloat
GIN indexes accumulate dead tuples after heavy UPDATE/DELETE cycles. Two maintenance paths exist:
- REINDEX CONCURRENTLY (PostgreSQL 12+): Rebuilds the index without blocking writes. Run during low-traffic windows.
- pg_repack or
pg_reorganize(extension): Reorganizes the table and indexes with minimal locking, useful when table bloat also needs addressing.
Schedule based on pg_stat_user_indexes.idx_tup_read growth or when pg_relation_size('idx_events_purchase') exceeds 2× the expected size.
Limitations and When to Avoid GIN
- Write-heavy workloads: If the JSONB column updates on every row change, the index maintenance cost may outweigh read gains. Consider a generated column + B-tree for a single frequently queried key.
- Low selectivity: If
@> '{"type": "purchase"}'matches >20% of rows, a sequential scan may be faster. Test with real data volumes. - Full-text search inside JSONB: For text search on JSONB values, a
tsvectorGIN index on a generated column outperformsjsonb_ops.
Quick Verification Checklist
- Identify the dominant JSONB predicates in your workload (
@>vs?). - Pick
jsonb_path_opsfor pure containment; otherwisejsonb_ops. - Add a
WHEREclause to the index if one predicate dominates. - Build with
CREATE INDEX CONCURRENTLY. - Confirm plan with
EXPLAIN (ANALYZE, BUFFERS). - Track
idx_scanandidx_tup_fetchweekly. - Schedule
REINDEX CONCURRENTLYwhen bloat exceeds 2× baseline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.