Skip to content
datahacks.dev
JSON

JSON Versus JSONB in PostgreSQL and Which to Store

PostgreSQL has two JSON types. What each keeps, drops and costs, measured on 200,000 rows, and the narrow cases where plain json is the right choice.

7 min read
Summarize in AI
Flat illustration of two options weighed side by side, with the JSON and PostgreSQL logos.

PostgreSQL has had a JSON type since 9.2 and the binary jsonb type since 9.4, and ten years later people still ask which one to pick. The short answer is jsonb, and the short answer is right most of the time. The reasons are worth knowing, because the other 10 percent of the time json is the correct choice and picking jsonb loses something you cannot get back. This article lists what each type does to a document, shows the speed and size difference on a table of 200,000 rows, and ends with the narrow set of cases where plain json wins.

What happens to the text

json is a text column with a validity check. When you insert a document, PostgreSQL parses it to make sure it is well formed and then stores the original characters unchanged. jsonb parses the document and stores the result in a binary form. Everything that differs between them follows from that.

Here are the differences I could observe, with the query that showed each:

Text
select '{"b": 1,  "a": [1, 2],  "a": 3, "c":  null}'::json;
 {"b": 1,  "a": [1, 2],  "a": 3, "c":  null}

select '{"b": 1,  "a": [1, 2],  "a": 3, "c":  null}'::jsonb;
 {"a": 3, "b": 1, "c": null}

json returned the input. jsonb collapsed the whitespace, sorted the keys by length and then bytes, and kept one a, the last. Other differences:

Behaviorjsonjsonb
WhitespaceKeptNormalized
Key orderAs typedSorted by length, then bytes
Duplicate keysAll keptLast one wins
{"a":1.0}{"a":1.0}{"a": 1.0}
1e21e2100
\u0000 in a stringAcceptedError: \u0000 cannot be converted to text
Equality operator =Does not existExists
Containment, existenceNone@>, ?, @?
GIN indexNoYes
Access (->, #>>)Re-parses the text each callDirect lookup

The \u0000 row catches people. An API that sends a NUL escape inside a string will fail to insert into jsonb with unsupported Unicode escape sequence, while json takes it (and returns it as text to a client that may choke on it). The usual repair is to strip the character before inserting.

The key order claim deserves a caution in the other direction. In json, -> on a document with a duplicate key returned 2, the last value, as it does in jsonb, even though both copies of the key are stored. The text keeps the duplicate, and access still resolves to one of them. The practical result is that you cannot rely on duplicates having any meaning in either type. A look at how parsers disagree on this is in duplicate keys in a JSON object.

Numbers and the type's limits

jsonb stores numbers as PostgreSQL numeric, so there is no 53-bit limit. {"a": 12345678901234567890} came back as typed. The precision problem exists in whatever client reads it: a JavaScript client using JSON.parse rounds that value to the nearest double. This is the issue in how JSON handles big numbers, and the database is not where it bites.

jsonb keeps the digits of the number but not its textual form: {"a": 0.10} returned 0.10 and 1E2 was shown as 100, so trailing zeros survive and exponent notation does not. A value like 1e400 is accepted and expanded into a 401 digit integer, which is legal and heavy. The values NaN and Infinity are not JSON, and both types reject them: '{"a": NaN}'::jsonb raised invalid input syntax for type json.

The speed difference

Because json holds text, every access parses it. The table below is from a single run on the 200,000 rows, using a nested path in one query and a top-level key in another, with a sequential scan in each case:

Text
filter                                json      jsonb
j #>> '{specs,dims,w}' = '20'         107 ms    34 ms
j ->> 'brand' = 'Acme'                 95 ms    19 ms

jsonb was three to five times faster for reads, on a small document. On a larger document the gap grows, since json has to scan more text to reach a key near the end and jsonb can jump to it. The cost moves to the write side: jsonb does extra work on input, because it parses and builds the binary form. For a load of 200,000 rows I did not see a difference large enough to matter, and if bulk-load speed is your constraint, measure it with your data.

The size difference is not what you expect

The common claim is that jsonb is bigger than json, and the common counterclaim is that it is smaller because it drops whitespace. Both can be true. On my documents, written by jsonb_build_object and cast to json, the text had one space after each colon and comma, and the totals were:

Text
 json_size | jsonb_size 
-----------+------------
 34 MB     | 45 MB

jsonb was a third larger. Its binary form carries a header word per container and a table of offsets so it can find a key quickly, and for small documents full of short keys that overhead outweighs the saved whitespace. With pretty-printed input the balance can flip. Your result depends on the data. If storage is tight, store a sample both ways and check pg_column_size on real documents instead of guessing. For documents past 2 KB both types are compressed in TOAST, and the difference shrinks.

Indexes and operators decide it for most tables

If you ever filter on the contents of the column, the choice is made for you. json has no =, < or @>; select '{"a":1}'::json = '{"a":1}'::json fails with operator does not exist: json = json, which also means you cannot DISTINCT or GROUP BY a json column without casting it to text. jsonb supports all of them. In my table select distinct over the jsonb column returned 200,000 distinct documents with no complaint.

And jsonb is the only type with a GIN index. Indexing a json column means indexing an expression on it, such as ((j ->> 'sku')) for a btree, which works but is re-parsing text during every index build and every insert. Indexing JSONB with GIN and when it helps covers the options. The operators available once you have jsonb are in querying JSONB with the arrow operators, and the whole workflow is mapped in working with JSONB in PostgreSQL from first query to index.

Converting between them

The two types cast in both directions. '{"a": 1}'::json::jsonb gives {"a": 1}, and '{"b":1,"a":2}'::jsonb::text gives {"a": 2, "b": 1}, with the keys already reordered. Going from jsonb back to json cannot restore what was dropped, so converting a column from json to jsonb is a one-way step for whitespace, order and duplicates:

SQL
alter table events alter column payload type jsonb using payload::jsonb;

That statement rewrites the table, takes an ACCESS EXCLUSIVE lock for the duration, and fails on the first row with an invalid NUL escape. On a big table, add a jsonb column, backfill in batches, validate, then swap the names. Before you start, count the rows that would change meaning, such as documents with duplicate keys, with a query that compares payload::text to payload::jsonb::text after normalizing, or test a sample.

What the application sees

Your driver decides what a jsonb value looks like in code, not the database. psycopg and pg (node-postgres) both parse json and jsonb into native objects by default, so the key order the application sees is whatever the language's dictionary does. Some drivers return the text unparsed, as PHP's PDO does, and you call json_decode yourself. In the application, jsonb order is not preserved, so code that serializes a document back out will emit sorted keys, which is a harmless change unless something downstream hashes the text or compares signatures. A webhook payload that you must verify with an HMAC is the textbook case: the signature covers the original bytes, and re-serializing a jsonb copy produces different bytes.

When plain json is the right type

Reach for json when the exact text matters more than the ability to query it:

Audit trails and webhook bodies. If you store what a third party sent, so that you can later prove what it said, or verify its signature, keep the original bytes. json keeps whitespace, order and duplicates; text is even closer, because it will also keep invalid JSON, which a receiver sometimes needs to log.

Order-sensitive documents. A few formats put meaning into key order, such as an ordered mapping in a configuration where the first match wins. jsonb will sort that away. Better to avoid such formats, and if you cannot, json preserves them.

Documents you only ever pass through. If the column is written once and read back whole, never filtered, and the application does all the work, json is cheaper to write and has no normalization surprises. Even here, text with a CHECK constraint using pg_input_is_valid(col, 'json') (PostgreSQL 16 and later) does the same job with fewer surprises about the type.

Bulk-loading very large documents once. jsonb input parsing cost is real for documents of many megabytes. If the load must be fast and the column will be read rarely, measure both.

For everything else, store jsonb, and do the work of deciding which fields are real columns. When the decision is between a JSON column and structure at all, read when JSONB is the wrong choice. The type reference is the PostgreSQL manual page on JSON types, and the grammar both types accept is in RFC 8259.

Also inPostgreSQL
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.