Now-Next

SaaS architecture

json vs jsonb in PostgreSQL: size and speed, measured

json vs jsonb in PostgreSQL 17 and 18, measured on six document shapes: when jsonb is 23% smaller or 41% larger, read speed, GIN, and what it changes.

Chris van Eijk, Now-Next · · 10 min read

Now-Next builds SaaS products and takes over existing ones. What we build

Whether jsonb takes more room than json depends on the JSON you put in, not on the type alone. In PostgreSQL 17.11 and 18.6, which stored every table byte for byte the same size, a million compact documents of about 200 bytes took 17.8% more room as jsonb, while the same documents pretty-printed took 3.6% less; across six document shapes, two input forms and two compression methods we measured everything from 22.6% smaller to 40.9% larger. Reading one key with ->> was 2.7 to 10.6 times faster from jsonb in every case we timed, and only jsonb can be compared, grouped or indexed for containment at all.

Two numbers that show up when you search for this point in opposite directions. In the slides of a PGConf.Russia talk from 2015 by Alexander Korotkov, Oleg Bartunov and Teodor Sigaev, 1.25 million bookmarks take 1,374 MB as jsonb against 1,322 MB as json: “overhead is < 4%”. A benchmark republished on BestHub in September 2026 reports about 888 MB against 1,200 MB for a million rows, 26% smaller. Korotkov’s 4% falls inside the range we measured. The 26% we could not reproduce: we ran the generator printed in that article for a million rows, with json.dumps called without arguments as it is there, which writes a space after every comma and colon, and jsonb came out 6.7% larger, 653.5 against 612.6 MB. In our own measurements four things decided which way it went: what the documents contain, whether the JSON contains whitespace, whether a document is large enough for PostgreSQL to compress it, and which compression method it uses.

json vs jsonb at a glance

PostgreSQL 17.11. Size is the whole table including TOAST, after VACUUM; the read is sum(length(doc->>'first')) over the whole table, without an index:

DocumentsRowsAverage inputjsonb vs json, pglz (default)jsonb vs json, lz4Read one key, json → jsonb
small, compact1,000,000203.4 bytes+17.8%+17.8%1,379 → 204 ms
small, pretty-printed1,000,000248.4 bytes−3.6%−3.6%1,498 → 207 ms
small with long keys, compact1,000,000423.4 bytes+6.3%+6.3%not timed
medium, compact100,0001,517.9 bytes−16.6%−16.6%850 → 310 ms
medium, pretty-printed100,0002,381.9 bytes+16.6%the same1,275 → 313 ms
large, compact5,00020,532.0 bytes+33.0%+22.2%541 → 146 ms
large, pretty-printed5,00028,829.0 bytesthe same+0.1%604 → 144 ms
flat, 780 random strings, compact5,00021,129.0 bytes−22.6%−10.4%430 → 149 ms
flat, pretty-printed5,00024,266.0 bytes−5.5%−10.4%602 → 149 ms
array of 3,500 integers, compact5,00020,692.3 bytes+3.2%+40.9%791 → 249 ms
array of integers, pretty-printed5,00038,216.3 bytes+1.9%+20.0%868 → 249 ms

The read times are from the pglz tables. On PostgreSQL 18.6 they were within 6% of these, except the small jsonb tables, which read 6 to 15% faster; jsonb was the faster type in every row on both versions.

The setup

PostgreSQL 17.11 and 18.6 in throwaway containers from the official images, on one machine with an 8-core Ryzen 7 3700X and 62 GiB of memory. shared_buffers 2 GB, so every table we queried was read from shared memory; autovacuum, JIT and parallel query off, data checksums on. Six document shapes, generated from a fixed seed:

  • small: eleven flat keys of short strings, numbers and a boolean, like {"first":"e6a14425","id":1,"status":"closed","tag":"r520","plan":"free","country":"NO","score":28.78,"active":true,…}
  • small with long keys: the same values under keys like subscription_lifecycle_status
  • medium: a nested order with a customer, an address, seven order lines and five events
  • large: a customer and 165 line items
  • flat: 780 keys with a random 16-character string each, which compresses badly
  • array of integers: 3,500 whole numbers between 0 and 99,999

