Postgres JSONB vs Separate Tables: Real Benchmarks - NextGenBeing Postgres JSONB vs Separate Tables: Real Benchmarks - NextGenBeing
Back to discoveries

Postgres JSONB vs Separate Tables: Real Benchmarks

Change one integer inside an 8 KB JSONB document and PostgreSQL 17 writes about 16,300 bytes of WAL. Change the same integer when it lives in its own column and it writes 294 bytes. That 55x gap…

Web Development 15 min read
Bekzod Erkinov

Bekzod Erkinov

Oct 1, 2026 • 13 views
Postgres JSONB vs Separate Tables: Real Benchmarks
Photo by Ferenc Almasi on Unsplash
Size:
Height:
📖 15 min read 📝 5,472 words 👁 Focus mode: ✨ Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 9 sections

Postgres JSONB vs Separate Tables: Real Benchmarks

Change one integer inside an 8 KB JSONB document and PostgreSQL 17 writes about 16,300 bytes of WAL. Change the same integer when it lives in its own column and it writes 294 bytes. That 55x gap came out of the benchmark below, and it explains why "just put it in JSONB" works fine in a prototype and then gets expensive in production. It is also only one of the numbers that matter. JSONB won one of the tests outright, came within a few percent of plain columns on two others, and lost the rest by margins from 1.2x to 55x.

This tutorial runs the same one-million-row product catalogue through two designs: one jsonb column per row, and conventional typed columns with a child table for tags. Every number here came from the scripts shown, run against PostgreSQL 17.11. You can rerun the whole thing on a laptop in about fifteen minutes and check my numbers against your own hardware.

The test setup and why it looks the way it does

Benchmarks that compare JSONB to relational tables usually go wrong in one of two ways. Either the JSONB side gets no indexes, or the relational side is normalised so hard that every query needs five joins. I've tried to avoid both. Each design gets the index a competent developer would add for each query pattern, and the relational side is split only where the data really is one-to-many (tags).

Environment:

  • PostgreSQL 17.11 (postgres:17-alpine Docker image)
  • Docker Desktop on Windows 11 (WSL2 backend), 8 vCPUs, ~7.8 GB RAM given to the VM
  • shared_buffers=1GB, work_mem=64MB, maintenance_work_mem=512MB, max_wal_size=4GB, effective_cache_size=4GB
  • Load generated with pgbench custom scripts, 4 clients, 4 threads, 20-second runs
  • All relations prewarmed with pg_prewarm before the read tests, so these are in-memory numbers

Start the server:

docker run -d --name jbench \
  -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=bench \
  postgres:17-alpine \
  -c shared_buffers=1GB -c work_mem=64MB -c maintenance_work_mem=512MB \
  -c max_wal_size=4GB -c effective_cache_size=4GB

On Git Bash for Windows, set MSYS_NO_PATHCONV=1 before any docker exec ... -f /file.sql call. Otherwise the shell rewrites /schema.sql to C:/Program Files/Git/schema.sql, and psql fails with psql: error: C:/Program Files/Git/schema.sql: No such file or directory.

The two schemas

CREATE TABLE products_doc (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  sku  text NOT NULL UNIQUE,
  data jsonb NOT NULL
);

CREATE TABLE products_rel (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  sku        text NOT NULL UNIQUE,
  category   text NOT NULL,
  brand      text NOT NULL,
  color      text NOT NULL,
  price      numeric(10,2) NOT NULL,
  stock      integer NOT NULL,
  weight_g   integer NOT NULL,
  created_at timestamptz NOT NULL
);

CREATE TABLE product_tags (
  product_id bigint NOT NULL REFERENCES products_rel(id),
  tag        text   NOT NULL,
  PRIMARY KEY (product_id, tag)
);

A typical document looks like this:

{"tags": ["tag_140", "tag_42", "tag_168"], "brand": "brand_312", "color": "red",
 "price": 767.71, "stock": 381, "category": "cat_42", "weight_g": 2172,
 "created_at": "2024-02-12T00:00:00+00:00"}

Deterministic data generation

Both tables load from one temporary source table, so the values match exactly. hashint4 scatters brands, prices and stock levels pseudo-randomly, and the same seed gives the same data on every run:

