Postgres jsonb vs json: Which Type Should You Use?
Compare Postgres json and jsonb types: storage format, query performance, GIN indexing, operators, and when to pick each with real SQL examples.
Postgres jsonb vs json: Which Type Should You Use?
PostgreSQL ships two JSON column types that look identical from the outside but behave very differently on disk and at query time. Picking the wrong one can cost you index support, query speed, and predictable read behavior. Here is the practical breakdown.
Storage: text vs binary
The json type stores the input exactly as you sent it, as text. Whitespace is preserved, duplicate keys are preserved, and key order is preserved. Every read reparses the string.
The jsonb type stores a decomposed binary representation. Whitespace is stripped, keys are deduplicated (the last value wins), and key order is not preserved. Parsing happens once, on write.
CREATE TABLE demo (a json, b jsonb);
INSERT INTO demo VALUES
('{"k": 1, "k": 2, "x": " spaced "}',
'{"k": 1, "k": 2, "x": " spaced "}');
SELECT a, b FROM demo;
-- a: {"k": 1, "k": 2, "x": " spaced "} (as-is)
-- b: {"k": 2, "x": " spaced "} (deduped, whitespace gone)Notice the value inside the string is untouched in both cases; only structural whitespace and duplicate keys are normalized in jsonb.
Operators: the same syntax, different speed
All the standard JSON operators work on both types:
->get object field or array element as JSON->>get object field or array element as text#>get value at path as JSON#>>get value at path as text
These are jsonb-only:
@>contains<@is contained by?key exists?|any of these keys exist?&all of these keys exist
-- Works on both types
SELECT data->'user'->>'email' FROM events;
SELECT data#>>'{address,city}' FROM events;
-- jsonb only
SELECT * FROM events WHERE data @> '{"status":"paid"}';
SELECT * FROM events WHERE data ? 'refund_id';
SELECT * FROM events WHERE data ?| array['email','phone'];Because jsonb is already parsed, extraction is significantly faster, especially for deep paths and repeated access in the same query.
Indexing: GIN is jsonb-only
This is the single biggest reason most teams pick jsonb. You cannot build a GIN index on a json column.
-- Default jsonb_ops: supports @>, ?, ?|, ?&
CREATE INDEX idx_events_data ON events USING GIN (data);
-- Smaller, path-value only: supports @> but not ?
CREATE INDEX idx_events_data_path ON events USING GIN (data jsonb_path_ops);
-- B-tree on an extracted scalar works on both types via expression index
CREATE INDEX idx_events_status ON events ((data->>'status'));With a GIN index, containment queries like WHERE data @> '{"plan":"pro"}' stay fast across millions of rows. Without it, Postgres does a sequential scan and reparses the text every row on a json column.
Disk footprint
jsonb is usually slightly larger on disk than the equivalent json, because of the binary header, jentry structure, and offset table. In exchange you get O(1) field lookup instead of reparsing. In most workloads this trade is a landslide win.
Write cost
jsonb is slower to insert because Postgres parses and normalizes on the way in. json is essentially a text cast. If you have a write-heavy pipeline that just archives payloads and never queries into them, json can be the right call.
When to use each
Use jsonb when | Use json when |
|---|---|
| You will query, filter, or index inside the document | You just need to store and return payloads verbatim |
You need containment (@>) or key existence checks | Whitespace, duplicate keys, or key order carry meaning (audit logs, webhook receipts, JWT payloads) |
| You need GIN indexes for search | Write throughput matters more than read cost |
| You do repeated extraction in the same query | The column is effectively a text blob |
Real-world example
CREATE TABLE orders (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
received timestamptz DEFAULT now()
);
CREATE INDEX idx_orders_payload ON orders USING GIN (payload jsonb_path_ops);
CREATE INDEX idx_orders_country ON orders ((payload#>>'{shipping,country}'));
-- Fast, uses GIN
SELECT id FROM orders WHERE payload @> '{"status":"fulfilled"}';
-- Fast, uses expression index
SELECT count(*) FROM orders
WHERE payload#>>'{shipping,country}' = 'DE';
-- Update a nested field
UPDATE orders
SET payload = jsonb_set(payload, '{shipping,tracking}', '"1Z999"')
WHERE id = 42;The default
If you have no reason to preserve the raw bytes of the input, start with jsonb. Reach for json only when byte-for-byte fidelity is a hard requirement or when the column is truly write-only storage.