Each shape was written twice from the same values: compact, and pretty-printed with an indent of two, as JSON.stringify(x, null, 2) writes it. Each went into four tables, loaded with COPY: json and jsonb, each with default_toast_compression pglz (the default) and lz4. Timings were taken inside the server: for queries the median of five runs after one untimed warm-up, for loads, index builds and updates the median of three. The scripts are linked at the end.

Is jsonb bigger than json?

Before compression, always bigger than compact JSON; on disk, it depends. The type stores a parsed document: a header for every key and value, keys sorted, numbers as numeric. To see that layer on its own, we also measured the size of each document as an uncompressed value, with pg_column_size(doc::json) and pg_column_size(doc::jsonb) over all rows, on both versions:

Documentsjson, compactjson, pretty-printedjsonb, either inputjsonb vs compactjsonb vs pretty-printed
small207.4 bytes252.4 bytes245.9 bytes+18.5%−2.6%
small with long keys427.4 bytes—465.8 bytes+9.0%—
medium1,521.9 bytes2,385.9 bytes2,071.9 bytes+36.1%−13.2%
large20,536.0 bytes28,833.0 bytes26,476.1 bytes+28.9%−8.2%
flat21,133.0 bytes24,270.0 bytes22,704.0 bytes+7.4%−6.5%
array of integers20,696.3 bytes38,220.3 bytes54,701.6 bytes+164.3%+43.1%

Three things follow from that table and from the stored sizes above.

The overhead is per key, not per byte. The small documents grew by 38.5 bytes in jsonb, the same documents with long keys by 38.4: 18.5% and 9.0%. Our small documents were not compressed, so the overhead carried over to disk: 17.8% and 6.3% larger tables, a little less than per value because every row also has a header of its own.

Whitespace is gone in jsonb, and that can pay for the headers. json keeps your text as it arrived, indentation and all; jsonb came out the same size from both inputs, and before compression smaller than pretty-printed json in every shape except the integer array. Integers are where jsonb grows most: 3,500 of them took 54.7 KB against 20.7 KB as compact text.

Compression reorders everything. PostgreSQL tries to compress a value once a row gets larger than about 2 kB, according to the documentation on TOAST. The compact medium document, 1,521.9 bytes as json, stayed under that and was stored uncompressed in all 100,000 rows. The same document as jsonb, 2,071.9 bytes, went over, and was compressed in all 100,000 rows, to 1,149.7 bytes with pglz. That is how a type that is 36% larger before compression ends up 16.6% smaller on disk. On the flat documents pglz left the compact json text uncompressed in all 5,000 rows, but did compress the jsonb form. Once both sides were compressed, jsonb was as large or larger in ten of the thirteen cases, up to 33.0% for the large compact documents with pglz and 22.2% with lz4; only the flat documents with random strings were 5.5 to 10.4% smaller as jsonb. Whitespace compresses well, so for pretty-printed medium documents the advantage turned around: 13.2% smaller before compression, 16.6% larger after pglz. And the method matters per shape: for the integer array, lz4 made jsonb 40.9% larger, pglz 3.2%.

Bytes per medium document, 100,000 orders, before compression and as stored with pglz: PostgreSQL 17.11 and 18.6
Type, input and stateBytes per document
json, compact, before compression1,521.9
json, compact, stored: not compressed1,521.9
json, pretty-printed, before compression2,385.9
json, pretty-printed, stored1,018.7
jsonb, either input, before compression2,071.9
jsonb, either input, stored1,149.7

Bar length is linear, against the largest value. Grey is the value before compression, violet the average stored value in the table, both measured with pg_column_size. Only compact json stays under the threshold of about 2 kB and is stored as typed; the two larger forms get compressed and end up smaller.

The result is a ranking that turns the uncompressed one upside down. On disk, pretty-printed json was the smallest of the three, 117.5 MB for the table, then jsonb with 136.9 to 137.0 MB, and compact json, the smallest before compression, was the largest with 164.2 MB.

So for a quick estimate: small documents are 6 to 18% larger as jsonb if your application sends compact JSON, and slightly smaller if it sends pretty-printed JSON. Above about 2 kB, measure your own documents.

Is jsonb faster to read?

Yes, in every case we timed. Extracting one key with ->> from the whole table, without an index, was 2.7 to 10.5 times faster from jsonb on PostgreSQL 17.11 and 2.8 to 10.6 times on 18.6. json has to parse the text again on every read. It made no difference whether we asked for the key written first or the one written last: 1,379 against 1,397 ms for json on the small table, 204 against 200 ms for jsonb.