CREATE TEMP TABLE src AS
SELECT g AS n,
       'SKU-' || lpad(g::text, 8, '0')                          AS sku,
       'cat_'   || (g % 50)                                      AS category,
       'brand_' || (hashint4(g) & 1023) % 400                    AS brand,
       (ARRAY['red','green','blue','black','white','grey'])[1 + g % 6] AS color,
       round((((hashint4(g * 7) & 2147483647) % 100000) / 100.0)::numeric, 2) AS price,
       (hashint4(g * 13) & 2147483647) % 500                     AS stock,
       100 + (hashint4(g * 17) & 2147483647) % 5000              AS weight_g,
       timestamptz '2024-01-01' + (g % 600) * interval '1 day'   AS created_at,
       ARRAY['tag_' || (g % 200), 'tag_' || (hashint4(g*3) & 2147483647) % 200,
             'tag_' || (hashint4(g*5) & 2147483647) % 200]        AS tags
FROM generate_series(1, 1000000) g;

INSERT INTO products_doc (sku, data)
SELECT sku, jsonb_build_object(
         'category', category, 'brand', brand, 'color', color,
         'price', price, 'stock', stock, 'weight_g', weight_g,
         'created_at', created_at,
         'tags', to_jsonb(ARRAY(SELECT DISTINCT unnest(tags))))
FROM src ORDER BY n;

INSERT INTO products_rel (sku, category, brand, color, price, stock, weight_g, created_at)
SELECT sku, category, brand, color, price, stock, weight_g, created_at FROM src ORDER BY n;

INSERT INTO product_tags (product_id, tag)
SELECT DISTINCT s.n, t FROM src s, unnest(s.tags) t;

VACUUM ANALYZE products_doc; VACUUM ANALYZE products_rel; VACUUM ANALYZE product_tags;

The data works out to 50 categories of 20,000 rows each, 400 brands, 6 colours, and roughly 3 million tag rows (a few duplicates were dropped by DISTINCT).

Indexes on both sides

-- JSONB side
CREATE INDEX doc_gin_ops   ON products_doc USING gin (data jsonb_path_ops);
CREATE INDEX doc_cat_brand ON products_doc ((data->>'category'), (data->>'brand'));
CREATE INDEX doc_price     ON products_doc (((data->>'price')::numeric));

-- Relational side
CREATE INDEX rel_cat_brand ON products_rel (category, brand);
CREATE INDEX rel_price     ON products_rel (price);
CREATE INDEX tags_tag      ON product_tags (tag, product_id);

The JSONB side gets both a GIN index and B-tree expression indexes. That matches how people actually query documents: sometimes with containment (@>) and sometimes by pulling out one key (->>). Those are different access paths with different costs, and the benchmark keeps them apart.

Storage: JSONB costs 2.5x per row before any indexes

Relation Heap Total (with indexes)
products_doc 284 MB 336 MB (PK + unique only)
products_rel 95 MB 147 MB (PK + unique only)
product_tags 126 MB 244 MB

pg_column_size(data) averages 235 bytes per document. A whole products_rel row averages 95 bytes. The gap has a clear cause. As the PostgreSQL JSON types documentation describes, jsonb stores a decomposed binary form, and every row carries its own copy of every key name ("category", "weight_g", "created_at"…) plus a header of offsets and lengths so keys can be looked up without parsing. A typed column spends its name once, in pg_attribute, and stores an integer in 4 bytes. Inside JSONB a number becomes a numeric with its own header.

Tags change the picture. The relational tag table is 244 MB with its primary key, because each of the 3 million rows pays a 24-byte tuple header plus alignment just to store (bigint, text). The JSONB array holding the same tags costs a few dozen bytes inside a row that already exists. Count products and tags together and the relational design totals 391 MB against 336 MB for JSONB, before secondary indexes.

Index sizes after the secondary indexes above:

Index Size
doc_gin_ops (jsonb_path_ops) 41 MB
GIN with default jsonb_ops (built, then dropped) 49 MB
doc_cat_brand / rel_cat_brand 7.3 MB each
doc_price / rel_price 21 MB each
tags_tag 90 MB

Two things stand out. First, a B-tree on an expression is exactly as big as the same B-tree on a column, because it stores the extracted value and not the document. Second, the GIN index over every key and value in every document is smaller than the B-tree on the tag table alone. GIN compresses its posting lists, and low-cardinality data like this compresses very well.

Build times: 10.2 s for the jsonb_path_ops GIN, 25.9 s for the default jsonb_ops GIN, and 1–2.7 s for each B-tree. If you only need @>, jsonb_path_ops builds 2.5x faster and ends up about 16% smaller. The price is that it does not support the key-exists operators ?, ?| and ?&.

