JSONB and arrays
When a document column beats another table, how the JSON operators differ, and how to index both JSONB and arrays properly.
jsonb versus json
json | jsonb | |
|---|---|---|
| Storage | Original text, verbatim | Parsed binary form |
| Key order / whitespace | Preserved | Not preserved, duplicates resolved |
| Access cost | Re-parses on every access | Already decomposed; cheaper |
| Indexable | Not with a plain index | Yes, with GIN |
| Use it when | You must reproduce the exact input bytes | Almost every other case |
SELECT payload -> 'sku' AS sku_json, -- jsonb
payload ->> 'sku' AS sku_text, -- text
payload #> '{ship,zip}' AS zip_json, -- path, jsonb
payload #>> '{ship,zip}' AS zip_text -- path, text
FROM events;
-- containment, key existence, and merge
SELECT id FROM events WHERE payload @> '{"status":"failed"}';
SELECT id FROM events WHERE payload ? 'retry_count';
UPDATE events SET payload = payload || '{"reviewed":true}'::jsonb WHERE id = 7;
UPDATE events SET payload = payload - 'debug_trace' WHERE id = 7;The single-arrow operators return jsonb and stay in JSON land; the double-arrow operators return text so you can compare against a string or cast it. Forgetting the extra arrow is the most common source of operator does not exist errors.
Indexing JSONB
-- general purpose: supports @>, ?, ?|, ?&
CREATE INDEX idx_events_payload ON events USING gin (payload);
-- containment only: smaller and faster
CREATE INDEX idx_events_payload_path ON events USING gin (payload jsonb_path_ops);
-- frequent equality or sorting on one extracted field
CREATE INDEX idx_events_sku ON events ((payload ->> 'sku'));
CREATE INDEX idx_events_created ON events (((payload ->> 'created_at')::timestamptz));- A GIN index speeds up containment, not path extraction:
payload -> 'sku' = 'X'needs the expression index. - Expression indexes need the expression written exactly the same way in the query, or the planner will not match it.
- GIN writes are slower than b-tree writes; on write-heavy tables keep the number of GIN indexes small.
- Keep JSONB for genuinely variable attributes. Fields you filter and join on constantly belong in real columns.
💡
JSONB does not enforce shape. If a field must always exist and always be an integer, promote it to a column with a
NOT NULL constraint and let the database protect it.Arrays and when to use them
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tags text[] NOT NULL DEFAULT '{}'
);
INSERT INTO articles (tags) VALUES (ARRAY['sql', 'postgres', 'indexing']);
SELECT id FROM articles WHERE tags @> ARRAY['postgres']; -- contains all
SELECT id FROM articles WHERE tags && ARRAY['sql', 'redis']; -- overlaps
SELECT id, unnest(tags) AS tag FROM articles; -- expand to rows
SELECT tag, count(*) FROM articles, unnest(tags) AS tag
GROUP BY tag ORDER BY count(*) DESC LIMIT 10; -- tag cloud
CREATE INDEX idx_articles_tags ON articles USING gin (tags);- Use an array when the values are a small, value-like list you never join to or constrain: tags, flags, a fixed set of channels.
- Use a junction table when the elements are entities with their own attributes you will filter, update or count across.
- Parameter lists map naturally onto arrays:
WHERE id = ANY($1::bigint[])avoids building anINlist of unknown length. array_aggcollapses rows into an array; combine it withGROUP BYto return one row per parent.
FAQ
Should this be a JSONB column or a table?
Model as tables when you need constraints, foreign keys and per-field indexes. Add a JSONB column for the long tail of attributes that differ per row and are only read together with the row.
How do I find rows where a key is missing?
Use
NOT (payload ? 'key'), or compare against SQL NULL with payload ->> 'key' IS NULL — a JSON null and a missing key produce the same answer that way, so pick based on whether the distinction matters.Related
psql and the basics of SQL Transactions and MVCC basics
Last refreshed 2026-09-18.