Home // Cloud Infrastructure // Odoo Development // AI & Automation // Odoo + AI Agents Case Studies About Blog Free Assessment
// DATABASE PERFORMANCE · 11 MIN READ

Postgres JSONB GIN Indexes: The Operator Class Detail That Determines Whether Your Index Gets Used

You can build a GIN index on a JSONB column and never see it used in a query plan — not because the index is wrong, but because the operator class doesn't match the query operator. This distinction isn't in most JSONB tutorials, and it causes real performance problems in production.

// PUBLISHED 2024-11-12 · LANIAKEA TEAM

The Two GIN Operator Classes for JSONB

PostgreSQL provides two GIN operator classes for JSONB columns:

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:

  1. Every query against this column uses only the @> containment operator
  2. Index size matters — the table is large enough that the 30–50% size reduction is meaningful
  3. 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.