Read benchmarks: where each design wins

Every read test uses pgbench with a custom script and random parameters, so each transaction hits different data. Here is the category-plus-brand filter in all three forms:

-- cb_doc_gin.sql
\set c random(0,49)
\set b random(0,399)
SELECT id, data->>'price' FROM products_doc
WHERE data @> jsonb_build_object('category','cat_' || :c, 'brand','brand_' || :b);
-- cb_doc_btree.sql  (same parameters)
SELECT id, data->>'price' FROM products_doc
WHERE data->>'category' = 'cat_' || :c AND data->>'brand' = 'brand_' || :b;
-- cb_rel.sql  (same parameters)
SELECT id, price FROM products_rel
WHERE category = 'cat_' || :c AND brand = 'brand_' || :b;

Each script runs through the same loop:

export MSYS_NO_PATHCONV=1
for s in cb_doc_gin cb_doc_btree cb_rel price_doc price_rel \
         full_doc full_rel tag_doc tag_rel; do
  docker exec jbench pgbench -U postgres -n -c 4 -j 4 -T 20 \
    -f /pgb/$s.sql bench | grep -E "latency average|^tps"
done

Results

Workload JSONB Relational Ratio
Category + brand, GIN @> 2.86 ms / 1,401 tps — —
Category + brand, B-tree on ->> 1.06 ms / 3,789 tps 0.92 ms / 4,328 tps rel 1.14x faster
Price range, ORDER BY price LIMIT 50 1.20 ms / 3,334 tps 1.13 ms / 3,542 tps rel 1.06x faster
Fetch whole product incl. tags by id 0.57 ms / 7,070 tps 1.04 ms / 3,848 tps JSONB 1.84x faster
Count products with tag X in category Y 8.07 ms / 495 tps 54.07 ms / 74 tps JSONB 6.7x faster

Reading the results

An expression index closes almost the whole gap. With a B-tree on (data->>'category'), (data->>'brand'), the JSONB query runs within 14% of the column version. The leftover cost is re-extracting data->>'price' from each matched row and a slightly bigger heap, since 284 MB of documents spans more pages than 95 MB of columns. The same holds for the price range: once the expression is indexed, the planner walks an ordinary B-tree either way.

GIN containment is the slowest way to answer a selective equality query. The GIN plan took 2.4 ms against 0.66 ms for the composite B-tree on the same 66 rows. EXPLAIN (ANALYZE, BUFFERS) shows why: the GIN bitmap index scan touched 20 buffers and needed 1.4 ms, because it intersects posting lists for category=cat_7 (20,000 entries) and brand=brand_120 (about 2,500 entries). The B-tree jumped straight to the leaf holding both values in 3 buffers. GIN is good at ad-hoc keys you did not plan for. It is not a replacement for a targeted B-tree.

Whole-object fetches favour the document. Getting one product with its tags is a single index lookup and a single heap tuple in JSONB. The relational version needs a primary-key lookup, then a second index scan on product_tags and an array_agg. At 1.84x, this is the pattern where JSONB clearly pays off: an aggregate root that is always read and written as one unit.

Multi-valued filters are where the document wins big. "Products in category 7 tagged tag_33" matches 161 rows. With JSONB, a single @> probe of the GIN index intersects both conditions inside the index:

Bitmap Heap Scan on products_doc (actual time=5.470..7.004 rows=161 loops=1)
  Recheck Cond: (data @> '{"tags": ["tag_33"], "category": "cat_7"}'::jsonb)
  Heap Blocks: exact=161
  Buffers: shared hit=185

The relational plan has no index that spans both tables. It fetched all 20,000 rows of category 7 (12,196 heap blocks, since g % 50 spreads each category across the whole table), hashed 14,798 tag matches, and joined them:

Hash Join (actual time=15.785..53.050 rows=161 loops=1)
  Hash Cond: (p.id = t.product_id)
  Buffers: shared hit=12278

That is 185 buffers against 12,278. You can narrow the gap by denormalising category into product_tags and indexing (tag, category), but that is exactly the kind of workaround a document avoids. A text[] column with its own GIN index would also have handled this query, so strictly the win belongs to multi-valued attributes in one row, not to JSONB as such.

Analytical scans: 2.7x slower for JSONB

Full-table aggregation is the other extreme: no index helps, and every row gets decoded.

-- JSONB
SELECT data->>'category', avg((data->>'price')::numeric), sum((data->>'stock')::int)
FROM products_doc GROUP BY 1;