For documents that PostgreSQL compresses, the compression method mattered as much as the type. Reading one key from the medium jsonb documents took 310 ms with pglz and 96 ms with lz4; from the flat ones 149 against 42 ms. Before ->> can look inside a compressed document, PostgreSQL has to decompress it, and lz4 was the faster of the two at that. It is not free on disk: the jsonb tables were 24.8% larger with lz4 than with pglz for the large documents and 45.1% for the integer array, the same size for the medium ones and 5.3% smaller for the flat ones.

The fastest read is no JSON at all. The same value in its own text column next to the document took 18.1 ms on the medium table, against 311 ms through doc->>'last'. For the large documents, which live in TOAST, the column took 0.49 ms against 146 ms: the scan never had to touch the documents. On the small table, where nothing is compressed, the gain was smaller: 134 against 200 ms.

Loading is where jsonb pays. COPY took 2.4 times as long for the small documents, 3,742 against 1,546 ms for a million rows, and 4.6 times as long for the medium documents with pglz, where jsonb was compressed and json was not. The smallest gap was 1.08 times, on the flat documents.

Does a GIN index help?

Only jsonb has one. CREATE INDEX … USING gin (j) on a json column fails with data type json has no default operator class for access method "gin", and json has no @> operator either. What you can do is cast in the query, doc::jsonb @> '{"tag":"r042"}', and that took 12.3 times as long as the same query on a jsonb column on the small table: 2,909 against 236 ms. On the medium and large tables it was 6.5 and 7.7 times.

With a GIN index on the jsonb column, PostgreSQL 17.11, compact documents, pglz:

Containment queryMatchesNo indexjsonb_opsjsonb_path_ops
small: {"tag":"r042"}982 of 1,000,000236.4 ms4.30 ms0.74 ms
small: {"status":"active"}599,506256.2 ms261.7 ms263.0 ms
small: key value that does not exist0232.5 ms0.07 ms0.05 ms
medium: {"tag":"r042"}119 of 100,000312.5 ms0.76 ms0.43 ms
medium: {"status":"active"}59,917313.8 ms205.9 ms203.3 ms
medium: nested, country in the address8,371331.2 ms34.6 ms33.1 ms
large: one SKU inside an array of 165 items144 of 5,000217.0 ms5.31 ms5.29 ms

For the rare values the index answered in 0.4 to 5.3 ms instead of 217 to 313 ms, and for the nested query with 8,371 matches it was about ten times faster. For a value that six in ten rows have, it did not help on the small table: PostgreSQL 17 kept the sequential scan, and PostgreSQL 18 chose the index and took 261.7 and 242.0 ms, against 268.3 ms without it. On the medium table, where a row is bigger, the index brought that query from 313.8 to about 205 ms.

The index is not small. On the small table jsonb_ops came to 407.1 MB, larger than the 292.9 MB table it indexes; jsonb_path_ops to 299.2 MB. On the large documents jsonb_ops was 3.4 times the size of the table. jsonb_path_ops was smaller every time and about twice as fast to build, 13.2 against 26.1 s on the small table, and on these queries as fast or faster. It does not support the key-exists operator ?, which we showed in our hstore vs jsonb measurement, so pick jsonb_ops only if you need that.

PostgreSQL 18 can build a GIN index with parallel workers. With two, jsonb_ops on the small table took 16.0 s against 27.1 s without, and 7.5 against 15.3 s on the medium table; on the large table with 5,000 rows it did not help, and on 17 the setting made no difference. That is one run per variant. The indexes came out exactly the same size on both versions.

Is updating a key cheaper in jsonb?

json has no function to change a key: json_set does not exist and jsonb_set does not accept json. You go through jsonb and back, which also rewrites your text in jsonb’s format. That took 1.8 times as long as jsonb_set on 50,000 small documents, 513.7 against 282.1 ms, 1.3 times on the medium documents and 1.7 times on the large ones, all with pglz. With lz4, where jsonb_set itself was faster on the compressed documents, the gap grew to 2.8 times for medium and 3.4 times for large.

