Postgres JSONB vs JSON: Which One Should You Use?
Use jsonb. In the Postgres JSONB vs JSON choice, jsonb stores a parsed binary form that is faster to query, supports containment and existence operators, and can be indexed with GIN, B-tree and hash indexes. Plain json stores the exact input text and reparses it every time you touch it, so it only wins when you must keep the original text byte for byte, including key order, whitespace and duplicate keys.
That is the whole answer for most apps. The rest of this article shows the differences in SQL so you can see them yourself, covers the few cases where json is the better choice, and explains how to convert an existing json column without locking your app.
#The differences at a glance
json | jsonb | |
|---|---|---|
| Stored as | The input text, unchanged | A decomposed binary format |
| Whitespace | Kept | Dropped |
| Key order | Kept as written | Not kept (Postgres uses its own internal order) |
| Duplicate keys | All kept; functions see the last one | Only the last value is kept |
| Writing | Slightly faster (just validates the text) | Slightly slower (parses and converts) |
| Reading and querying | Reparsed on every access | Fast, no reparse |
@>, <@, ?, ?|, ?& | Not available | Available |
@? and @@ (jsonpath) | Not available | Available |
| Indexes | Only expression indexes on extracted values | GIN on the whole document, plus B-tree and hash |
Equality (=) | Not defined | Defined |
The official PostgreSQL documentation makes the same recommendation: most applications should store JSON as jsonb unless they have specialized needs, such as legacy assumptions about the order of object keys. See the JSON types page for the full rules.
#See it in SQL
Run these in any Postgres 17 database, or in the SQL Editor of a StitchDB project.
#Whitespace and key order
select '{"b": 1, "a": 2}'::json as as_json,
'{"b": 1, "a": 2}'::jsonb as as_jsonb; as_json | as_jsonb
--------------------+------------------
{"b": 1, "a": 2} | {"a": 2, "b": 1}json gives you back exactly what you stored. jsonb normalizes spacing and reorders keys (shorter keys first, then by bytes). Your app should never depend on key order in a JSON object anyway; the JSON spec does not promise one.
#Duplicate keys
select '{"role": "user", "role": "admin"}'::json ->> 'role' as from_json,
'{"role": "user", "role": "admin"}'::jsonb ->> 'role' as from_jsonb,
'{"role": "user", "role": "admin"}'::jsonb as stored_jsonb;Both lookups return admin, because the last value wins. But the json column still stores both keys, while jsonb stores only {"role": "admin"}. If an upstream system sends duplicate keys and you need to prove what it sent, that is an argument for json (or for storing the raw payload as text).
#Numbers
jsonb stores numbers as Postgres numeric. That means:
select '{"n": 1.230e-5}'::json as as_json,
'{"n": 1.230e-5}'::jsonb as as_jsonb;
-- as_json: {"n": 1.230e-5}
-- as_jsonb: {"n": 0.00001230}Exponent notation is expanded and trailing zeros are kept. Values beyond the range of numeric are rejected by jsonb but accepted by json. For normal app data neither matters; for scientific payloads it is worth knowing.
#Operators that only jsonb has
create table public.products (
id bigint generated always as identity primary key,
attrs jsonb not null default '{}'
);
insert into public.products (attrs) values
('{"color": "red", "sizes": ["S", "M"], "brand": "Acme"}'),
('{"color": "blue", "sizes": ["L"], "discontinued": true}');
-- Containment: rows whose attrs include this sub-document
select id from public.products where attrs @> '{"color": "red"}';
-- Existence: rows that have a top-level key
select id from public.products where attrs ? 'discontinued';
-- jsonpath: any size equal to "L"
select id from public.products where attrs @? '$.sizes[*] ? (@ == "L")';Try the same queries on a json column and Postgres reports that the operator does not exist. With json you are limited to the extraction operators ->, ->>, #> and #>>, and to functions that reparse the text on every call.
#Why jsonb is faster to read
A json value is text. Every time a query runs attrs ->> 'color', Postgres parses the whole document to find the key. For a 20 KB document in a table of a million rows, that is a lot of repeated parsing.
A jsonb value has already been parsed. Postgres can jump to a key without scanning the text, and it can compare whole values for equality. The cost moves to write time, where the input is parsed once. For a typical app that reads far more than it writes, that trade is clearly worth it.
#Indexing is the bigger win
Speed per row matters less than being able to skip rows entirely. jsonb supports a GIN index over the whole document:
create index products_attrs_gin on public.products using gin (attrs);That one index serves @>, ?, ?|, ?&, @? and @@ on any key. If you only use containment and jsonpath, the smaller jsonb_path_ops class is usually faster:
create index products_attrs_path on public.products using gin (attrs jsonb_path_ops);With json the best you can do is an expression index on one extracted value, such as create index on t ((doc ->> 'email')). That works, and the same trick works for jsonb, but you need one index per key you filter on. The companion article Querying JSONB in Postgres covers operators and index choices in depth.
#When plain json is the right choice
There are a few honest cases for json:
- Audit or webhook logs that must match the original bytes. If you verify a signature over the raw body, or you need to show a partner exactly what they sent, keep the original. Many teams store it as
textorjsonnext to a parsedjsonbcopy. - Write-heavy append logs you rarely query. If you insert millions of payloads and read them only when debugging, skipping the parse on write saves a little CPU.
- Order-sensitive consumers you cannot change. Some old clients expect keys in a fixed order. Fixing the client is better, but
jsonbuys time.
Even then, a common pattern is both: a raw json column for evidence and a generated jsonb column for queries.
create table public.webhook_events (
id bigint generated always as identity primary key,
received_at timestamptz not null default now(),
raw json not null,
body jsonb generated always as (raw::jsonb) stored
);
create index webhook_events_body_gin on public.webhook_events using gin (body jsonb_path_ops);#When neither: use real columns
jsonb is not a replacement for a schema. If every row has an email, a status and a created_at, make them columns. You get type checks, not null, foreign keys, cheap B-tree indexes and clear statistics for the query planner. Postgres keeps statistics on columns, not on keys inside a JSON document, so filters on JSON keys often get poor row estimates.
A good rule: columns for anything you filter, join or sort on regularly; jsonb for the long tail of optional, varying or user-defined attributes. If you are weighing a document database for that flexibility, see Postgres JSONB vs MongoDB.
You can still validate the shape of a jsonb column with a check constraint:
alter table public.products
add constraint attrs_is_object check (jsonb_typeof(attrs) = 'object');For full JSON Schema validation, the pg_jsonschema extension is in the list of extensions available on StitchDB (see /docs/database/extensions).
#Converting json to jsonb
If you already have a json column, the conversion is one statement:
alter table public.events alter column payload type jsonb using payload::jsonb;Be careful on large tables. Changing a column type rewrites the table and blocks reads and writes while it runs. On a table with millions of rows that can be minutes of downtime. The safer pattern:
- Add a new nullable
jsonbcolumn. - Backfill it in batches of a few thousand rows.
- Update your code to write both columns, then read the new one.
- Drop the old column.
alter table public.events add column payload_b jsonb;
-- repeat until it updates 0 rows
update public.events set payload_b = payload::jsonb
where id in (
select id from public.events where payload_b is null limit 5000
);On StitchDB, the SQL Editor checks schema changes before they run: an alter column ... type on a busy table triggers a warning that it blocks reads and writes while the table is rewritten, with a safer version offered instead. The details are in Safe schema changes.
#Using jsonb columns on StitchDB
Every StitchDB project is a dedicated Postgres database, so jsonb works exactly as described above. A few platform specifics:
- REST and SDK filters. The
cs(contains),cd(contained by) andov(overlaps) operators work on JSON columns through the auto-generated REST API. In the SDK:
import { createClient } from 'stitchdb-js'
const stitchdb = createClient('https://<project-ref>.p.stitchdb.com', '<anon-key>')
const { data, error } = await stitchdb
.from('products')
.select('id, attrs')
.contains('attrs', { color: 'red' })- Indexes in Studio. Database, then Indexes, can create
ginindexes as well as btree, gist, hash and brin, so you do not need SQL for the basic case. - Search. A search index can include
jsonandjsonbcolumns, so text inside your documents is searchable with ranking. - Row Level Security still applies. A
jsonbcolumn is just a column. Policies on the table decide who can read it, as with any other data (Row Level Security).
#FAQ
#Is jsonb always faster than json?
For reads and queries, yes in practice, because json is reparsed on every access. For inserts, json is slightly faster since it only validates the text. Most apps read far more than they write, so jsonb comes out ahead.
#Does jsonb take more disk space than json?
It can be somewhat larger or smaller depending on the document, because it stores structure alongside values but drops whitespace and duplicate keys. Both are compressed by TOAST when large. Size is rarely the deciding factor; indexability is.
#Can I index a json column?
Only with expression indexes on extracted values, for example ((doc ->> 'email')). You cannot create a GIN index over a whole json document. Convert to jsonb if you need flexible indexed queries.
#Does jsonb keep the order of array elements?
Yes. Only object key order is lost. Arrays keep their element order in both types.
#Should I store everything in one jsonb column?
No. Put fields you filter, join or sort on in normal columns, and use jsonb for optional or varying attributes. You get better constraints and better query plans.
Try jsonb with GIN indexes on a real database: create a free StitchDB project.