-- Relational
SELECT category, avg(price), sum(stock) FROM products_rel GROUP BY 1;

Five runs each, all in cache:

Mode JSONB (median) Relational (median) Ratio
Parallel (2 workers) 663 ms 243 ms 2.7x
max_parallel_workers_per_gather = 0 1,216 ms 452 ms 2.7x

The ratio holds steady whether or not parallelism is on, which suggests the cost is per-row CPU and not I/O. Each row needs three key lookups in the binary document, a text copy of each value, and a text → numeric or text → int cast. The column path reads a fixed-offset attribute straight from the tuple. Scan three times more heap on top of that and 2.7x is about what you would predict.

One warning while benchmarking this. My first attempt included ORDER BY 1 LIMIT 1, and both versions came back in about 40 ms. The planner had noticed that doc_cat_brand delivers rows already sorted by category, and it stopped after aggregating the first group. So look at the plan before you trust a surprising number.

Writes: HOT updates, WAL volume and TOAST

This is where the two designs split furthest, and where the decision usually gets made.

-- upd_doc.sql
\set i random(1,1000000)
UPDATE products_doc
SET data = jsonb_set(data, '{stock}', to_jsonb((data->>'stock')::int + 1))
WHERE id = :i;
-- upd_rel.sql  (same random id)
UPDATE products_rel SET stock = stock + 1 WHERE id = :i;

WAL per transaction is the LSN difference divided by the committed count:

docker exec jbench psql -U postgres -d bench -qAtc "CHECKPOINT"
A=$(docker exec jbench psql -U postgres -d bench -qAtc "select pg_current_wal_lsn()")
OUT=$(docker exec jbench pgbench -U postgres -n -c 4 -j 4 -T 20 -f /pgb/upd_doc.sql bench)
B=$(docker exec jbench psql -U postgres -d bench -qAtc "select pg_current_wal_lsn()")
N=$(echo "$OUT" | awk '/actually processed/ {print $NF}')
W=$(docker exec jbench psql -U postgres -d bench -qAtc "select pg_wal_lsn_diff('$B','$A')")
echo "WAL bytes per update: $((W / N))"

Small documents (235 bytes)

Metric JSONB Relational
tps, synchronous_commit=on 1,082 1,064
tps, synchronous_commit=off 4,412 6,705
WAL bytes per update (right after a checkpoint) 12,077 7,757
HOT updates 0 of 21,643 10,921 of 21,268

With synchronous commit on, both designs hit the same ceiling of about 1,070 tps. That is the fsync latency of the Docker Desktop virtual disk, not the database, so the test measures nothing useful. Turn off the flush wait (PGOPTIONS="-c synchronous_commit=off") and the real difference appears: the relational update runs 1.52x faster.

The HOT counter from pg_stat_user_tables explains it. A heap-only tuple (HOT) update skips index maintenance entirely, but only when no indexed column changes and the new version fits on the same page. On products_rel, stock is not indexed, so about half the updates were HOT. The other half hit full pages, since the default fillfactor is 100. On products_doc, the GIN index and both expression indexes all depend on data, and so does every update. As far as HOT is concerned, changing stock inside the document is the same as changing the indexed column. Every update inserted new entries into the GIN index, doc_cat_brand and doc_price, even though category, brand and price never changed.

That means every expression index or GIN index on a JSONB column makes every update to that column non-HOT, whichever key you touch. With columns, you pay index maintenance only for the columns you index.

The large WAL figures on both sides come from full-page images. The first change to each page after a checkpoint logs the whole 8 KB page. Random updates across a million rows keep hitting fresh pages, so these numbers are pessimistic in absolute terms, but the ratio between the two designs still holds.

Large documents: the TOAST cliff

Once a row passes TOAST_TUPLE_THRESHOLD (about 2 KB by default), PostgreSQL compresses it and, if that isn't enough, moves big values into a separate TOAST table. The TOAST documentation spells out the key behaviour: a value is stored and replaced as one unit. Change one byte inside a TOASTed JSONB value and PostgreSQL writes a whole new copy of it.

The test uses 100,000 rows, each with an ~8 KB description string (hex md5 digests, so compression helps only a little):

CREATE TABLE fat_doc (id bigint PRIMARY KEY, data jsonb NOT NULL);
CREATE TABLE fat_rel (id bigint PRIMARY KEY, stock int NOT NULL, description text NOT NULL);

