PostgreSQL

How to Resolve "PostgreSQL Slow Query Due to Missing Index on JSONB"

4 min read by DebuggedIt

Quick answer

A query filtering on data stored inside a JSONB column takes far longer than an equivalent query on a normal indexed column, even on a table that isn't...

A query filtering on data stored inside a JSONB column takes far longer than an equivalent query on a normal indexed column, even on a table that isn't especially large. JSONB fields don't automatically get useful indexes the way regular columns often do, and a query filtering into JSON structure without one forces PostgreSQL to scan and parse every row's JSON payload from scratch.

The Problem

A query filtering on a JSONB field takes seconds instead of milliseconds:

EXPLAIN ANALYZE
SELECT * FROM events WHERE payload @> '{"type": "purchase"}';
Seq Scan on events  (cost=0.00..48213.00 rows=1021 width=812) (actual time=0.045..1842.291 rows=15234 loops=1)
  Filter: (payload @> '{"type": "purchase"}'::jsonb)
  Rows Removed by Filter: 984766
Planning Time: 0.112 ms
Execution Time: 1843.019 ms

The plan shows a sequential scan filtering out nearly a million rows to find the matching ones β€” exactly the pattern that a good index should eliminate.

Without an index vs. with a GIN index Seq Scan: parse every row's JSON GIN Index: lookup matching keys ~1.8s, 1M rows scanned ~5ms, direct index hit CREATE INDEX ... USING GIN (payload jsonb_path_ops)

Why It Happens

A regular B-tree index (the PostgreSQL default) works well for equality and range comparisons on scalar values, but it doesn't understand the internal structure of a JSONB document, so it's useless for queries filtering on keys nested inside one. Without a specialized index designed for JSON structure, PostgreSQL has no choice but to read and parse every row's JSONB payload to check if it matches your filter β€” a sequential scan, regardless of how selective the actual filter is. This slowness shows up specifically when:

  • Filtering with the containment operator (@>) or key-existence operators (?, ?|, ?&) on a JSONB column with no supporting index at all.
  • The table has grown large enough that scanning and parsing every row's JSON is genuinely expensive, whereas the same query felt fast on a small development dataset.
  • An index exists but is the wrong type for the query pattern β€” a plain B-tree index on the whole JSONB column doesn't help containment queries the way a GIN index does.

The Fix

Add a GIN (Generalized Inverted Index) index on the JSONB column, which is purpose-built for containment and existence queries against JSON structure:

CREATE INDEX idx_events_payload ON events USING GIN (payload);

Re-run the query and check the plan again:

EXPLAIN ANALYZE
SELECT * FROM events WHERE payload @> '{"type": "purchase"}';
Bitmap Heap Scan on events  (cost=68.23..4821.11 rows=1021 width=812) (actual time=0.891..4.523 rows=15234 loops=1)
  Recheck Cond: (payload @> '{"type": "purchase"}'::jsonb)
  ->  Bitmap Index Scan on idx_events_payload  (cost=0.00..68.00 rows=1021 width=0) (actual time=0.612..0.612 rows=15234 loops=1)
Planning Time: 0.203 ms
Execution Time: 4.891 ms

Execution time drops from nearly two seconds to a few milliseconds. If your queries consistently use the containment operator (@>) specifically, and never the key-existence operators, the more compact jsonb_path_ops variant produces a smaller, often faster index for that specific access pattern:

CREATE INDEX idx_events_payload_pathops ON events USING GIN (payload jsonb_path_ops);

If you only ever query a specific known key rather than arbitrary JSON structure, an expression index targeting just that key can be even more efficient than a full GIN index over the whole document:

CREATE INDEX idx_events_type ON events ((payload->>'type'));
SELECT * FROM events WHERE payload->>'type' = 'purchase';

This regular B-tree expression index works well when your access pattern is consistently a single, specific key lookup rather than general JSON containment, and it's typically smaller and faster to maintain than a full GIN index.

Still Not Working?

If you've added a GIN index but the planner still chooses a sequential scan, check whether table statistics are stale β€” PostgreSQL's query planner relies on statistics to decide whether using an index is actually cheaper than a scan, and outdated statistics can lead it to underestimate the index's benefit:

ANALYZE events;

Re-run EXPLAIN ANALYZE after updating statistics. If the planner still prefers a sequential scan even with a fresh, accurate row count estimate, double-check the query actually matches the operator the index was built for β€” a GIN index on containment (@>) won't be used automatically for a differently-structured query like a raw text search inside the JSON, which needs its own separate indexing strategy.