SQL for Backend Engineers · JSON in PostgreSQL

JSONB and SQL/JSON

Reading and filtering jsonb, flattening arrays with JSON_TABLE, building nested JSON with jsonb_agg, and indexing JSON for containment queries.

  • 11 min read
  • 13 practice questions
  • 3 graded problems
On this page
  1. Description
  2. json or jsonb
  3. Reading values: -> and ->>
  4. Filtering: containment and SQL/JSON paths
  5. Arrays to rows: JSON_TABLE
  6. Building JSON: jsonb_build_object and jsonb_agg
  7. Changing JSON
  8. Indexing JSON
  9. Column or JSON?
  10. Where it breaks
  11. Tradeoffs
  12. Check yourself
  13. Key takeaways
  14. Quick revise
  15. Practice

Description

JSON turns up in every backend: webhook payloads stored as they arrive, per-customer settings, feature flags, API responses you'd like the database to put together for you. PostgreSQL handles JSON very well - it can read it, search it, index it and build it. The catch is that JSON values follow their own rules, and SQL written as if they were ordinary columns goes wrong quietly:

  • A filter for payments "over 1,000" returns a 900 payment, because the amounts were compared as text.
  • An export drops every order whose item list is empty.
  • An API response gives every customer without orders a phantom order: {"id": null, "total": null}.

We'll go from the basics to the parts that need care:

  1. json versus jsonb - which one to use.
  2. Reading values with -> and ->>, and the text-comparison trap.
  3. Filtering with containment (@>) and SQL/JSON paths.
  4. Turning JSON arrays into rows with JSON_TABLE.
  5. Building JSON responses, and changing documents.
  6. Indexing JSON, and when a plain column is the better home.

Examples use five events with payload jsonb, and two customers - one with orders, one without; every result was produced in the sandbox on PostgreSQL 18.

json or jsonb

Postgres has two JSON types. json stores the text exactly as given; jsonb parses it into a binary form - duplicate keys collapse to the last one, key order and whitespace aren't kept, and it can be indexed and compared:

SELECT '{"b": 1, "a": 2, "a": 3}'::json  AS as_json,
       '{"b": 1, "a": 2, "a": 3}'::jsonb AS as_jsonb;
as_jsonas_jsonb
{"b": 1, "a": 2, "a": 3}{"a": 3, "b": 1}