INSERT INTO fat_doc
SELECT g, jsonb_build_object(
  'stock', g % 500,
  'description', (SELECT string_agg(md5((g*1000+i)::text), ' ')
                  FROM generate_series(1,240) i))
FROM generate_series(1,100000) g;

INSERT INTO fat_rel
SELECT id, (data->>'stock')::int, data->>'description' FROM fat_doc;

Both tables start with a 5.9 MB heap and a 781 MB TOAST table. Then the benchmark updates stock only, with synchronous_commit=off:

Metric JSONB Relational
tps 1,935 6,882
Mean latency 2.07 ms 0.58 ms
WAL bytes per update 16,311 294
TOAST size after the 20 s run 1,049 MB (+268 MB) 781 MB (unchanged)
Read stock for one id 0.66 ms 0.52 ms

Twenty seconds of updates to one integer grew the TOAST table by a third, and dead TOAST chunks stay until vacuum reclaims them. In the relational table the description column is untouched, so its TOAST pointer carries over into the new tuple unchanged and only a small heap tuple gets written. Reads lose too: data->>'stock' has to fetch and decompress the whole 8 KB value just to return a three-digit number.

If a document holds a large, rarely changing blob next to small, often changing counters, split them. Keep the counters in columns, or at least in a separate small JSONB column.

The planner cannot see inside your documents

Performance is not just raw execution speed. The planner picks plans from row estimates, and by default it has no statistics on JSONB keys. Compare the estimates with the actual rows:

EXPLAIN ANALYZE SELECT count(*) FROM products_doc WHERE data->>'color' = 'red';
EXPLAIN ANALYZE SELECT count(*) FROM products_doc WHERE (data->>'stock')::int < 10;
Predicate Estimated rows Actual rows Error
data->>'color' = 'red' (no expression stats) ~5,000 166,666 33x under
data @> '{"color":"red"}' 101,011 166,666 1.6x under
color = 'red' (column) ~167,800 166,666 accurate
(data->>'stock')::int < 10 ~333,000 19,809 17x over
stock < 10 (column) ~24,500 19,809 1.2x over

These are hard-coded defaults: 0.5% selectivity for equality on an expression the planner knows nothing about, and 33% for an inequality. On one table with one predicate, the planner still chose a sensible parallel sequential scan. In a five-way join, a 33x underestimate is the classic way to end up with a nested loop that runs for minutes.

There are two fixes. An expression index makes ANALYZE collect statistics on that expression, which is part of why the indexed category/brand queries planned well. From PostgreSQL 14 you can also collect expression statistics without building an index:

CREATE STATISTICS doc_color_stats ON (data->>'color') FROM products_doc;
ANALYZE products_doc;

EXPLAIN SELECT count(*) FROM products_doc WHERE data->>'color' = 'red';
-- Parallel Seq Scan on products_doc  (cost=0.00..44383.07 rows=69918 width=0)
-- 69,918 per worker x 2.4 ≈ 167,800 — now accurate

The stock < 10 estimate was still the default rows=138890 per worker after this, because that query casts to int and the statistics object covers the uncast text expression. The planner matches the expression exactly, cast included. See CREATE STATISTICS for the syntax.

A hybrid schema that keeps the wins

The benchmark points to a clear split. Put a field in a real column when you filter, sort, aggregate or update it often, or when you need a constraint on it. Put it in JSONB when it varies by product type, is read with the whole row, is multi-valued, or is written once and rarely queried on its own.

CREATE TABLE products (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  sku        text NOT NULL UNIQUE,
  category   text NOT NULL,
  brand      text NOT NULL,
  price      numeric(10,2) NOT NULL CHECK (price >= 0),
  stock      integer NOT NULL DEFAULT 0,
  -- variable, per-category attributes: read with the row, rarely filtered alone
  attributes jsonb NOT NULL DEFAULT '{}'
             CHECK (jsonb_typeof(attributes) = 'object'),
  tags       text[] NOT NULL DEFAULT '{}'
) WITH (fillfactor = 90);  -- leave room on each page for HOT updates of stock

CREATE INDEX products_cat_brand ON products (category, brand);
CREATE INDEX products_price     ON products (price);
CREATE INDEX products_tags      ON products USING gin (tags);
CREATE INDEX products_attrs     ON products USING gin (attributes jsonb_path_ops);

In this layout stock is not indexed and sits outside attributes, so updates to it can be HOT, and fillfactor = 90 leaves space on each page for the new versions. Tags keep their single-row GIN advantage through a native array. The GIN index on attributes still covers the ad-hoc @> queries for fields nobody planned for. The trade-off is that every write to attributes updates that GIN index. Keep hot counters out of it.

