Default to JSONB. JSON stores the raw text; JSONB stores a parsed binary form — and the binary form is what makes querying and indexing fast. The "B" is for binary, and it changes almost everything about how the value behaves.
JSON — verbatim text
JSON keeps an exact textual copy of what you inserted: original whitespace, key order, and duplicate keys are all preserved. Every operation that reaches into it (->, ->>, jsonb_path_query, etc.) must re-parse the text each time. It cannot be indexed with a GIN index. About the only reason to choose JSON is if you must round-trip a document byte-for-byte and never query into it — a rare need.
JSONB — decomposed binary
JSONB parses the document once on insert into an efficient binary structure. The trade-offs flip:
- Whitespace and key order are not preserved (keys are reordered, roughly by length then bytes).
- Duplicate keys are collapsed — the last value wins.
- Insert is marginally slower (it parses up front); reads and queries are much faster (no re-parse).
- It supports rich operators and, crucially, GIN indexing.
SELECT '{"b":1, "a":2, "a":3}'::json; -- {"b":1, "a":2, "a":3} (verbatim, dup kept)
SELECT '{"b":1, "a":2, "a":3}'::jsonb; -- {"a": 3, "b": 1} (reordered, dedup)
Querying and indexing JSONB
SELECT data->>'email' FROM users WHERE data @> '{"active": true}';
-- GIN index for containment / key-existence queries:
CREATE INDEX ON users USING gin (data);
-- jsonb_path_ops: smaller/faster, supports @> but not key-exists (?):
CREATE INDEX ON users USING gin (data jsonb_path_ops);
The @> containment operator and ?/?|/?& key-existence operators are GIN-accelerated. For a single hot field, a plain expression index is often better:
CREATE INDEX ON users ((data->>'email'));