JSONB: Documents in PostgreSQL
100,000 event payloads stored as JSONB. Query inside them, extract nested values, and index them — all without ever normalizing them into columns.
JSONB stores a parsed, binary representation of a JSON document, which makes it genuinely queryable rather than just a text blob to be pulled out whole and parsed in application code. The two navigation operators are the foundation: -> returns a nested value as JSONB, keeping it navigable for further chaining, while ->> returns that same value as text, ready to compare against a plain string. @> tests whether one JSONB value contains another as a subset — the natural operator for "does this document have this field set to this value" — while ? tests only for a key's existence, regardless of its value.\n\nFor genuinely nested structures — an array of line items inside an order, say — jsonb_path_query runs a full SQL/JSON path expression against the document, capable of filtering inside a nested array directly, which plain -> chaining alone cannot express. And because none of this changes how the data is physically stored, a GIN index using jsonb_path_ops is what actually makes @> containment queries fast at scale — the exact same indexing mechanism this certificate's Index Engineering block already proved for full-text search.
Navigation: -> for JSONB, ->> for Text
SELECT count(*) FROM sql_events WHERE payload->>'type' = 'checkout';
-- 25000
SELECT id, payload->'user'->>'email' AS email FROM sql_events WHERE id IN (1,2,3);
id | email
----+-------------------
1 | user1@example.com
2 | user2@example.com
payload->'user' returns the nested user object as JSONB, still chainable; ->>'email' on the end converts the final step to plain text for comparison or display.
? for Key Existence, Independent of Value
SELECT count(*) FROM sql_events WHERE payload ? 'type'; -- 100000
SELECT count(*) FROM sql_events WHERE payload ? 'discount_code'; -- 0
Every event has a type key (regardless of what it's set to); none has a discount_code key at all — ? answers a structural question about the document's shape, not about any value inside it.
jsonb_path_query: Filtering Inside a Nested Array
SELECT id, jsonb_path_query(payload, '$.items[*] ? (@.price > 15)')
FROM sql_events WHERE id <= 20 ORDER BY id;
id | jsonb_path_query
----+-------------------------------
11 | {"sku": "SKU11", "price": 16}
12 | {"sku": "SKU12", "price": 17}
$.items[*] ? (@.price > 15) walks into the items array and returns only the elements whose price field exceeds 15 — a filter inside a nested array, which plain -> chaining has no way to express at all.
@>, Before and After a GIN Index
EXPLAIN SELECT count(*) FROM sql_events WHERE payload @> '{"type":"checkout"}';
Aggregate (cost=3484.61..3484.62 rows=1 width=8)
-> Seq Scan on sql_events (cost=0.00..3424.00 rows=24242 width=0)
CREATE INDEX ON sql_events USING GIN(payload jsonb_path_ops);
EXPLAIN SELECT count(*) FROM sql_events WHERE payload @> '{"type":"checkout"}';
Aggregate (cost=2714.99..2715.00 rows=1 width=8)
-> Bitmap Heap Scan on sql_events (cost=177.36..2654.39 rows=24242 width=0)
-> Bitmap Index Scan on sql_events_payload_idx
The identical query, before and after one index — Seq Scan becomes a genuine Bitmap Heap Scan the moment a GIN index built specifically for @> exists.
-> vs ->>
Both navigate one level into a JSONB value by key or array index. -> returns the result as JSONB, preserving its type and letting further -> or ->> operations chain onto it; ->> returns the result as text, which is what a plain string comparison (= 'checkout') needs. Chaining several -> steps and finishing with a single ->> at the end is the standard pattern for extracting a deeply nested scalar value as text.
jsonb_path_ops
A GIN operator class specifically optimized for the @> containment operator on JSONB, producing a smaller and faster index than the default jsonb_ops class at the cost of not supporting the ?, ?|, and ?& key-existence operators — the identical size-versus-operator-coverage tradeoff already seen for JSONB GIN indexing back in Block 3.2, applied here to a genuinely different dataset and use case.
🧭 Navigate Nested JSONB With -> and ->>
Extract a top-level field as text for comparison, then navigate two levels deep to a nested field.
psql -U postgres -d beer_db -c "SELECT count(*) FROM sql_events WHERE payload->>'type' = 'checkout';"psql -U postgres -d beer_db -c "SELECT id, payload->'user'->>'email' AS email FROM sql_events WHERE id IN (1,2,3);"student@lab:~$ psql -U postgres -d beer_db -c "SELECT count(*) FROM sql_events WHERE payload->>'type' = 'checkout';" SET count ------- 25000 (1 row) student@lab:~$ psql -U postgres -d beer_db -c "SELECT id, payload->'user'->>'email' AS email FROM sql_events WHERE id IN (1,2,3);" SET id | email ----+------------------- 1 | user1@example.com 2 | user2@example.com 3 | user3@example.com (3 rows)
🔑 Test Key Existence With ?, Independent of Value
Check whether a key exists on the document at all, contrasting a key that is always present with one that never appears.
psql -U postgres -d beer_db -c "SELECT count(*) FROM sql_events WHERE payload ? 'type';" -c "SELECT count(*) FROM sql_events WHERE payload ? 'discount_code';"student@lab:~$ psql -U postgres -d beer_db -c "SELECT count(*) FROM sql_events WHERE payload ? 'type';" -c "SELECT count(*) FROM sql_events WHERE payload ? 'discount_code';" SET count -------- 100000 (1 row) count ------- 0 (1 row)
🔍 Filter Inside a Nested Array With jsonb_path_query
Find only the line items inside each event's nested items array whose price exceeds 15.
psql -U postgres -d beer_db -c "SELECT id, jsonb_path_query(payload, '\$.items[*] ? (@.price > 15)') FROM sql_events WHERE id <= 20 ORDER BY id;"student@lab:~$ psql -U postgres -d beer_db -c "SELECT id, jsonb_path_query(payload, '\$.items[*] ? (@.price > 15)') FROM sql_events WHERE id <= 20 ORDER BY id;" SET id | jsonb_path_query ----+------------------------------- 11 | {"sku": "SKU11", "price": 16} 12 | {"sku": "SKU12", "price": 17} 13 | {"sku": "SKU13", "price": 18} 14 | {"sku": "SKU14", "price": 19} 15 | {"sku": "SKU15", "price": 20} 16 | {"sku": "SKU16", "price": 21} 17 | {"sku": "SKU17", "price": 22} 18 | {"sku": "SKU18", "price": 23} 19 | {"sku": "SKU19", "price": 24} 20 | {"sku": "SKU0", "price": 25} (10 rows)
⚡ Index @> Containment With GIN and Confirm the Switch
Run a containment query before and after adding a GIN jsonb_path_ops index, confirming the plan genuinely changes.
psql -U postgres -d beer_db -c "EXPLAIN SELECT count(*) FROM sql_events WHERE payload @> '{\"type\":\"checkout\"}';"psql -U postgres -d beer_db -c "CREATE INDEX ON sql_events USING GIN(payload jsonb_path_ops);"psql -U postgres -d beer_db -c "EXPLAIN SELECT count(*) FROM sql_events WHERE payload @> '{\"type\":\"checkout\"}';"student@lab:~$ psql -U postgres -d beer_db -c "EXPLAIN SELECT count(*) FROM sql_events WHERE payload @> '{\"type\":\"checkout\"}';" SET QUERY PLAN ----------------------------------------------------------------------- Aggregate (cost=3484.61..3484.62 rows=1 width=8) -> Seq Scan on sql_events (cost=0.00..3424.00 rows=24242 width=0) Filter: (payload @> '{"type": "checkout"}'::jsonb) (3 rows) student@lab:~$ psql -U postgres -d beer_db -c "CREATE INDEX ON sql_events USING GIN(payload jsonb_path_ops);" SET CREATE INDEX student@lab:~$ psql -U postgres -d beer_db -c "EXPLAIN SELECT count(*) FROM sql_events WHERE payload @> '{\"type\":\"checkout\"}';" SET QUERY PLAN ------------------------------------------------------------------------------------------------- Aggregate (cost=2714.99..2715.00 rows=1 width=8) -> Bitmap Heap Scan on sql_events (cost=177.36..2654.39 rows=24242 width=0) Recheck Cond: (payload @> '{"type": "checkout"}'::jsonb) -> Bitmap Index Scan on sql_events_payload_idx (cost=0.00..171.30 rows=24242 width=0) Index Cond: (payload @> '{"type": "checkout"}'::jsonb) (5 rows)
Lab 3.3.4 complete. JSONB as a genuinely queryable, genuinely indexable document store:\n\n\n -> / ->> navigation, nested : ✅ 25,000 checkout events, emails extracted\n ? key existence, independent value : ✅ 100,000 vs 0, structural check confirmed\n jsonb_path_query, nested array filter: ✅ only matching items returned\n GIN jsonb_path_ops, @> containment : ✅ Seq Scan -> Bitmap Heap Scan, real\n
Enable JavaScript to run the live terminal and track your progress.