Either way, PostgreSQL writes the whole document again. Changing one status in a large document, about 6.9 KB compressed, wrote 7,947 bytes of WAL per row with pglz, after the pages were already in the WAL since the last checkpoint. Changing the same status in a separate column wrote 231 bytes, because the document in TOAST stayed where it was. For medium documents a separate column saved nothing: 1,386 against 1,382 bytes per row, because the compressed document lives in the row itself and is copied along with every update.

What does jsonb change in your data?

json stores the text. jsonb stores what it parsed, and gives back its own rendering of it. On both versions, with identical output:

Inputjsonjsonb
{"a":1,"a":2}kept as typed; ->>'a' returns 2, json_object_keys returns a twice{"a": 2}: the last value wins
{"zebra":1,"a":2,"mm":3}kept as typed{"a": 2, "mm": 3, "zebra": 1}
{ "a" : 1 }kept, 12 characters{"a": 1}, 8 characters
1.10, 1e2, 1E-2, -0kept as typed1.10, 100, 0.01, 0
1e400kepta number of 401 characters
1e200000acceptedvalue overflows numeric format
"\u0000"accepted, but ->> fails with unsupported Unicode escape sequencerejected on input
"\ud83d", an unpaired surrogateacceptedrejected on input

Check for duplicate keys before you migrate: jsonb silently keeps the last value, and IS JSON WITH UNIQUE KEYS finds them. Trailing zeros survive, so 1.10 stays 1.10, but [1.10] and [1.1] are equal as jsonb. Numbers are stored as numeric, so 123456789012345678901234567890.123456789 came back digit for digit; that is the exact type we discuss for amounts in money in PostgreSQL. And jsonb’s output is not your input: it puts a space after every colon and comma, so the small documents came back as 224.4 bytes of text where 203.4 went in.

json has no equality at all. =, GROUP BY, DISTINCT, ORDER BY, UNION, IN, min() and a primary key or unique constraint all failed, with errors such as operator does not exist: json = json and could not identify an equality operator for type json. With jsonb, {"a":1}, {"a": 1} and {"a":1.0} formed one group of three.

When would we still use json?

When the exact text matters, such as a webhook payload you verify against a signature, or a document you store and return without looking inside. Compact json was also cheaper to load, and smaller on disk for small documents. For anything you query, filter, group, index or update, we would use jsonb, with jsonb_path_ops if you need a GIN index and not the ? operator, and consider lz4 for documents above 2 kB that you read often, after checking what it does to the size of yours: for two of our four shapes it took 25 to 45% more room. If one key in a large document changes often or is read in every query, give it its own column.

Reproduce the benchmark

Two files: setup.sql creates the measurement procedures, run.sh generates the documents from a fixed seed, starts a container per version, runs every measurement and removes the container again. It needs Docker and Python 3 and about 30 GB of free disk; SCALE=0.02 ./run.sh runs a quick trial. On our machine a full run took about 20 minutes per version. The script writes every timing, size and query plan as CSV to a results folder next to itself, plus the full psql output of the behaviour checks in this article.

What we did not measure

Returning whole documents to a client, where json hands back its text and jsonb has to render it. jsonpath queries and the SQL/JSON functions. B-tree expression indexes on a key. Documents larger than about 40 KB, concurrent writers, a cold cache and tuned compression. The size of jsonb with values we did not generate, such as long texts, floating-point numbers with many digits or deep nesting: our integers already behaved very differently from our strings. All timings are from one machine; read them as relative, not absolute, and differences under 10% as a tie.

Sources: the PostgreSQL 17 documentation on JSON types and on TOAST; Alexander Korotkov, Oleg Bartunov and Teodor Sigaev, JsQuery, slides from PGConf.Russia 2015, the “Jsonb vs Json” slide with the table sizes; PostgreSQL JSON vs JSONB: 1M row benchmark on BestHub, by the dbaplus Community, read on 6 November 2026. All measurements are our own, on PostgreSQL 17.11 on 3 November 2026 and on PostgreSQL 18.6 on 5 and 6 November 2026, in throwaway containers; the BestHub generator we ran on PostgreSQL 17.11 on 6 November 2026, with a fixed seed and otherwise as printed.

Let's talk

What are we building?

One conversation is enough to know whether we fit. Tell us what you have in mind — we will tell you how fast it can happen, and whether we are the right people for it.

Start the conversation