JSON and semi-structured data in PostgreSQL
Indexing jsonb
The idea in 30 seconds
A GIN index on a jsonb column speeds up containment (@>) and key-existence (?) searches across every key at once. It does nothing for ->> comparisons, which need a B-tree index on that exact expression. Which index helps depends on how the query is written, so match the index to the query, then check the plan.
Helps to know first: SQL types and the type system, B-tree indexes
On this page
One question, three ways to ask it
An events table stores each event’s details as a document. To count failed events, all three of these give the same answer:
SELECT count(*) FROM events WHERE payload @> '{"status": "failed"}'; -- containment
SELECT count(*) FROM events WHERE payload ->> 'status' = 'failed'; -- extract and compare
SELECT count(*) FROM events WHERE payload ? 'error'; -- key exists (only failures have one)
To PostgreSQL they are three different operators, and an index serves an operator, not a meaning.
Three indexes, three queries
We built events with one million rows (195 MB), about 2% of them failed, and tried each kind of index in turn. Second run of each query, PostgreSQL 18.3 on a laptop:
| Index | @> containment | ->> equality | ? key exists | Index size |
|---|---|---|---|---|
| None | 45 ms | 36 ms | 32 ms | — |
GIN, default (jsonb_ops) | 4.1 ms | 37 ms, not used | 2.7 ms | 27 MB |
GIN, jsonb_path_ops | 3.7 ms | 37 ms, not used | 31 ms, not used | 20 MB |
B-tree on (payload ->> 'status') | 44 ms, not used | 1.6 ms | 29 ms, not used | 6.8 MB |
Without an index, every query reads all 24,949 pages of the table. With a matching index, the same counts read under 600.
CREATE INDEX ON events USING gin (payload); -- default: @>, ?, ?|, ?&
CREATE INDEX ON events USING gin (payload jsonb_path_ops); -- @> only, smaller
CREATE INDEX ON events ((payload ->> 'status')); -- this one expression
Choosing
- Queries search on many different fields? One GIN index on the column. Use
jsonb_path_opsif you only ever use@>: it was 26% smaller here and slightly faster. - A few fields are filtered on all the time? A B-tree expression index for each: smallest and fastest, and it supports ranges and sorting too.
- A field is filtered on in almost every query? That is a hint it should be a real column. The next page is about that decision.
Write your queries to match the index you have. payload @> '{"status": "failed"}' and payload ->> 'status' = 'failed' look interchangeable, but with a GIN index one takes 4 ms and the other 37.
Reproduce the measurements
SELECT setseed(0.7);
CREATE TABLE events (id bigint PRIMARY KEY, payload jsonb NOT NULL);
INSERT INTO events
SELECT g, jsonb_build_object(
'type', (ARRAY['login','logout','scan','export','invite','sync','webhook','payment','search','upload'])[1 + g % 10],
'status', CASE WHEN random() < 0.02 THEN 'failed' ELSE 'ok' END,
'user', jsonb_build_object('id', (random() * 50000)::int, 'plan', (ARRAY['free','pro','team'])[1 + g % 3]),
'duration_ms', (random() * 2000)::int,
'tags', jsonb_build_array('v' || (g % 7), 'region-' || (g % 4)))
FROM generate_series(1, 1000000) g;
UPDATE events SET payload = payload || '{"error": "timeout"}' WHERE payload ->> 'status' = 'failed';
VACUUM ANALYZE events;
Then run the three queries with EXPLAIN (ANALYZE, BUFFERS) before and after creating each index, dropping it before the next.
Check yourself
A table has a GIN index on
payload. Why doesWHERE payload ->> 'status' = 'failed'still read the whole table?Show answer
A GIN index on the column supports operators like
@>and?, not->>followed by=. That comparison needs a B-tree index on the expressionpayload ->> 'status'.When would you choose
jsonb_path_opsover the default GIN operator class?Show answer
When queries only use containment (
@>) and not key-existence operators. The index is smaller (20 MB instead of 27 MB here) and searches are more specific.Rewrite
payload ->> 'status' = 'failed'so a default GIN index onpayloadcan serve it.Show answer
payload @> '{"status": "failed"}'.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 8.14 JSON TypesPrimary· §8.14.4 jsonb Indexing
The default GIN operator class supports ?, ?|, ?&, @>, @? and @@; jsonb_path_ops supports only @>, @? and @@, but its index is usually much smaller and its searches more specific.
- 2.PostgreSQL documentation: 11.7 Indexes on ExpressionsPrimary· §11.7 Indexes on Expressions
An index on an expression is used by queries that use the same expression.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 8.14 JSON TypesPrimary· §8.14.4 jsonb Indexing
Shows when an expression GIN index on one sub-document beats indexing the whole column.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.