JSON vs JSONB in PostgreSQL: when the B matters
PostgreSQL has two JSON types and the names undersell the difference.
JSON stores the text you sent, byte for byte. JSONB
parses it once on write and stores a binary tree. Everything that matters
downstream, query speed, indexing, operators, follows from that one choice.
What JSONB buys you
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload JSONB NOT NULL
);
SELECT * FROM events WHERE payload @> '{"type": "refund"}';
SELECT payload->>'customer_id' FROM events;
- Queries don't reparse. With
JSON, every->>access parses the whole text again, per row, per query.JSONBnavigates the binary form. - GIN indexes. The containment query above can be served
by an index; the plain
JSONversion can't be indexed for content at all:
For one hot key, an expression B-tree is smaller and faster than indexing the whole document:CREATE INDEX idx_events_payload ON events USING GIN (payload);CREATE INDEX idx_events_type ON events ((payload->>'type')); - Richer operators.
@>,?,jsonb_set,||for merge, JSONPath: most of the useful toolbox is JSONB-only or JSONB-first.
What JSONB costs
The write pays the parse, and the binary form doesn't keep what the text
had: key order is lost, duplicate keys are collapsed (last one wins),
whitespace is gone, and 1.50 may come back as 1.5.
Updates rewrite the whole value, jsonb_set on one field stores a
new copy of the document (normal MVCC behavior, but people expect in-place
edits and size their tables wrong). Very large documents get TOASTed either
way; ripping one hot field out into a real column beats tuning JSON access to
it.
The rare cases for plain JSON
You're storing the payload as evidence: a webhook body that must be
replayable byte-for-byte, a signed document whose hash covers the exact text,
an audit trail where "what did they actually send" is the requirement. There,
fidelity is the feature and JSON (or even TEXT) is
honest. If you both need the evidence and want to query it, store the raw text
once and a JSONB shadow column for querying.
The column that should have been columns
The failure mode worth flagging in review isn't JSON vs JSONB, it's JSONB
as a schema escape hatch. When every query touches
payload->>'status' and the application can't function
unless customer_id is inside the blob, those are columns wearing
a JSON costume: no types, no NOT NULL, no FK, no cheap index. Keep the
document for what's genuinely document-shaped (variable vendor payloads,
user-defined attributes) and promote what the schema depends on:
CREATE TABLE events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_type VARCHAR(40) NOT NULL,
customer_id BIGINT REFERENCES customers (id),
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
In the generated code
Either type reaches Java as a String by default (or a mapped
type via @JdbcTypeCode(SqlTypes.JSON)), Python as
dict, TypeScript as unknown, the honest type, since
nothing in the DDL says what's inside. If the payload has a stable shape,
paste a sample of it and generate a real model for the application boundary;
the database column stays JSONB, the code stops passing unknown
around.