Fetching whole profiles, a JSONB document reads ~20× fewer disk pages than the equivalent join — its fields are co-located. Fetching just one field, the same document reads ~12× more, because it must load the entire record. Same data; the winner flips with the access pattern.
Store a profile as one JSON document and its fields sit together on disk; split it across normalized tables and the pieces scatter, so reassembling one profile means several index lookups. When you need the whole entity, that locality is a big win — the document reads ~20× fewer disk pages than the equivalent join. But locality cuts both ways: when you need just one field, the document forces you to read the entire record, and the normalized narrow column wins by ~12×. You will measure both directions on the same data.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 3 — Data Models and Query Languages, §"Data locality for reads and writes":
If your application often needs to access the entire document … this storage locality has a performance advantage … The locality advantage applies only if you need large parts of the document at the same time.
The book states both halves of the trade. Here you measure each: the whole-entity win, and the single-field loss.
The surprise is about where related data physically sits. These help; each line notes where it shows up.
EXPLAIN (ANALYZE, BUFFERS) — Buffers: shared hit/read counts the 8 KB pages a query touched. Fewer buffers = less I/O; it's the metric here.EXPLAIN and the BUFFERS option. TOAST (#4): the PostgreSQL manual, "TOAST" — how oversized column values are stored and fetched. If only one is new, make it #1 — locality is the whole story, both ways.
postgres:16 image (PostgreSQL 16.14). 200,000 users, 5 positions each (1,000,000 rows), stored normalized and as JSONB documents.
The same profile data, two layouts:
users_n and positions_n (5 positions per user, indexed on user_id). Fetching a full profile joins the two tables.users_doc(id, profile jsonb), the whole profile (user + positions) co-located in one JSONB value.We run two workloads and read Buffers:
The whole exercise is one script, code/data_locality.sql.
docker run -d --name ddia-ch3 -e POSTGRES_PASSWORD=study -e POSTGRES_DB=study postgres:16
sleep 3
docker cp data_locality.sql ddia-ch3:/tmp/
docker exec ddia-ch3 psql -U postgres -d study -f /tmp/data_locality.sql
Do not read the output yet — make each prediction first.
Predict. Fetch 2,000 full profiles — each user plus all their positions. Which layout reads fewer pages, and by roughly how much?
The join reads ~8,289 buffers — a nested loop doing 2,000 separate index lookups into positions, each landing on a different page. The document reads ~408 buffers: the whole profile is one value, fetched in one place. ~20× fewer pages for the same profiles.
Predict. Now fetch just the name of 20,000 users — nothing else. The document has locality on its side… doesn't it?
Now it flips. The normalized query reads ~342 buffers — the users_n table is narrow, so its pages are dense with names. The document reads ~4,057 buffers: to extract one field it must load each entire profile JSONB (positions, descriptions, and all, much of it TOASTed out-of-line). ~12× more pages to get one small field.
A document is stored as one contiguous value, so everything about a profile — the user and all their positions — sits together. Fetching the whole thing is a single lookup that lands on essentially one place on disk. The normalized layout scatters the same profile: the user is in users_n, the five positions are in positions_n, and reassembling it means a lookup per position, each potentially a different page. Across 2,000 profiles that's thousands of small random reads — the 8,289 buffers — versus the document's 408. This is exactly the locality advantage the book describes, and why some relational systems (Spanner's interleaved tables, Bigtable-style wide columns) offer the same co-location without giving up the relational model.
But locality is co-location of the whole record, and that is a liability when you want a sliver of it. To read one field the database must load the value that field lives in — and a full profile JSONB is large enough to be stored out-of-line (TOAST), so pulling "just the name" drags in the positions and their descriptions anyway: 4,057 buffers for 20,000 names. The normalized users_n table has no such baggage; its rows are narrow, so a page holds many names and the read is dense: 342 buffers. Same data, opposite winners — decided entirely by whether you need most of the record or a small part of it.
name text to users_doc ((profile->>'name') STORED) and index it; re-run Step 2. Predict whether you can recover the narrow-read speed while keeping the document.Sources: DDIA 2e, Ch. 3, §"Data locality for reads and writes" · PostgreSQL manual, EXPLAIN (BUFFERS) and "TOAST."