The Two GIN Operator Classes for JSONB
PostgreSQL provides two GIN operator classes for JSONB columns:
jsonb_ops— the default. Indexes every key, every value, and every key-value pair in the document. Supports the@>,?,?|, and?&operators. Produces a larger index.jsonb_path_ops— indexes only values reachable via paths. Supports only the@>operator. Produces a smaller index — typically 30–50% smaller thanjsonb_ops— and has faster lookups for containment queries.
The critical point: if your query uses ? (key exists) operators and you built your GIN index with jsonb_path_ops, the index will never be used. The planner silently falls back to a sequential scan. No warning, no error, no hint in EXPLAIN unless you look carefully at the index scan operator.
-- Create a table with JSONB metadata
CREATE TABLE events (
event_id BIGINT PRIMARY KEY,
event_time TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL
);
-- Index with default operator class (jsonb_ops)
CREATE INDEX idx_events_payload_ops
ON events USING GIN (payload); -- equivalent to jsonb_ops
-- Index with path operator class (jsonb_path_ops)
CREATE INDEX idx_events_payload_path_ops
ON events USING GIN (payload jsonb_path_ops);
Which Operators Use Which Index
This table is the core reference. Memorize it or bookmark it before building any GIN index on a JSONB column:
Operator Meaning jsonb_ops jsonb_path_ops
----------- ----------------------------- ---------- ---------------
@> Left contains right YES YES
<@ Left is contained by right YES NO
? Key exists at top level YES NO
?| Any of these keys exist YES NO
?& All of these keys exist YES NO
@@ jsonpath match YES (PG14+) NO
@? jsonpath predicate YES (PG14+) NO
The @> containment operator is supported by both. Everything else is jsonb_ops only.
Diagnosing Index Non-Use
The canonical diagnostic: run EXPLAIN on a query you expect to use the GIN index and check whether it does.
-- Sample data
INSERT INTO events (event_id, event_time, payload)
SELECT
g,
NOW() - (random() * INTERVAL '30 days'),
jsonb_build_object(
'user_id', (random() * 10000)::int,
'action', (ARRAY['click','view','purchase','logout'])[ceil(random()*4)::int],
'tags', jsonb_build_array('web', 'mobile'),
'metadata', jsonb_build_object('region', 'us-east-1', 'version', '2.1')
)
FROM generate_series(1, 1000000) g;
ANALYZE events;
-- Query 1: containment — @> works with both operator classes
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM events
WHERE payload @> '{"action": "purchase"}';
-- Query 2: key existence — ? only works with jsonb_ops
EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*) FROM events
WHERE payload ? 'user_id';
For Query 2 against an index built with jsonb_path_ops, the plan will show:
Aggregate (cost=23847.50..23847.51 rows=1 width=8)
-> Seq Scan on events (cost=0.00..23597.50 rows=100000 width=0)
Filter: (payload ? 'user_id'::text)
Sequential scan. One million rows scanned. The GIN index exists but is not applicable to the ? operator under jsonb_path_ops.
With jsonb_ops:
Aggregate (cost=97.51..97.52 rows=1 width=8)
-> Bitmap Heap Scan on events (cost=84.00..97.51 rows=1 width=0)
Recheck Cond: (payload ? 'user_id'::text)
-> Bitmap Index Scan on idx_events_payload_ops
Index Cond: (payload ? 'user_id'::text)
Bitmap index scan. Orders of magnitude faster.
When to Use jsonb_path_ops
jsonb_path_ops is the right choice when all of the following are true:
- Every query against this column uses only the
@>containment operator - Index size matters — the table is large enough that the 30–50% size reduction is meaningful
- Write throughput on the table is high — smaller GIN indexes have lower write amplification cost
Common patterns that fit jsonb_path_ops well: permission/role checking (WHERE permissions @> '{"role": "admin"}'), tag filtering (WHERE tags @> '["featured"]'), and nested attribute matching (WHERE config @> '{"feature_flags": {"dark_mode": true}}').
Use jsonb_ops (the default) when you use any ? operators — checking for key existence without caring about value — or when your query patterns are mixed.
Expression Indexes for Specific Keys
GIN indexes on entire JSONB columns are expensive to build and maintain. For high-cardinality values accessed by a specific key, an expression B-tree index on the extracted value is often faster than a GIN index on the whole document:
-- GIN index on the whole column: useful for flexible queries
CREATE INDEX idx_events_payload_gin ON events USING GIN (payload);
-- Expression B-tree on a specific key: better for equality on known paths
CREATE INDEX idx_events_user_id
ON events ((payload->>'user_id'));
CREATE INDEX idx_events_action
ON events ((payload->>'action'));
-- The expression index is used for:
SELECT * FROM events WHERE payload->>'action' = 'purchase';
-- The GIN index is used for:
SELECT * FROM events WHERE payload @> '{"action": "purchase"}';
-- Note: these two queries are logically equivalent but use different operators
-- and different indexes
The expression index approach has a key advantage: the Postgres planner has accurate statistics on the extracted value (via a regular histogram), whereas GIN index statistics are much coarser. For high-selectivity queries on a known path, the expression B-tree typically produces better plans.
GIN Index Maintenance Cost
GIN indexes have higher write amplification than B-tree indexes. Each INSERT or UPDATE to a row with a JSONB column triggers decomposition of the document into GIN index entries — one entry per key-value path. A document with 20 keys generates 20 index entries. At high write rates on a table with a GIN index, this becomes visible in pg_stat_user_indexes:
SELECT
schemaname,
tablename,
indexname,
idx_blks_read,
idx_blks_hit,
idx_tup_read,
idx_tup_fetch
FROM pg_statio_user_indexes
WHERE indexname LIKE '%payload%';
GIN indexes use a pending list (a small in-memory buffer for fast inserts) that is flushed to the main index structure periodically. The flush happens when gin_pending_list_limit is reached (default 4MB) or when a VACUUM runs on the table. A table with high INSERT rate and infrequent VACUUM may accumulate a large pending list, which slows down index scans because the pending list must be scanned separately from the main index.
-- Monitor GIN pending list size (requires pg_gin_pending_stats or manual check)
-- After a VACUUM, pending list should be 0
VACUUM ANALYZE events;
For write-heavy tables with JSONB GIN indexes, set autovacuum_vacuum_scale_factor lower on that specific table to keep the pending list drained:
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01, -- vacuum when 1% of rows are dead
autovacuum_vacuum_cost_delay = 2 -- reduce autovacuum I/O throttling
);
The jsonpath Operators in PostgreSQL 14+
PostgreSQL 12 introduced the SQL/JSON path language with the @@ and @? operators. These allow more expressive path queries than the containment operators:
-- jsonpath: find events where metadata.version starts with "2."
SELECT event_id FROM events
WHERE payload @? '$.metadata.version ? (@ starts with "2.")';
-- jsonpath: find events where user_id is in a range
SELECT event_id FROM events
WHERE payload @@ '$.user_id > 5000';
In PostgreSQL 14+, jsonb_ops GIN indexes support @@ and @?. jsonb_path_ops does not. If you're using jsonpath operators and need index support, you must use jsonb_ops.
Decision Matrix
Query pattern Recommended index
------------------------------------- -----------------------------------------------
Only @> containment, large table jsonb_path_ops GIN (smaller, faster for @>)
Mix of @> and ? operators jsonb_ops GIN (default)
Equality on specific known key Expression B-tree on (payload->>'key')
Range queries on numeric JSON values Expression B-tree on ((payload->>'key')::numeric)
jsonpath @@ / @? operators jsonb_ops GIN (PG 14+)
Full-document search (all operators) jsonb_ops GIN
Seeing sequential scans on JSONB queries despite having a GIN index?
We audit index configurations, operator class mismatches, and query plan regressions as part of every Postgres performance engagement. Free assessment, no obligation.