10 min read

PostgreSQL JSONB Performance: GIN Indexes, TOAST, and Query Shape

JSONB is useful until it becomes an unplanned document store. Performance depends on query shape, GIN operator classes, TOAST size, generated columns, and write amplification.

JSONB is one of PostgreSQL's most useful escape hatches. It lets a team ship before every attribute deserves a column. It absorbs integrations, product experiments, feature flags, event payloads, and customer-specific metadata.

The trouble starts when the escape hatch becomes the main hallway. The application filters on keys buried inside payloads, updates large documents for tiny changes, and adds one generic GIN index that everyone hopes will save the day.

JSONB performance is not about whether JSONB is good or bad. It is about whether the access pattern has become stable enough to deserve a real shape.

Use the right GIN index for the query

A default JSONB GIN index supports many operators. jsonb_path_ops is smaller and faster for containment-heavy queries, but it is less general. Choose from the query, not habit.

CREATE INDEX CONCURRENTLY events_payload_gin
ON events
USING gin (payload);

CREATE INDEX CONCURRENTLY events_payload_path_gin
ON events
USING gin (payload jsonb_path_ops);

Promote hot keys to columns

If the product filters on the same key every day, it may no longer be semi-structured data. A generated column or normal column gives PostgreSQL better statistics, simpler indexes, and clearer query plans.

ALTER TABLE events
ADD COLUMN customer_tier text
GENERATED ALWAYS AS (payload->>'customer_tier') STORED;

CREATE INDEX CONCURRENTLY events_customer_tier_created_idx
ON events (customer_tier, created_at DESC);

TOAST can hide the real cost

Large JSONB values are stored out of line in TOAST. A query that looks like it reads one row may pull a large compressed payload behind the scenes. This is especially painful when a request only needs one small field from a large document.

The production checklist

  1. List the JSONB keys used in WHERE, JOIN, ORDER BY, and GROUP BY.
  2. Promote stable hot keys to columns or generated columns.
  3. Use GIN only for query patterns it actually supports.
  4. Watch table, TOAST, and index size together.
  5. Avoid updating large JSONB payloads for tiny state changes.
  6. Use EXPLAIN BUFFERS to see whether the query reads too much.

The practical standard

The best PostgreSQL performance work is boring in the right way. Name the failure mode, capture the before plan or metric, make one change, and compare the exact same signal afterward. Anything else is just a more confident guess.