When a JSONB key turns out to be hot after all, promote it without changing application writes, using a stored generated column:

ALTER TABLE products
  ADD COLUMN screen_inches numeric
  GENERATED ALWAYS AS ((attributes->>'screen_inches')::numeric) STORED;

CREATE INDEX products_screen ON products (screen_inches);

This fixes the statistics problem, because the column gets ordinary ANALYZE stats, and it gives you a plain typed index. It does not fix the HOT problem: a generated column is recomputed whenever the row is updated, and because it depends on attributes, changing attributes changes an indexed column.

PostgreSQL 17 also adds JSON_TABLE, which turns documents into rows for reporting queries without a view full of ->> casts:

SELECT jt.*
FROM products_doc,
     JSON_TABLE(data, '$' COLUMNS (
       category text    PATH '$.category',
       price    numeric PATH '$.price',
       stock    int     PATH '$.stock')) AS jt
WHERE products_doc.id = 42;

JSON_TABLE makes the SQL easier to read. It does not make the scan faster: the per-row decode cost from the aggregation test still applies.

Common pitfalls

Using -> where you meant ->>. data->'category' = 'cat_7' compares jsonb to a literal that PostgreSQL parses as jsonb, so it fails with ERROR: invalid input syntax for type json because cat_7 is not valid JSON. The text-returning operator is ->>. And an index built on (data->>'category') is used only by queries that write exactly data->>'category'.

Expecting jsonb_path_ops to serve ?. A WHERE data ? 'discount' query silently ignores a jsonb_path_ops index and falls back to a sequential scan. Only the default jsonb_ops operator class supports key-existence operators. In this data set that index was 20% bigger and took 2.5x longer to build.

Casting mismatches that make an index unusable. An index on ((data->>'price')::numeric) does nothing for WHERE (data->>'price')::float8 > 10. Pick one cast per key and use it everywhere, ideally through a view or a generated column.

Relying on GIN for selective lookups on hot paths. GIN had 2.7x the latency of a targeted B-tree in the category-plus-brand test. Use GIN for flexibility and B-trees for the queries you know you will run.

Letting documents grow past ~2 KB with mutable fields inside. Each update rewrites the whole TOASTed value: 16 KB of WAL to change one integer here. Watch pg_column_size(data) percentiles, not just the average.

Forgetting that JSONB normalises its input. Duplicate keys collapse to the last value, key order is not kept, and whitespace is thrown away. Anything that needs the exact original text (signature checks on webhook payloads, for example) belongs in json or text.

Numeric precision surprises. A JSONB number is stored as numeric, so {"price": 19.90} stays exact inside Postgres. Many JSON client libraries decode it into an IEEE double, though, and 0.1 + 0.2 problems show up in application code instead.

Benchmarking bulk loads without thinking about constraints. Loading the 3 million product_tags rows with the foreign key already in place took 3 min 51 s, because each row fires a referential-integrity check. That number says nothing about JSONB against columns. It says to add foreign keys after a bulk load. For the same reason, the 25.4 s against 8.9 s load times of the two product tables are not a fair comparison: the JSONB insert also built the document and de-duplicated the tag array.

Trusting fsync-bound numbers. Both update tests ran at the same ~1,070 tps until synchronous commit was turned off. If your write benchmark shows two very different designs running at identical speed, you are probably measuring the disk.

Verdict by workload

A summary of the measured ratios, relational over JSONB unless marked:

  • Indexed equality filter: 1.14x in favour of columns, provided JSONB has a matching expression index
  • Indexed range scan with ordering: 1.06x in favour of columns
  • Full aggregation: 2.7x in favour of columns
  • Small-row update, CPU-bound: 1.52x in favour of columns, with 0% HOT on the JSONB side
  • Update to a small field in an 8 KB document: 3.6x in favour of columns in throughput, 55x in WAL
  • Whole-object read with child data: 1.84x in favour of JSONB
  • Multi-valued attribute plus scalar filter: 6.7x in favour of JSONB (or a native array)
  • Storage: 2.5x more heap per row for JSONB, but less in total once a separate tag table is counted

JSONB is not slow, and columns are not always faster. Each layout makes some access paths cheap and others expensive. So list the queries and updates your application actually runs, run the scripts above on your own data shapes, and keep each field in whichever layout makes its most common access path cheap.

Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

🎁 Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.