JSONB and arrays
Reading JSONB with -> and ->>, containment with @>, expanding arrays into rows, and when a document column is the wrong design.
After this lesson you can
- Extract JSONB values with ->, ->> and
- Filter documents with @> and ? and expand nested arrays into rows
- Use array operators, ANY and unnest WITH ORDINALITY
- Decide between a JSONB column and normalized tables
PostgreSQL has two JSON types. json stores the text exactly as given;
jsonb parses it into a binary form: keys are deduplicated (the last one
wins), key order and whitespace are not kept, and it supports indexing
and containment operators. Use jsonb unless you must reproduce the
original text byte for byte.
Getting values out
doc -> 'key'returnsjsonb(an object, array, string, number...). On an array,-> 0is the first element; JSON arrays are 0-based.doc ->> 'key'returnstext. Use it to compare with a string or to cast:(doc ->> 'qty')::int.doc #>> '{user,id}'follows a path and returns text.doc @> '{"type": "purchase"}'is containment: does the document include this structure?doc ? 'items'asks whether a top-level key exists.
A missing key is not an error: the operators return NULL, and
jsonb_array_length of NULL is NULL, not 0.
CREATE TABLE events (id int PRIMARY KEY, payload jsonb NOT NULL);INSERT INTO events VALUES (1, '{"type": "purchase", "user": {"id": 7, "country": "GE"}, "items": [{"sku": "tea", "qty": 2}, {"sku": "cup", "qty": 1}]}'), (2, '{"type": "refund", "user": {"id": 8}, "items": [{"sku": "tea", "qty": 1}]}'), (3, '{"type": "purchase", "user": {"id": 8, "country": "DE"}}');SELECT id, (payload -> 'user')::text AS user_jsonb, payload -> 'user' ->> 'country' AS country, payload #>> '{user,id}' AS user_id, payload @> '{"type": "purchase"}' AS is_purchase, payload ? 'items' AS has_items, jsonb_array_length(payload -> 'items') AS item_countFROM eventsORDER BY id;Comparisons on ->> are text comparisons: '10' > '9' is false.
Cast before comparing numbers.
Arrays inside documents become rows
jsonb_array_elements(doc -> 'items') is a set-returning function: one
row per element. Put it in FROM with CROSS JOIN LATERAL (or just a
comma) to pair each element with its parent row. A document without the
array produces no rows, so a cross join drops it; use
LEFT JOIN LATERAL ... ON true if it must stay.
CREATE FUNCTION attempt(stmt text) RETURNS text LANGUAGE plpgsql AS $$BEGIN EXECUTE stmt; RETURN 'ok';EXCEPTION WHEN OTHERS THEN RETURN SQLSTATE || ': ' || SQLERRM;END $$;CREATE TABLE events (id int PRIMARY KEY, payload jsonb NOT NULL);INSERT INTO events VALUES (1, '{"type": "purchase", "items": [{"sku": "tea", "qty": 2}, {"sku": "cup", "qty": 1}]}'), (2, '{"type": "refund", "items": [{"sku": "tea", "qty": 1}]}'), (3, '{"type": "purchase"}');SELECT e.id, item ->> 'sku' AS sku, (item ->> 'qty')::int AS qty, -- the same constants on every row, to show the traps: '{"n": "10"}'::jsonb ->> 'n' > '9' AS text_compare, ('{"n": "10"}'::jsonb ->> 'n')::int > 9 AS int_compare, attempt($$SELECT '{"type": "purchase"}'::jsonb -> 'type' = 'purchase'$$) AS arrow_vs_string, '{"b": 1, "a": 2, "a": 3}'::jsonb::text AS jsonb_keepsFROM events eCROSS JOIN LATERAL jsonb_array_elements(e.payload -> 'items') AS itemORDER BY e.id, sku;PostgreSQL arrays
Native arrays (int[], text[]) are 1-based, and indexing out of
range returns NULL rather than raising. x = ANY (arr) tests membership,
@> containment, && overlap. unnest(arr) WITH ORDINALITY turns an
array into rows with their positions, and array_agg builds one back.
An empty array has cardinality 0 but array_length(arr, 1) NULL.
CREATE TABLE posts (id int PRIMARY KEY, tags text[] NOT NULL);INSERT INTO posts VALUES (1, '{sql,postgres}'), (2, '{postgres}'), (3, '{}');SELECT p.id, t.tag, t.pos, p.tags[1] AS first_tag, p.tags[0] AS zeroth_tag, 'postgres' = ANY (p.tags) AS mentions_pg, cardinality(p.tags) AS n_tagsFROM posts pLEFT JOIN LATERAL unnest(p.tags) WITH ORDINALITY AS t(tag, pos) ON trueORDER BY p.id, t.pos;When not to use JSONB
A JSONB column is a good home for data whose shape varies and which you
mostly read whole: webhook payloads, settings, audit snapshots. It is a
poor home for anything you join on, aggregate across, or must constrain:
there are no foreign keys into a document, CHECKs get awkward, and
the planner has no per-key statistics for an expression like
payload ->> 'type' unless you index that expression or create
statistics on it (CREATE STATISTICS accepts expressions since
PostgreSQL 14). A GIN index on the
column serves @> and ?, which is why containment is the form to
prefer for filters; the indexes lesson shows the plan.
Common mistakes
- Comparing
doc ->> 'n' > '9'and getting a text comparison. - Using
->where text was needed:doc -> 'type' = 'purchase'comparesjsonbwithjsonb, and the right side is not valid JSON, so the query fails to parse it. Use->>. - Expanding an array with a cross join and silently losing parent rows that have no array.
- Indexing a PostgreSQL array from 0 and getting NULL.
What interviewers ask
- json versus jsonb? Text kept verbatim versus parsed binary: jsonb
drops duplicate keys and order, and supports GIN indexes and
@>. - How do you query rows whose JSON array contains an object with a given field? with a nested pattern (indexable with GIN), or expand with and filter.
Exercise · Pro
Units sold per SKU from purchase events
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade