JSONB is Postgres's binary JSON type — nested, flexible, queryable, indexable. It turns Postgres into a credible document store while you keep transactions, joins with relational data, and the rest of the SQL ecosystem.
Most "we need a NoSQL document database" cases are better served by JSONB. This page is the working set.
Two JSON types in Postgres:
JSON — text storage; preserves whitespace, key order, duplicates. Faster to write; slower to query.JSONB — binary storage; normalised; queryable; indexable. Faster to query; slightly slower to write.For almost every use case: JSONB. Use plain JSON only if you need to preserve the exact text representation.
JSONB normalises on write:
INSERT INTO docs (data) VALUES ('{"a": 1, "a": 2, "b": 3 }');
SELECT data FROM docs;
-- {"a": 2, "b": 3} -- duplicate key removed; whitespace gone
Internally, JSONB stores as a tree of typed elements, not as a string. Operations don't re-parse.
Update of a single field rewrites the whole JSONB value (Postgres MVCC). For very large JSONB values updated frequently, this is expensive — split into separate columns or rows.
-- Get value at path
SELECT data->'name' FROM docs; -- returns JSONB
SELECT data->>'name' FROM docs; -- returns text
SELECT data->'address'->>'city' FROM docs;
SELECT data#>'{address,city}' FROM docs; -- text path syntax
SELECT data#>>'{address,city}' FROM docs;
-- Has key
SELECT * FROM docs WHERE data ? 'email';
-- Has all keys
SELECT * FROM docs WHERE data ?& array['name', 'email'];
-- Has any of
SELECT * FROM docs WHERE data ?| array['phone', 'email'];
-- data contains the given JSON (subset match)
SELECT * FROM docs WHERE data @> '{"status": "active"}';
-- Array containment
SELECT * FROM docs WHERE data->'tags' @> '["urgent"]';
@> is the workhorse for JSONB queries. It matches partial structure; the right side need only be a subset.
SELECT * FROM docs WHERE data @? '$.address.city ? (@ == "Berlin")';
More expressive but less commonly used. Useful for complex path queries; awkward syntax.
The trick with JSONB is index strategy.
CREATE INDEX idx_docs_data ON docs USING GIN (data);
Indexes every key-value pair. Supports @>, ?, ?&, ?| queries. Works for diverse query patterns; large index size.
jsonb_path_opsCREATE INDEX idx_docs_data ON docs USING GIN (data jsonb_path_ops);
Smaller (~30% smaller) and faster for @> queries specifically. Doesn't support ?, ?&, ?|. Use when containment is your primary query.
For most JSONB use cases, jsonb_path_ops is the right pick.
CREATE INDEX idx_docs_user_id ON docs ((data->>'user_id'));
CREATE INDEX idx_docs_status ON docs ((data->>'status')) WHERE data->>'status' IS NOT NULL;
Far smaller than full-JSONB GIN; faster for queries on that specific path. Right when you have a few hot paths and don't need general-purpose JSON queries.
The pattern: GIN for general flexibility; functional indexes for hot paths.
CREATE INDEX idx_docs_tenant_status ON docs (
tenant_id,
(data->>'status')
);
For multi-tenant queries that filter by JSONB fields, this works.
The pragmatic shape: typed columns for stable, queryable, constrainable fields; JSONB for flexible / sparse / payload fields. Hybrid.
UPDATE docs SET data = '{"new":"data"}' WHERE id = 1;
Simplest; rewrites the row.
jsonb_set — update a pathUPDATE docs SET data = jsonb_set(data, '{address,city}', '"Berlin"') WHERE id = 1;
Cleaner than reading-modifying-writing in app code.
UPDATE docs SET data = data || '{"updated_at": "2026-04-25"}' WHERE id = 1;
Merges the new fields into the existing JSONB.
UPDATE docs SET data = data - 'temporary_field' WHERE id = 1;
UPDATE docs SET data = data #- '{nested,path}' WHERE id = 1;
For complex updates, consider extracting into proper columns instead. Once update patterns stabilise, JSONB's flexibility is no longer earning its keep.
JSONB columns over 2KB get TOAST-stored (out-of-line). Reading the row reads the inline part fast; reading the JSONB requires the TOAST fetch. For very large JSONB values, this matters.
Mitigation: split large JSONB into smaller pieces; use separate tables for parts queried independently.
@> for deep nesting@> is fast with jsonb_path_ops GIN for top-level matches; deeper-nested matches are slower. Profile.
Each JSONB update rewrites the row entirely (MVCC). High-update workloads on big JSONBs produce dead tuples; vacuum has to clean. Monitor.
Postgres's planner doesn't have great statistics for JSONB. Selectivity estimates can be off; query plans suboptimal.
Workaround: where critical, use functional indexes on specific paths and let the planner use those columns' statistics.
JSONB is schemaless by default. Enforce structure where it matters:
-- CHECK constraint
ALTER TABLE docs ADD CONSTRAINT data_has_id
CHECK (data ? 'id' AND jsonb_typeof(data->'id') = 'string');
For complex schema validation, store schemas externally (JSON Schema) and validate at the application or via a function trigger.
Mature pattern: data starts in JSONB while shape is uncertain; specific frequently-queried fields get extracted to proper columns.
-- Add a typed column, backfill from JSONB
ALTER TABLE docs ADD COLUMN status TEXT;
UPDATE docs SET status = data->>'status' WHERE data ? 'status';
CREATE INDEX ON docs (status);
-- Optionally remove from JSONB to avoid duplication
UPDATE docs SET data = data - 'status';
Or, generated columns:
ALTER TABLE docs ADD COLUMN status TEXT
GENERATED ALWAYS AS (data->>'status') STORED;
CREATE INDEX ON docs (status);
The generated column auto-syncs; no separate update logic.
For most "document store" use cases, Postgres + JSONB matches MongoDB on:
And exceeds on:
Where MongoDB wins:
For most teams in 2026: JSONB in Postgres beats MongoDB for the same use cases.
For schema-flexible data:
data column.A pragmatic JSONB schema ages well. A JSONB-everything schema stagnates.