studyDDIAChapter 3 · Data Models and Query Languages

You Can Index Inside a Document

Filtering 2 million JSONB documents on a nested field scans the whole table (~98 ms, ~34,000 pages). Add a GIN index and the same query becomes an index lookup touching ~600 pages in ~3.6 ms — ~27× faster. The catch: the index serves @> containment, not the ->> equality you might reach for.


Concept

"Document data is opaque — you can store JSON, but querying inside it means scanning every row." That was true once; it isn't now. Relational databases converged with document databases, and Postgres can index inside a JSONB column. You will filter 2 million documents on a nested field, watch it scan the whole table (~98 ms, ~34,000 pages), add a GIN index, and watch the same query become an index lookup that touches ~600 pages in ~3.6 ms — ~27× faster. The catch: the index serves the containment operator @>, not the ->> equality you might reach for first.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 3 — Data Models and Query Languages, §"Convergence of document and relational databases" (and §"Query languages for documents"):

Relational databases added support for JSON types and query operators, and the ability to index properties inside documents.

The book claims relational and document models converged. Here you exercise that convergence — a document column, queried and indexed inside — and meet the operator subtlety it comes with.

Prerequisites

The surprise is about indexing a semi-structured column. These help; each line notes where it shows up.

  1. JSONB — Postgres's binary JSON type; you can query into it with operators like @> (contains) and ->> (get field as text).
  2. Sequential scan vs. index scan — with no usable index, a filter reads every row; an index jumps to the matching rows. The plan change is the whole point.
  3. GIN (Generalized Inverted iNdex) — an inverted index that maps the elements inside a value (JSON keys/values, array items) to the rows holding them — the structure that makes "index inside a document" possible.
  4. Operator classes — a GIN index supports specific operators. The default jsonb_ops serves containment @> and key-existence ?, but not the ->> = field-equality expression.
Where to learn the prerequisites JSONB indexing (#1, #3, #4): the PostgreSQL manual, "jsonb Indexing" — which operators jsonb_ops vs jsonb_path_ops support. GIN internals (#3): the PostgreSQL manual, "GIN Indexes" (inverted index over composite values). The convergence (#1): DDIA §"Convergence of document and relational databases." If only one is new, make it #4 — the operator catch is the part that bites.
Environment these numbers came from Reproducible plan shapes (buffer counts and timing vary a little with cache state). Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, with Docker 29.6.2 running the official postgres:16 image (PostgreSQL 16.14). 2,000,000 JSONB documents; ~200 have status: "archived".

Mental model

A docs(id, data jsonb) table of 2,000,000 documents, each like:

{"status": "active", "priority": 3, "author": "user42", "tags": ["t7","t22"]}

About 200 of them have status: "archived". We ask for those with a containment query — data @> '{"status":"archived"}' — three ways: with no index, with a GIN index on data, and finally the same logical filter written as data->>'status' = 'archived'. We read EXPLAIN (ANALYZE, BUFFERS). The whole exercise is one script, code/jsonb_gin_index.sql.

Setup

docker run -d --name ddia-ch3 -e POSTGRES_PASSWORD=study -e POSTGRES_DB=study postgres:16
sleep 3
docker cp jsonb_gin_index.sql ddia-ch3:/tmp/
docker exec ddia-ch3 psql -U postgres -d study -f /tmp/jsonb_gin_index.sql

Do not read the output yet — make each prediction first.


Step 1 · no index (calibration)

Predict. WHERE data @> '{"status":"archived"}' over 2,000,000 documents, no index on data. How does Postgres run it, and how many pages does it read?

How does an unindexed nested-field filter run?
########## querying a nested field WITHOUT an index ########## Finalize Aggregate (actual time=94.373..97.886 rows=1 loops=1) Buffers: shared hit=14129 read=20354 -> Gather ... -> Parallel Seq Scan on docs (actual rows=67 loops=3) Filter: (data @> '{"status": "archived"}'::jsonb) Rows Removed by Filter: 666600 Buffers: shared hit=14129 read=20354

A Parallel Seq Scan — ~34,483 buffers, ~98 ms — reading and testing every one of the 2,000,000 documents to find ~200. No index, no shortcut.

Step 2 · add a GIN index (the surprise)

Predict. Create a GIN index on the whole data column and run the identical query. Can an index on a JSON blob actually help? How much?

Can a GIN index on a JSON blob help?
########## same query WITH a GIN index on the jsonb column ########## Aggregate (actual time=3.596..3.596 rows=1 loops=1) Buffers: shared hit=475 read=134 -> Bitmap Heap Scan on docs (actual rows=200 loops=1) Recheck Cond: (data @> '{"status": "archived"}'::jsonb) -> Bitmap Index Scan on docs_gin (actual rows=200 loops=1) Index Cond: (data @> '{"status": "archived"}'::jsonb) Buffers: shared hit=409

~3.6 ms — about 27× faster — a Bitmap Index Scan on docs_gin. The index maps the key/value status:archived straight to the ~200 rows that contain it, reading ~600 buffers total (409 to probe the index) instead of 34,483. You can index inside a document.

Step 3 · the catch: the operator matters

Predict. The same filter can be written data->>'status' = 'archived'. Does the GIN index serve that query too?

Does the index serve the ->> equality form?
########## the catch: GIN jsonb_ops does NOT serve ->> equality ########## Finalize Aggregate -> Gather -> Parallel Seq Scan on docs Filter: ((data ->> 'status'::text) = 'archived'::text)

Back to a Seq Scan. The default GIN operator class (jsonb_ops) indexes containment (@>) and key existence (?), but a ->>-then-= expression is opaque to it. Same logical question, different operator — and the index sits unused unless you write the query the way the index understands (or build a separate expression index for ->>).


What you should see

Why

Old intuition treats a document column as an opaque blob: you can store it, but filtering inside means reading every one. A GIN index breaks that assumption. GIN is an inverted index over composite values: instead of one key per row, it extracts the elements inside each JSONB — its keys and values — and, for each such element, records the list of rows that contain it. Querying @> '{"status":"archived"}' becomes a lookup of that one key/value pair in the index, returning the ~200 matching rows directly (the 409-buffer Bitmap Index Scan) rather than scanning 34,483 pages. This is the convergence the book points at: the relational engine gained the document engine's ability to index inside a value.

The catch is that "inside a document" is indexed by element, through specific operators. data @> '{"status":"archived"}' asks "does this document contain this key/value?" — the exact question GIN's inverted structure answers. But data->>'status' = 'archived' first extracts a scalar and then compares it; to the planner that's an arbitrary expression over an opaque function result, and the containment index has nothing to offer it. The fix is to speak the index's language (@>, or a jsonpath @@), or to build a different index for the other form — a B-tree on the expression (data->>'status'). The capability is real; you just have to match the query to the index you built.

The boundary — indexing inside a document isn't free, and it's operator-shaped A GIN index over a whole JSONB column can be large and slower to update than a B-tree, because every key and value in every document becomes an index entry (jsonb_path_ops trades some query flexibility for a smaller index). And it only accelerates the operators it's built for. So the book's convergence is genuine but partial: you get document-style flexibility and indexed queries in one system — provided you choose the operator class and write the query to match. This is the same theme as the earlier Ch. 3 exercises: the relational and document models now overlap, but the seams still show.

Go deeper

Sources: DDIA 2e, Ch. 3, §"Convergence of document and relational databases" and §"Query languages for documents" · PostgreSQL manual, "jsonb Indexing" and "GIN Indexes."