PostgreSQL’s jsonb type stores JSON in a decomposed binary format that’s fast to query and can be indexed. It lets you keep flexible, document-style data next to your relational tables, often removing the need for a separate document database.
json vs jsonb
| json | jsonb | |
|---|---|---|
| Storage | Exact text copy | Binary, parsed |
| Whitespace, key order, duplicate keys | Preserved | Normalized (last duplicate key wins) |
| Query speed | Re-parsed every time | Fast |
| Indexing (GIN) | No | Yes |
Use jsonb unless you must preserve the exact original text.
Create and insert
CREATE TABLE events (
id bigserial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
data jsonb NOT NULL
);
INSERT INTO events (data) VALUES
('{"type": "signup", "user": {"id": 42, "country": "JP"}, "tags": ["trial", "ads"]}'),
('{"type": "payment", "user": {"id": 42}, "amount": 19.5}');
Read values
SELECT data->'user' AS user_json, -- jsonb
data->'user'->>'id' AS user_id, -- text
data#>>'{user,country}' AS country, -- path, as text
data['type'] AS type -- subscript syntax (PG 14+)
FROM events;
| Operator | Returns |
|---|---|
-> key / index |
jsonb |
->> key / index |
text |
#> / #>> path array |
jsonb / text |
@> |
Left contains right |
? / `? |
/?&` |
@? / @@ |
jsonpath exists / matches |
Filter rows
SELECT * FROM events WHERE data->>'type' = 'signup';
SELECT * FROM events WHERE data @> '{"type": "signup", "user": {"country": "JP"}}'; -- containment
SELECT * FROM events WHERE data->'tags' ? 'trial'; -- array element
SELECT * FROM events WHERE data @? '$.amount ? (@ > 10)'; -- jsonpath
SELECT * FROM events WHERE (data->>'amount')::numeric > 10; -- cast for comparison
Update
UPDATE events SET data = jsonb_set(data, '{user,country}', '"HK"') WHERE id = 1;
UPDATE events SET data = data || '{"processed": true}' WHERE id = 1; -- merge keys
UPDATE events SET data = data - 'processed' WHERE id = 1; -- remove key
UPDATE events SET data = data #- '{user,country}' WHERE id = 1; -- remove path
UPDATE events SET data['status'] = '"done"' WHERE id = 2; -- PG 14+
Every update writes a new version of the whole row, so very large documents that change often are expensive.
Index jsonb
GIN index on the whole column, for containment and key-existence queries:
CREATE INDEX idx_events_data ON events USING gin (data); -- supports @>, ?, ?|, ?&, @?, @@
CREATE INDEX idx_events_data_path ON events USING gin (data jsonb_path_ops); -- smaller, faster; @>, @?, @@ only
Expression (B-tree) index for one frequently filtered or sorted key:
CREATE INDEX idx_events_type ON events ((data->>'type'));
SELECT * FROM events WHERE data->>'type' = 'payment'; -- uses idx_events_type
Write queries to match the index. A GIN index helps data @> '{"type":"payment"}', but not data->>'type' = 'payment', which needs the expression index. Verify with EXPLAIN ANALYZE.
Turn JSON into rows, and rows into JSON
-- array elements as rows
SELECT id, jsonb_array_elements_text(data->'tags') AS tag FROM events;
-- object keys and values
SELECT key, value FROM events, jsonb_each(data) WHERE id = 1;
-- build JSON for an API response
SELECT jsonb_agg(jsonb_build_object('id', id, 'type', data->>'type')) FROM events;
Schema design advice
- Promote stable, frequently queried fields to real columns (with types, constraints, foreign keys). Keep jsonb for variable attributes, metadata, and raw payloads.
- Validate shape with
CHECKconstraints for required keys (CHECK (data ? 'type')), or in the application. - Avoid huge documents. Rows over about 2 KB are compressed and moved to TOAST storage, which makes access slower.
- Use generated columns to expose important keys as typed, indexable columns:
ALTER TABLE events ADD COLUMN event_type text GENERATED ALWAYS AS (data->>'type') STORED;
jsonb vs MongoDB
With jsonb you get documents plus transactions, joins, SQL, and one database to back up and operate. MongoDB still has advantages for very large document workloads and its native sharding. See PostgreSQL vs MongoDB and MySQL JSON columns.
Quick reference
| Task | SQL |
|---|---|
| Get text value | data->>'key' |
| Nested path | data#>>'{a,b}' |
| Contains | data @> '{"k":"v"}' |
| Key exists | data ? 'k' |
| Set value | jsonb_set(data, '{a,b}', '"v"') |
| Index all | USING gin (data jsonb_path_ops) |
| Index one key | CREATE INDEX ON t ((data->>'k')) |
Related: PostgreSQL full-text search and PostgreSQL extensions.