PostgreSQL JSONB Indexing and Query Performance
PostgreSQL JSONB indexing lets applications search fields inside flexible JSON documents without reading every row, but an index only helps when it matches the operators and paths a workload actually uses. This matters to developers and database operators who store optional attributes, event payloads, or configuration alongside relational data. The PostgreSQL project provides JSONB as a queryable document type inside a relational database; choosing how to index it is a separate design decision from choosing how to store it.
What Is PostgreSQL JSONB?
PostgreSQL accepts JSON in two related types. json stores the input text and reparses it when values are processed. jsonb converts the input to a decomposed binary representation, which takes a little more work on input but supports efficient processing and indexing. JSONB does not preserve object key order or insignificant whitespace, and duplicate object keys are reduced to the last value. If an application needs the original input text exactly as submitted, json may be more appropriate.
The JSON syntax itself is described by RFC 8259. JSONB is PostgreSQL’s implementation choice for working with that data; it is not a separate wire format that clients must use.
The Problem JSONB Indexes Solve
Without a suitable index, a query that filters on a property inside a JSON document can inspect that property row by row. On a large table, the repeated reads and JSON operations can add latency and consume CPU and I/O. A normal B-tree index on a relational column cannot automatically index every key and value buried in a JSONB document.
The opposite mistake is to add a large general-purpose index to every JSONB column. Indexes consume disk, must be maintained as rows change, and can make writes more expensive. They can also be ineffective when a predicate does not use an operator supported by that index or when most rows match anyway. The goal is to index the queries that matter, not JSON in the abstract.
How JSONB Indexing Works
PostgreSQL’s Generalized Inverted Index, or GIN, is designed for values containing multiple searchable components. For JSONB, it indexes keys and values so supported operators can find candidate rows. The PostgreSQL GIN documentation describes this inverted-index design. A GIN search can still require PostgreSQL to recheck candidate table rows against the full condition.
The default JSONB GIN operator class is jsonb_ops. jsonb_path_ops indexes fewer kinds of information and supports fewer operators, but often creates a smaller, more selective index for containment and JSONPath searches. A B-tree expression index is a different option: it indexes one extracted value, which works well when queries repeatedly filter or sort by a particular scalar field.
| Index choice | Best fit | Operators or conditions | Trade-off |
|---|---|---|---|
GIN with jsonb_ops |
Varied searches across document keys and values | Containment (@>), key existence (?, `? |
, ?&`), and supported JSONPath operators |
GIN with jsonb_path_ops |
Containment or supported JSONPath searches | @>, @?, and @@; not the key-existence operators |
Often smaller and more selective, but narrower in operator support |
| B-tree expression index | A frequently queried scalar path | Equality, ordering, or ranges on the same extracted expression | Focused and useful for scalar comparisons; does not index arbitrary document searches |
| No JSONB index | Small tables or infrequent, broad queries | Any predicate, evaluated by scanning rows | No index maintenance cost, but potentially more read work |
For example, this containment condition can use a compatible GIN index:
SELECT id
FROM events
WHERE payload @> '{"status": "queued"}'::jsonb;
By contrast, if most queries filter by one text field, an expression index can target that path:
CREATE INDEX events_tenant_id_idx
ON events ((payload ->> 'tenant_id'));
SELECT id
FROM events
WHERE payload ->> 'tenant_id' = 'tenant-42';
The query expression needs to match the indexed expression closely enough for the planner to recognize it. If a field is numeric, extract and cast it consistently in both the index and query, and validate stored values so unexpected strings cannot make the cast fail.
Key Concepts and Trade-Offs
JSONB operators determine which index design is useful. The -> operator extracts a JSON value, while ->> returns text. The @> operator checks containment, and ? checks whether a top-level object key or array string exists. A GIN index intended for containment does not make every expression involving ->> indexable; a scalar path commonly needs its own expression index.
Indexes also change write behavior. Updating a JSONB value covered by an index can require index maintenance; GIN may generate multiple index entries for a document. PostgreSQL can defer some GIN work in a pending list, which helps absorb writes but can shift work to cleanup. Vacuuming and autovacuum help clean up index maintenance, but they do not make unnecessary indexes free. Measure write latency and index size as well as read speed.
The planner chooses between an index scan and a sequential scan based on estimated cost, table size, selectivity, and statistics. It is normal for a small table or a query matching a large fraction of rows to use a sequential scan even when an index exists. Check the execution plan instead of treating the appearance of an index scan as the goal.
To tune a real workload, capture a baseline with representative data and query parameters. Compare actual rows, buffers, and latency before and after each change. Slow-query logs or pg_stat_statements can identify recurring predicates worth indexing; inspect index size and usage over a representative workload window rather than an idle interval. Statistics are cumulative and should be interpreted alongside reset times and traffic cycles.
Finally, consider whether a frequently queried field belongs in a regular column. Relational columns provide straightforward types, constraints, and indexing for stable, important attributes; JSONB is useful for optional or evolving document properties. A mixed model often keeps stable identifiers and common filters relational while leaving genuinely variable attributes in JSONB. Applications should distinguish an absent property from JSON null and SQL NULL, and validate value types. If filters assume a key always exists, inconsistent documents can produce incorrect results regardless of how quickly an index finds them.
Real-World Use Cases
- Event records: Keep an event type and timestamp in regular columns, with variable event-specific attributes in a JSONB payload. Use GIN for diverse containment searches, or an expression index for a frequently queried payload key.
- Product catalogs: Store irregular specifications that differ by product category in JSONB. Promote fields used for common sorting, filtering, or constraints into columns when query patterns become stable.
- Application settings: Keep sparse per-tenant options in a JSONB object, but use a targeted expression index only if a particular setting becomes a frequent selective filter.
These patterns work best when the application knows which fields are stable and which query shapes need predictable performance. JSONB flexibility does not remove the need to define those access patterns.
Getting Started: Index and Measure a JSONB Query
Run the following SQL in a PostgreSQL database. It creates a small events table and example data:
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL
);
INSERT INTO events (payload) VALUES
('{"status": "queued", "tenant_id": "tenant-42", "attempt": 1}'),
('{"status": "complete", "tenant_id": "tenant-42", "attempt": 2}'),
('{"status": "queued", "tenant_id": "tenant-99", "attempt": 1}');
Choose an index based on the query the application runs. These are alternatives to evaluate, not a recommendation to create every index:
-- Flexible containment and key-existence searches:
CREATE INDEX events_payload_gin_idx
ON events USING GIN (payload);
-- Alternatively, containment / supported JSONPath searches:
-- CREATE INDEX events_payload_path_gin_idx
-- ON events USING GIN (payload jsonb_path_ops);
-- A frequently filtered scalar path:
CREATE INDEX events_tenant_id_idx
ON events ((payload ->> 'tenant_id'));
Update planner statistics, then inspect the query plan:
ANALYZE events;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM events
WHERE payload ->> 'tenant_id' = 'tenant-42';
EXPLAIN displays the planner’s estimate; EXPLAIN ANALYZE runs the statement and reports actual behavior, so use it thoughtfully with statements that modify data. PostgreSQL’s EXPLAIN guide explains how to read plans. This example table is deliberately tiny, so a sequential scan may be cheaper than using the new index. Test with representative data and workload, compare latency and buffers before and after, and avoid keeping an index that does not justify its storage and write cost.
Common Misconceptions
- “A GIN index makes every JSONB query fast.” Only compatible operators and query shapes can use it; a scalar extraction may call for an expression index.
- “
jsonb_path_opsis always the better GIN choice.” It can be smaller and faster for supported searches, but it cannot serve key-existence operators such as?. - “If an index exists, PostgreSQL should use it.” The planner can correctly prefer a sequential scan for small tables, unselective conditions, or stale estimates.
- “JSONB means the schema no longer matters.” Applications still need rules for required keys, value types, and which properties should become relational columns.
Related Articles
- For general index design and maintenance trade-offs, read Database Indexing Strategies.
- For broader PostgreSQL measurement and tuning practices, see PostgreSQL Performance Optimization.
- To understand how PostgreSQL distributes committed row changes to consumers, read PostgreSQL Logical Replication and CDC.
- For the cleanup and write costs associated with row versions, see PostgreSQL MVCC, VACUUM, and Table Bloat.
- For evolving a relational schema safely, see Managing Database Schema Changes.
Changelog
- Initial publication.
Last updated: 2026-10-07.

