JSONB or proper columns?
Measured on Postgres 16 at one million rows: jsonb costs 2.3x on disk and 1.4x on point lookups, both survivable. The number that actually hurts is the update path — one index on a jsonb column kills every HOT update and multiplies WAL by 7.8x, even when the update never touches the indexed field.
Use typed columns for anything you update, and jsonb for data you write once and read whole. The read-side penalty everyone argues about is small — 1.4x on a point lookup, 2.9x on a scan. The write-side penalty is not: putting a single index on a jsonb column disabled every HOT update in my test and multiplied WAL by 7.8x for changing two characters, even though the update never touched the indexed field. That is the number the folklore misses on both sides.
The short answer
- On Postgres 16 at one million rows, the same data cost 103 MB as typed columns and 237 MB as a single jsonb column — 2.3x, before any index.
- A GIN index on that jsonb column was 208 MB — larger than the typed-column table plus all of its indexes — and took 14.1 seconds to build against 246 ms for a btree on an equivalent integer column.
- A jsonb point lookup through a btree expression index is about 1.4x slower than a btree on a column (16–20 µs versus 11–13 µs per lookup); the same lookup through GIN is 5.5x slower, at 73–91 µs, because it goes through a bitmap scan touching 12 buffers instead of 4.
- Changing one field in a jsonb document does not rewrite the whole document — until you index the column. With no index on the jsonb column, changing two characters in a 293-byte document cost 88 bytes of WAL per row and 100% HOT updates — identical to typed columns. Adding one btree index on an unrelated field inside the same document pushed it to 485–693 bytes per row and zero HOT updates.
- Once a document crosses the TOAST threshold it is genuinely rewritten. Changing two characters in a 2,204-byte TOASTed document cost 2,805–3,924 bytes of WAL per row; the same edit on a 1,955-byte row of 52 typed columns cost 1,966 bytes, deterministically.
What was measured, and on what
Postgres 16.15 in Docker on an Apple M3 laptop — 8 cores, 16 GB RAM, macOS 26.4.1, Docker 29.5.2, default shared_buffers=128MB, max_wal_size=4GB. These are indicative figures from a laptop in Docker, not a lab benchmark. Every timing was run at least three times and is reported as a range. Your absolute numbers will differ; the ratios are the point.
One million rows of the same seven fields — user_id, status, amount, country, device, session_id, created_at — stored three ways:
CREATE TABLE t_cols (id bigserial PRIMARY KEY, user_id int, status text,
amount numeric(10,2), country text, device text,
session_id text, created_at timestamptz);
CREATE TABLE t_json (id bigserial PRIMARY KEY, doc jsonb NOT NULL);
CREATE TABLE t_gin (id bigserial PRIMARY KEY, doc jsonb NOT NULL);
t_cols got btree (user_id), t_json got btree ((doc->>'user_id')), and t_gin got USING gin (doc). Rows were built into staging tables first, so the insert timings measure the insert and not the cost of constructing jsonb — that was a separate 2.5 seconds for a million documents.
How much bigger is jsonb on disk?
After loading and VACUUM ANALYZE:
| typed columns | jsonb + btree expr | jsonb + GIN | |
|---|---|---|---|
| Heap | 103 MB | 237 MB | 237 MB |
| Indexes | 43 MB | 43 MB | 229 MB |
| Total | 146 MB | 280 MB | 466 MB |
| Index build, 1M rows | 246 ms | 1,080 ms | 14,121 ms |
| 1M row bulk insert | 1,248–1,525 ms | 1,619–2,005 ms | — |
| Insert 100k into indexed table | 424–467 ms | 536–811 ms | 2,499–2,811 ms |
The heap difference is exactly what you would expect: jsonb stores every key name in every row. Seven short key names per document, one million documents, 134 MB.
The GIN number is the one to sit with. A GIN index on the whole document was 208 MB on its own — bigger than the entire typed-column table with both of its indexes. Switching to jsonb_path_ops, which indexes hashed paths rather than every key and value separately, brought it down to 128 MB and the build to 9.2 seconds. If you only ever run containment queries, use jsonb_path_ops; it saves 38% and costs nothing.
Bulk insert is the one place the folklore is simply wrong. Writing a million jsonb documents into a heap took 1,619–2,005 ms against 1,248–1,525 ms for columns — roughly 1.2x, and the run-to-run spread on this laptop was nearly as wide as the difference itself. Two byte-identical jsonb tables differed by 25% between runs. I would not defend a number tighter than "about the same". Maintaining a GIN index during insert is a different story: 100,000 rows into a GIN-indexed table took 2,499–2,811 ms against 424–467 ms for columns, about 6x.
Is a jsonb lookup slower?
Yes, but not by much — unless you go through GIN. Twenty thousand random point lookups by user_id in a PL/pgSQL loop, three warm runs, with the empty loop costing 3.8–4.9 ms:
| btree on a column | btree on doc->>'user_id' |
GIN containment | |
|---|---|---|---|
| 20,000 lookups | 219 / 265 / 268 ms | 329 / 346 / 399 ms | 1,456 / 1,470 / 1,816 ms |
| per lookup | ~11–13 µs | ~16–20 µs | ~73–91 µs |
| relative | 1.0x | 1.4x | 5.5x |
| buffers per lookup | 4 | 4 | 12 |
Both btrees touch the same four buffers, so the 1.4x is not IO — it is the cost of extracting a field from a document and comparing it as text. GIN is 5.5x because a containment query is a bitmap scan: build a bitmap from the posting lists, scan the heap, then recheck the condition against the actual document.
The lesson is not "GIN is bad". It is that GIN is not a substitute for a btree on a known field. If you know at design time that you will look rows up by user_id, index that expression with a btree — or better, make it a column.
What about scans and aggregates?
Full-table work on a numeric field, warm cache, parallel workers on:
| Query over 1M rows | typed columns | jsonb |
|---|---|---|
count(*), sum(amount) where amount between 500 and 600 |
34.4 / 35.0 / 66.9 ms | 97.4 / 101.3 / 137.0 ms |
avg(amount) over the whole table |
30.0 / 30.1 / 32.6 ms | 70.3 / 76.5 / 81.3 ms |
About 2.4x to 2.9x. Part of that is the 2.3x more heap to read; the rest is (doc->>'amount')::numeric running once per row, parsing text out of the binary document and then a numeric out of the text. If you aggregate over a field regularly, that field wants to be a column — a better query plan will not save you, because the work is per-row.
The mirror image is what makes jsonb worth having. Asked for a predicate nobody planned for — an exact session_id, which no table had a matching index on — the GIN index answered in 3.5 / 4.8 / 5.3 ms where either sequential scan took 96 to 494 ms. That is jsonb's whole argument in one measurement: it answers questions you did not anticipate.
Does updating one field rewrite the whole document?
This is where I expected to confirm the folklore, and instead had to write down the opposite.
I built a table of 100,000 rows with a 293-byte jsonb document, fillfactor=25 so every new row version had room on its own page, and changed a two-character field. Then, on the same table, I replaced the entire 256-byte payload. WAL measured as a pg_current_wal_lsn() delta, with no full-page images in the window:
| Edit to a 293-byte jsonb document | WAL per row |
|---|---|
Change 2 bytes ("US" → "BB") |
88 bytes |
| Replace the whole 256-byte payload | 345 bytes |
No — a jsonb update does not inherently rewrite the whole document. When the new row version lands on the same page as the old one, Postgres logs only the span that changed, trimming the common prefix and suffix. Eighty-eight bytes to change two characters in a 293-byte document is the same WAL a typed column costs.
It holds right up to the point where the row version cannot stay on its page. Then the whole tuple is logged, and jsonb's 2.3x row width becomes a 2.3x WAL bill. There are three ways to fall off that cliff, and two of them are things people do without noticing.
Do HOT updates still happen with jsonb?
Only if nothing indexes the jsonb column. This is the finding worth the whole article.
Same table, same 100,000 rows, same two-character edit. The only variable is what is indexed:
| 100k rows, change one field | WAL per row | HOT updates |
|---|---|---|
Typed columns, btree on the unchanged blob column |
88 / 88 / 88 bytes | 100,000 / 100,000 |
jsonb, no index on doc |
88 bytes | 100,000 / 100,000 |
jsonb, btree on (doc->>'blob') — a field the update never touches |
684 / 693 / 485 bytes | 0 / 100,000 |
A HOT update is skipped when an indexed column changes. Postgres decides that by comparing the column values, and for an expression index the indexed column is the whole doc. Change any byte anywhere in the document and Postgres concludes the indexed column changed, even when the indexed expression evaluates to exactly the same string. So the update is not HOT, a new index entry is written, the old one is left for vacuum, and the WAL goes up 5.5x to 7.9x.
The typed-column row is the control. Identical data, identical index target, identical edit — and because country and blob are separate columns, Postgres sees the indexed column did not change and keeps every update HOT at 88 bytes.
On the full seven-field dataset at fillfactor=90 the same pattern shows up, scaled by how much real work each layout does. Changing one field on 100,000 rows, three runs:
| Layout | WAL per row | HOT updates |
|---|---|---|
| Columns, changed field unindexed | 240 / 239 / 282 B | 22,122–22,170 |
Columns + btree on user_id |
297 / 294 / 307 B | 22,133–22,176 |
jsonb, no index on doc |
334 / 334 / 342 B | 25,690–25,694 |
jsonb + btree on (doc->>'user_id') |
450 / 421 / 452 B | 0 |
jsonb + GIN on doc |
1,326 / 1,330 / 1,408 B | 0 |
Un-indexed jsonb costs 1.4x the WAL of un-indexed columns, which is less than its 2.3x row width — the prefix trimming again. Indexed jsonb costs 1.5x. jsonb with a GIN index costs 4.5x, because every changed document rewrites posting-list entries for every key and value it contains.
The third cliff is TOAST. Documents above roughly 2 KB are compressed and stored out of line, and a change to any byte rewrites the whole out-of-line value. Changing two characters in a 2,204-byte document cost 2,805–3,924 bytes of WAL per row. The same 50 fields as typed text columns stayed inline at 1,955 bytes per row and cost a flat, reproducible 1,966 bytes — the whole row, but only once.
So what does getting it wrong cost?
Use typed columns for fields you filter on, aggregate over, or update: the identity of the row, its status, its amounts, its foreign keys. Use jsonb for whole documents you write once and read back whole — a captured webhook payload, a run history record, a third-party API response whose shape you do not control.
The cost of getting it wrong in the jsonb direction is a table that is 2.3x larger, aggregates that are 2.9x slower, and — if you put any index on the document — an update path that generates 5.5x to 7.9x the WAL and never takes the HOT path. On a hot table that is replication lag, vacuum pressure and index bloat all at once, and it does not show up in a query plan. The cost of getting it wrong in the column direction is a migration.
The hybrid that survives contact with production is boring: promote the three or four fields you query to real columns, keep the rest in a jsonb column, and do not index the jsonb column unless you have measured that you need to. If you index it, use jsonb_path_ops. And if you find yourself reaching for a GIN index to make one known field fast, you have found a column. The same reasoning applies to picking a primary key type: the read side is where people argue, and the write side is where the bill arrives.
Check it yourself
About three minutes, one container, nothing installed. This reproduces the size table and the WAL-and-HOT result:
docker run -d --name jsonbench -p 55432:5432 \
-e POSTGRES_PASSWORD=demo -e POSTGRES_DB=bench postgres:16 -c max_wal_size=4GB
sleep 12
docker exec -i jsonbench psql -U postgres -d bench -q <<'SQL'
CREATE TABLE src AS
SELECT g AS user_id,
(ARRAY['pending','paid','shipped','refunded','cancelled'])[1+(g%5)] AS status,
round((random()*1000)::numeric,2) AS amount,
(ARRAY['US','GB','DE','FR','VN','JP','BR','IN'])[1+(g%8)] AS country,
md5(g::text) AS session_id
FROM generate_series(1,1000000) g;
CREATE TABLE src_json AS SELECT to_jsonb(src) AS doc FROM src;
CREATE TABLE t_cols (id bigserial PRIMARY KEY, user_id int, status text,
amount numeric(10,2), country text, session_id text) WITH (fillfactor=90);
CREATE TABLE t_json (id bigserial PRIMARY KEY, doc jsonb NOT NULL) WITH (fillfactor=90);
CREATE TABLE t_gin (id bigserial PRIMARY KEY, doc jsonb NOT NULL) WITH (fillfactor=90);
INSERT INTO t_cols (user_id,status,amount,country,session_id)
SELECT user_id,status,amount,country,session_id FROM src;
INSERT INTO t_json (doc) SELECT doc FROM src_json;
INSERT INTO t_gin (doc) SELECT doc FROM src_json;
CREATE INDEX ix_cols ON t_cols (user_id);
CREATE INDEX ix_json ON t_json ((doc->>'user_id'));
CREATE INDEX ix_gin ON t_gin USING gin (doc);
SQL
docker exec -i jsonbench psql -U postgres -d bench \
-c "VACUUM ANALYZE t_cols, t_json, t_gin" \
-c "SELECT relname,
pg_size_pretty(pg_relation_size(oid)) AS heap,
pg_size_pretty(pg_indexes_size(oid)) AS indexes,
pg_size_pretty(pg_total_relation_size(oid)) AS total
FROM pg_class WHERE relname IN ('t_cols','t_json','t_gin') ORDER BY 1"
for T in cols json gin; do
case $T in
cols) U1="UPDATE t_cols SET country='AA' WHERE id<=100000"
U2="UPDATE t_cols SET country='BB' WHERE id<=100000";;
*) U1="UPDATE t_$T SET doc=jsonb_set(doc,'{country}','\"AA\"') WHERE id<=100000"
U2="UPDATE t_$T SET doc=jsonb_set(doc,'{country}','\"BB\"') WHERE id<=100000";;
esac
docker exec -i jsonbench psql -U postgres -d bench -tAq <<SQL
CHECKPOINT;
$U1;
SELECT pg_stat_force_next_flush(); SELECT pg_sleep(2);
SELECT pg_stat_reset_single_table_counters('t_$T'::regclass); SELECT pg_sleep(2);
SELECT pg_current_wal_lsn() AS l0 \gset
$U2;
SELECT pg_stat_force_next_flush(); SELECT pg_sleep(2);
SELECT 't_$T: ' || pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), :'l0'))
|| ' WAL, ' || round(pg_wal_lsn_diff(pg_current_wal_lsn(), :'l0')/100000.0) || ' B/row, '
|| (SELECT n_tup_hot_upd FROM pg_stat_user_tables WHERE relname='t_$T')
|| '/' || (SELECT n_tup_upd FROM pg_stat_user_tables WHERE relname='t_$T') || ' HOT';
SQL
done
docker rm -f jsonbench
A run of that script on a fresh container printed:
relname | heap | indexes | total
---------+--------+---------+--------
t_cols | 100 MB | 43 MB | 143 MB
t_gin | 200 MB | 167 MB | 368 MB
t_json | 200 MB | 43 MB | 243 MB
t_cols: 27 MB WAL, 280 B/row, 21757/100000 HOT
t_json: 47 MB WAL, 496 B/row, 0/100000 HOT
t_gin: 99 MB WAL, 1034 B/row, 0/100000 HOT
A second run of the same script gave 283 / 496 / 1,034 bytes per row — the WAL figures reproduce to within a couple of percent, and the HOT counts are stable. The HOT column is the article. Watch it go to zero the moment the jsonb column is indexed, and note that the index in this script is on user_id — a field the UPDATE does not touch. Drop ix_json and re-run the t_json block to see it come back.