Use jsonb unless you must keep the original text byte for byte (a signed webhook body you'll verify later, say).

Reading values: -> and ->>

-> returns JSON (jsonb), ->> returns text, and #>> follows a path of keys to text:

SELECT payload -> 'user'             AS user_json,
       payload -> 'user' ->> 'email'  AS email,
       payload #>> '{user,id}'        AS user_id
FROM events
WHERE id = 1;
user_jsonemailuser_id
{"id": 7, "email": "ava@example.com"}ava@example.com7

payload -> 'currency' is the JSON string "usd" - quotes included - so comparing or displaying it as text needs ->>. And ->> always gives text, even for numbers, which makes comparisons alphabetical:

SELECT id,
       payload ->> 'amount'                    AS amount,
       payload ->> 'amount' > '1000'           AS as_text,
       (payload ->> 'amount')::numeric > 1000  AS as_number
FROM events
WHERE payload ->> 'type' = 'payment'
ORDER BY id;
idamountas_textas_number
212500truetrue
3900truefalse
415000truetrue

As text, '900' > '1000' because 9 sorts after 1. Cast before comparing numbers or dates: (payload ->> 'amount')::numeric. The cast from ->> text also copes with event 4, whose amount arrived as the string "15000"; casting the JSON value itself ((payload -> 'amount')::numeric) works for JSON numbers but fails on strings.

A key that isn't there gives NULL, and jsonb_typeof tells you what's there:

SELECT id,
       payload ->> 'plan'                AS plan,
       jsonb_typeof(payload -> 'amount') AS amount_type
FROM events
ORDER BY id;
idplanamount_type
1teamNULL
2NULLnumber
3NULLnumber
4NULLstring
5NULLnumber

So a condition like payload ->> 'plan' <> 'free' quietly excludes every event without a plan (NULL isn't unequal to anything) - here it matches only event 1.

Filtering: containment and SQL/JSON paths

@> tests whether a document contains another - every key and value on the right appears on the left (for arrays, every element):

SELECT id FROM events
WHERE payload @> '{"type": "payment", "currency": "usd"}'
ORDER BY id;
id
2
4

? tests for a key (or an array element): payload -> 'tags' ? 'card'. For conditions inside arrays and nested values, SQL/JSON path expressions do the work; since Postgres 17 the standard functions JSON_EXISTS, JSON_VALUE and JSON_QUERY accept them:

SELECT id,
       JSON_VALUE(payload, '$.amount' RETURNING numeric) AS amount,
       JSON_EXISTS(payload, '$.items[*] ? (@.qty > 2)')  AS bulk_item
FROM events
WHERE JSON_EXISTS(payload, '$.amount')
ORDER BY id;
idamountbulk_item
212500false
3900false
415000false
52500true

JSON_VALUE(... RETURNING numeric) extracts a scalar and converts it - event 4's string included. $.items[*] ? (@.qty > 2) means "some item with a quantity above 2".

Arrays to rows: JSON_TABLE

JSON_TABLE (Postgres 17+) turns a JSON array into rows with typed columns:

SELECT e.id, i.sku, i.qty
FROM events e,
     JSON_TABLE(e.payload, '$.items[*]'
                COLUMNS (sku text PATH '$.sku',
                         qty int  PATH '$.qty')) AS i
ORDER BY e.id, i.sku;
idskuqty
5MUG-011
5TEA-023

Listing it in FROM joins each event to its own items - and an event with no items (no items key, or an empty array) produces no rows, so it vanishes. To keep every event, use LEFT JOIN JSON_TABLE(...) ON true:

SELECT e.id, i.sku, i.qty
FROM events e
LEFT JOIN JSON_TABLE(e.payload, '$.items[*]'
                     COLUMNS (sku text PATH '$.sku',
                              qty int  PATH '$.qty')) AS i ON true
WHERE e.payload ->> 'type' <> 'payment'
ORDER BY e.id, i.sku;
idskuqty
1NULLNULL
5MUG-011
5TEA-023

A column can supply a value when the key is missing - qty int PATH '$.qty' DEFAULT 1 ON EMPTY - and NESTED PATH flattens arrays inside arrays. Before Postgres 17 the same job was CROSS JOIN LATERAL jsonb_array_elements(payload -> 'items') (or LEFT JOIN LATERAL ... ON true), with the same empty-array behavior.

Building JSON: jsonb_build_object and jsonb_agg

jsonb_build_object makes an object from key/value pairs, and jsonb_agg collects a group's values into an array - so one query can return a customer with their orders nested. Give jsonb_agg an ORDER BY, or the array's order is whatever the plan produced. The classic bug is a customer with no orders:

SELECT c.id,
       jsonb_agg(jsonb_build_object('id', o.id, 'total', o.total) ORDER BY o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id
ORDER BY c.id;
idorders
1[{"id": 10, "total": 42.00}, {"id": 11, "total": 18.50}]
2[{"id": null, "total": null}]

The LEFT JOIN keeps Liam with one all-NULL order row, and jsonb_agg dutifully builds an object from it. (Aggregating o.id alone would give [null].) Skip those rows with FILTER, and turn "no rows at all" - which aggregates to NULL - into an empty array:

SELECT c.id,
       COALESCE(jsonb_agg(jsonb_build_object('id', o.id, 'total', o.total) ORDER BY o.id)
                    FILTER (WHERE o.id IS NOT NULL),
                '[]') AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id
ORDER BY c.id;
idorders
1[{"id": 10, "total": 42.00}, {"id": 11, "total": 18.50}]
2[]

Changing JSON

jsonb_set replaces the value at a path, || merges objects (right side wins), and - removes a key:

UPDATE events
SET payload = (jsonb_set(payload, '{plan}', '"business"')  -- replace a value
               || '{"beta": true}')                        -- add or overwrite keys
              - 'user'                                     -- remove a key
WHERE id = 1;
payload
{"beta": true, "plan": "business", "type": "signup"}

The parentheses matter: - binds more tightly than ||, so without them Postgres tries to subtract 'user' from '{"beta": true}' and fails. Each update rewrites the whole value (and the row), so a large document that changes often is better split into columns or its own table.

Indexing JSON

A GIN index serves containment on any key. The jsonb_path_ops operator class supports only @> (and path matches) and is often smaller and faster for them than the default - not always: the deep dive measures both. On 10,000 events, 2% of them refunds:

CREATE INDEX events_payload_idx ON events USING gin (payload jsonb_path_ops);
EXPLAIN (COSTS OFF)
SELECT id FROM events WHERE payload @> '{"type": "refund"}';
Bitmap Heap Scan on events
  Recheck Cond: (payload @> '{"type": "refund"}'::jsonb)
  ->  Bitmap Index Scan on events_payload_idx
        Index Cond: (payload @> '{"type": "refund"}'::jsonb)

The same index does nothing for payload ->> 'type' = 'refund' - that plan is a Seq Scan - because it's a different operator. For a key you filter by value, an expression index fits: CREATE INDEX ON events ((payload ->> 'type')). Or write the filter as containment.

Column or JSON?

use a column when the value...keep it in jsonb when...
is in most rows, and you filter, join or sort by itit's sparse or varies by row (per-integration settings)
needs a type, NOT NULL, CHECK or a foreign keyyou store an external payload as received
is updated on its ownit's read and written as a whole

It's common to have both: promote the fields you query to columns (a generated column can extract them), and keep the full document beside them. As a decision:

yes

no

yes

no

yes

no

Do you filter, join
or sort by this field?

Column
(or a generated column from the JSON)

Does it need a type,
NOT NULL or a foreign key?

Is it sparse, varying,
or stored as received?

Keep it in jsonb

yes

no

yes

no

yes

no

Do you filter, join
or sort by this field?

Column
(or a generated column from the JSON)

Does it need a type,
NOT NULL or a foreign key?

Is it sparse, varying,
or stored as received?

Keep it in jsonb

Where it breaks

  • Comparing ->> text as numbers or dates - cast first.
  • -> where you meant ->> - you get JSON, quotes included.
  • Missing keys - NULL, which drops rows from = and <> alike.
  • JSON_TABLE or jsonb_array_elements in a plain join - rows with empty arrays disappear; LEFT JOIN ... ON true.
  • jsonb_agg over a LEFT JOIN - a phantom null element; use FILTER and COALESCE(..., '[]'), and ORDER BY inside the aggregate.
  • Expecting a GIN index to serve ->> equality - it serves @>; index the expression instead.

Tradeoffs

  • jsonb vs columns: jsonb adds fields without migrations; columns give types, constraints, statistics and smaller rows. Promote what you query.
  • Building JSON in SQL vs in the app: one query with jsonb_agg saves round trips and avoids N+1 queries; app-side mapping is easier to change and test.
  • GIN jsonb_path_ops vs default jsonb_ops: path_ops serves only @> and path matches (not key-exists ?), and is often - not always - the smaller of the two.

Going further. The deep dive (next tab) covers how jsonb is stored and compressed, why updating one key rewrites the whole document, the planner's blind spot for JSON fields (and the statistics that fix it), GIN index internals and sizes, validating JSON with constraints, and how other databases handle JSON.

Check yourself

  1. Why does WHERE payload ->> 'amount' > '1000' return a payment of 900?
Show answer

->> returns text, so the comparison is alphabetical, and '9' sorts after '1'. Cast: (payload ->> 'amount')::numeric > 1000.

  1. An export flattens doc -> 'items' with JSON_TABLE in the FROM list. What happens to an order whose items array is empty?
Show answer

It produces no rows and disappears from the export. Use LEFT JOIN JSON_TABLE(...) ON true to keep it, with NULL item columns.

  1. A customer with no orders gets "orders": [{"id": null, "total": null}]. What's the fix?
Show answer

COALESCE(jsonb_agg(...) FILTER (WHERE o.id IS NOT NULL), '[]'): the FILTER skips the all-NULL row from the LEFT JOIN, and COALESCE turns the resulting NULL into an empty array.

Key takeaways

  • Use jsonb; -> returns JSON, ->> text, #>> text at a path, and a missing key is NULL.
  • Cast ->> values before comparing numbers or dates - text compares alphabetically.
  • Filter with @> and SQL/JSON paths (JSON_EXISTS, JSON_VALUE in Postgres 17+); flatten arrays with JSON_TABLE, and LEFT JOIN ... ON true to keep rows with empty arrays.
  • Build responses with jsonb_build_object and jsonb_agg(... ORDER BY ...), guarding empty groups with FILTER and COALESCE(..., '[]').
  • A GIN index serves @>; an expression index serves ->> equality. Promote fields you query often to columns.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Operators and functions

You wantWrite
A value as JSONpayload -> 'user'
A value as textpayload ->> 'currency'
Text at a pathpayload #>> '{user,id}'
A number to compare(payload ->> 'amount')::numeric or JSON_VALUE(payload, '$.amount' RETURNING numeric)
Contains these keys and valuespayload @> '{"type": "payment"}'
Has a key / array elementpayload ? 'plan'
A condition inside an arrayJSON_EXISTS(payload, '$.items[*] ? (@.qty > 2)')
Array to rowsJSON_TABLE(payload, '$.items[*]' COLUMNS (...)) - LEFT JOIN ... ON true to keep empty ones
Build an object / arrayjsonb_build_object(...), jsonb_agg(... ORDER BY ...)
Change a documentjsonb_set(doc, '{path}', '"value"'), doc || '{"k": 1}', doc - 'key'
What type is there?jsonb_typeof(payload -> 'amount')

Use jsonb unless you must keep the exact original text (a signed webhook body).

Snippets worth knowing by heart

-- customers with their orders, empty array when there are none
SELECT c.id,
       COALESCE(jsonb_agg(jsonb_build_object('id', o.id, 'total', o.total) ORDER BY o.id)
                  FILTER (WHERE o.id IS NOT NULL), '[]') AS orders
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;

-- flatten items, keeping events without any
SELECT e.id, i.sku, i.qty
FROM events e
LEFT JOIN JSON_TABLE(e.payload, '$.items[*]'
          COLUMNS (sku text PATH '$.sku', qty int PATH '$.qty')) AS i ON true;

-- indexes: containment, or one field by value
CREATE INDEX ON events USING gin (payload jsonb_path_ops);
CREATE INDEX ON events ((payload ->> 'type'));

-- promote a field you query
ALTER TABLE events ADD COLUMN amount numeric GENERATED ALWAYS AS ((payload ->> 'amount')::numeric) STORED;

Column or jsonb?

Use a column when the value...Keep it in jsonb when...
is in most rows, and you filter, join or sort by itit's sparse or varies by row
needs a type, NOT NULL, CHECK or a foreign keyyou store an external payload as received
is updated on its ownit's read and written as a whole

The traps

  • ->> returns text - '900' > '1000' is true. Cast first.
  • -> keeps the quotes - "usd", not usd.
  • A missing key is NULL - payload ->> 'plan' <> 'free' drops every event without a plan.
  • JSON_TABLE / jsonb_array_elements in a plain join lose rows with empty arrays.
  • jsonb_agg over a LEFT JOIN builds a phantom {"id": null}; no ORDER BY means any order.
  • - binds tighter than || - parenthesize chained edits.
  • A GIN index doesn't serve ->> equality - it serves @>; index the expression instead.

Staff-level points, one line each

  • jsonb is a parsed tree with sorted keys and offsets: {"a": 1} is 12 bytes as json, 28 as jsonb.
  • Values past ~2 kB are compressed and moved out of line (TOAST); compression depends on content (143 bytes for 10 kB of repeats, none for random hex).
  • Updating one key rewrites the whole value - de-TOAST, modify, recompress, new row version, WAL. Keep hot counters and statuses in columns.
  • Reading one key de-TOASTs the whole document.
  • No statistics per JSON key: payload ->> 'type' = ? gets a fixed 0.5% guess (150 estimated vs 600). An expression index or CREATE STATISTICS ... ON ((payload ->> 'type')) (14+) fixes it; @> is already estimated well.
  • GIN = one entry per key/value inside each document. jsonb_ops answers @>, ?, ?|, ?&; jsonb_path_ops only @> and path matches, usually smaller.
  • Build GIN after a bulk load: grown by inserts, the same index was 2.5-4× bigger. The pending list (fastupdate) makes searches scan unmerged entries until vacuum.
  • Validate with CHECK (payload ? 'type' AND jsonb_typeof(payload -> 'type') = 'string'), or a generated column that fails the insert on a bad cast.
  • MySQL indexes JSON via generated columns and multi-valued indexes and can update some values in place; MongoDB updates fields in place natively.

Say it in an interview

  • "I'd keep the payload as jsonb but promote type and amount to columns, since we filter and sort by them."
  • "That counter shouldn't live inside the document - every increment rewrites the whole thing."
  • "The planner has no stats on that key; an expression index gives it both an index and statistics."
  • "GIN for ad-hoc containment queries; an expression B-tree for one field on a write-heavy table."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice JSONB and SQL/JSON free

Flatten Order Line Items

MediumProSQL problem

The storefront saves each order's contents as a document in orders.doc:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Customer Documents for the API

HardProSQL problem

The mobile app's customer screen makes one API call, and the API reads one row per customer from a view. Create the view customer_documents with two columns: customer_id, and doc - a JSON document like this:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice JSONB and SQL/JSON free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime