jsonb_to_recordset — Open JSON array line items into typed rows
A table that keeps its line items in a JSON array cannot be aggregated as it stands. jsonb_to_recordset expands an array into rows after you declare the column names and types, and once the rows are open you can apply SUM and GROUP BY exactly as you would to any other table. Because it reads a column of the current row, it is paired with LATERAL.
SELECT t.id_col, x.* FROM table_name t LEFT JOIN LATERAL jsonb_to_recordset(t.json_col) AS x(key_col text, num_col int) ON TRUE; -- you declare the names and types yourself
CROSS JOIN LATERAL (a comma-separated list means the same thing) the original row itself drops out of the result. To keep it, write LEFT JOIN LATERAL … ON TRUE.From the orders table, calculate the total amount of each order. items is an array of line-item objects, and each element has sku (text), qty (quantity) and unit_price (unit price). The total amount is the sum of qty × unit_price over the line items, and an order with no line items is 0. Return order_id, customer, total_amount, sorted by order_id ascending.
| order_id | customer | items (jsonb) |
|---|---|---|
| 1 | Sato | [{"sku":"A-1","qty":2,"unit_price":1200},{"sku":"B-2","qty":1,"unit_price":3000}] |
| 2 | Suzuki | [{"sku":"A-1","qty":1,"unit_price":1200}] |
| 3 | Tanaka | [{"sku":"C-3","qty":3,"unit_price":800},{"sku":"A-1","qty":2,"unit_price":1200},{"sku":"B-2","qty":1,"unit_price":3000}] |
| 4 | Takahashi | [] |
| order_id | customer | total_amount |
|---|---|---|
| 1 | Sato | 5400 |
| 2 | Suzuki | 1200 |
| 3 | Tanaka | 7800 |
| 4 | Takahashi | 0 |
SELECT o.order_id, o.customer, COALESCE(SUM(i.qty * i.unit_price), 0) AS total_amount -- pull orders with no line items down to 0 FROM orders o LEFT JOIN LATERAL jsonb_to_recordset(o.items) AS i(sku text, qty int, unit_price numeric) ON TRUE -- declare the types, then open into rows GROUP BY o.order_id, o.customer ORDER BY o.order_id; /* Execution order (logical evaluation order): 1. FROM orders → Read 4 rows 2. LEFT JOIN LATERAL jsonb_to_recordset → Expand each order's items into rows (7 rows) 3. GROUP BY o.order_id, o.customer → Fold back into 4 groups, one per order 4. SELECT COALESCE(SUM(...), 0) → Sum the line amounts; no line items means 0 5. ORDER BY o.order_id → order_id ascending */
LEGEND
① FROM orders
FROM ordersRead all 4 rows of orders. The line items are still folded into items as an array, so at this point one order is one row. Order 4 has an empty array in items and no line items at all.| order_id | customer | items (jsonb) |
|---|---|---|
| 1 | Sato | [{"sku":"A-1","qty":2,...},{"sku":"B-2","qty":1,...}] |
| 2 | Suzuki | [{"sku":"A-1","qty":1,...}] |
| 3 | Tanaka | [{"sku":"C-3","qty":3,...},{"sku":"A-1","qty":2,...},{"sku":"B-2","qty":1,...}] |
| 4 | Takahashi | [] |
jsonb_to_recordset does not infer column types from the JSON. The declaration AS i(sku text, qty int, unit_price numeric) is the contract: an element that lacks a value gets NULL in that column, and an element holding something that cannot be read as a number fails at run time. Amounts are received as numeric so that no float rounding error is carried in.LEFT JOIN … ON TRUE keeps an order with no line items as a NULL row, which you can then give meaning to with COALESCE.COUNT returns 0, but SUM / AVG / MAX return NULL when there is nothing to aggregate. If you want to publish an order worth 0, applying COALESCE outside the aggregate is the standard move.FROM orders o, jsonb_to_recordset(o.items) AS i(...) means the same as CROSS JOIN LATERAL, and orders with no line items disappear silently. The only way to notice that the result has fewer rows than the input is to compare the counts.CHECK can help. The moment line items start being treated as "something we aggregate daily" is the signal to split them out into an order_items table. As a first step of the migration, freezing an expansion query like the one in this question as a view lets you move to a real table without rewriting the readers.WITH ORDINALITY — Take the array position out as a number
A JSON array has an order, but the moment it is opened into rows that information is not a column. Adding WITH ORDINALITY appends a 1-based sequence column to the output of a set-returning function, so "which element is this" becomes something WHERE and ORDER BY can work with.
SELECT x.elem, x.pos FROM table_name t CROSS JOIN LATERAL jsonb_array_elements_text(t.json_col) WITH ORDINALITY AS x(elem, pos); -- pos is a 1-based bigint
ORDER BY. If the position carries meaning, always turn it into a column before you sort.In the playlists table, tracks is an array of track names (strings). Retrieve the first three tracks of each playlist together with their track number. The number starts at 1 for the head of the array. Return playlist_id, pos, title, sorted by playlist_id ascending and pos ascending.
| playlist_id | name | tracks (jsonb) |
|---|---|---|
| 1 | morning | ["Aurora","Bloom","Cinder","Drift"] |
| 2 | focus | ["Ember","Frost"] |
| 3 | night | ["Glow","Halo","Iris"] |
| playlist_id | pos | title |
|---|---|---|
| 1 | 1 | Aurora |
| 1 | 2 | Bloom |
| 1 | 3 | Cinder |
| 2 | 1 | Ember |
| 2 | 2 | Frost |
| 3 | 1 | Glow |
| 3 | 2 | Halo |
| 3 | 3 | Iris |
SELECT p.playlist_id, t.pos, t.title FROM playlists p CROSS JOIN LATERAL jsonb_array_elements_text(p.tracks) WITH ORDINALITY AS t(title, pos) -- the track name and its 1-based position WHERE t.pos <= 3 -- keep only the first three tracks ORDER BY p.playlist_id, t.pos; /* Execution order (logical evaluation order): 1. FROM playlists → Read 3 rows 2. CROSS JOIN LATERAL jsonb_array_elements_text → Expand tracks into rows (9 rows) 3. WITH ORDINALITY → Attach a 1-based pos in expansion order 4. WHERE t.pos <= 3 → Narrow to 8 rows 5. SELECT playlist_id, pos, title → Choose 3 columns 6. ORDER BY playlist_id, pos → Playlist order, then track order */
LEGEND
① FROM playlists
FROM playlistsRead all 3 rows of playlists. The tracks are still folded into the tracks array, and the number of tracks differs from playlist to playlist.| playlist_id | name | tracks (jsonb) |
|---|---|---|
| 1 | morning | ["Aurora","Bloom","Cinder","Drift"] |
| 2 | focus | ["Ember","Frost"] |
| 3 | night | ["Glow","Halo","Iris"] |
WITH ORDINALITY sequence, not by the arrangement of the expanded rows. Once it is a column, every ordinary tool applies to it — WHERE, ORDER BY, even PARTITION BY in a window function.LATERAL, pos starts at 1 for each playlist. When you want a number running across the whole result instead, that is the job of ROW_NUMBER() OVER (ORDER BY …).jsonb_array_elements returns the elements as jsonb, so a string arrives quoted as "Aurora". When you know the elements are strings and want to use them as text, choosing jsonb_array_elements_text removes one cast.LIMIT 3 applies to the whole result, so it does not give three tracks per playlist. Top-N per group is expressed with a position column or with ROW_NUMBER.sort_order column, numbered with gaps so that values can be inserted between them (10, 20, 30 …), is easier to live with. A JSON array suits the case where the whole thing is replaced at once, or only ever read.JSONPath — Pull matching elements out of a nested array
The containment operator @> can only ask "does it contain this value" and cannot express a numeric comparison. With SQL/JSON path you can insert a ? ( … ) filter expression in the middle of the path and take out only the elements that match. Inside the filter, @ refers to the element currently being looked at.
SELECT x.elem FROM table_name t CROSS JOIN LATERAL jsonb_path_query(t.json_col, '$.arr[*] ? (@.num_key > 0)') AS x(elem); -- $ = the whole document, .arr[*] = every array element, ? ( … ) = the filter
jsonb_path_query is a set-returning function, so a row with no matching element drops out under CROSS JOIN LATERAL. To keep the row, use LEFT JOIN LATERAL … ON TRUE; if you only want a true/false answer, use the predicate operator @?.In the products table, spec holds a variants array for each product. Each element has color and stock (units in stock). Take out only the variants whose stock is 1 or more. Return product_id, color, stock, sorted by product_id ascending and color ascending. stock must be numeric and color must be text with no quotation marks.
| product_id | spec (jsonb) |
|---|---|
| 1 | {"brand":"Nova","variants":[{"color":"red","stock":0},{"color":"blue","stock":12}]} |
| 2 | {"brand":"Orbit","variants":[{"color":"black","stock":4},{"color":"white","stock":7}]} |
| 3 | {"brand":"Pico","variants":[{"color":"green","stock":0}]} |
| 4 | {"brand":"Quill","variants":[]} |
| product_id | color | stock |
|---|---|---|
| 1 | blue | 12 |
| 2 | black | 4 |
| 2 | white | 7 |
SELECT p.product_id, v.elem ->> 'color' AS color, (v.elem ->> 'stock')::int AS stock -- to a number by way of text FROM products p CROSS JOIN LATERAL jsonb_path_query(p.spec, '$.variants[*] ? (@.stock > 0)') AS v(elem) ORDER BY p.product_id, color; /* Execution order (logical evaluation order): 1. FROM products → Read 4 rows 2. CROSS JOIN LATERAL jsonb_path_query → Walk every element via $.variants[*] 3. ? (@.stock > 0) → Return only elements that have stock (3 rows) 4. SELECT color, stock → Take two keys out of the element 5. ORDER BY product_id, color → Product order, then color name */
LEGEND
① FROM products
FROM productsRead all 4 rows of products. variants is an array of objects, and the number of elements differs per product. Quill has an empty array in variants.| product_id | spec (jsonb) |
|---|---|
| 1 | {"brand":"Nova","variants":[{red,0},{blue,12}]} |
| 2 | {"brand":"Orbit","variants":[{black,4},{white,7}]} |
| 3 | {"brand":"Pico","variants":[{green,0}]} |
| 4 | {"brand":"Quill","variants":[]} |
$.variants[*] ? (@.stock > 0) packs "descend", "spread" and "narrow" into a single path. The result is the same as expanding first and filtering with WHERE, but because the condition stays on the JSON side, a condition spanning several levels can be written far more briefly.@.stock > 0 is a comparison between JSON numbers. If the value is stored as a string such as "12", the comparison is neither true nor false but undefined, and the element is not returned. Any field you intend to search numerically has to be written as a number in the first place.jsonb_path_query; when you only want to know whether a matching element exists, use @?. WHERE spec @? '$.variants[*] ? (@.stock > 0)' does not multiply rows, and a GIN index can serve it.spec @> '{"variants":[{"stock":0}]}' is an equality test meaning "contains an element with stock 0", and cannot express "stock of 1 or more". Treat @> as strictly for equality containment and push comparisons into a path expression.jsonb_path_query(spec, '$.variants[*] ? (@.stock > $min)', jsonb_build_object('min', 0)).?, and many drivers hijack ? as their bind-parameter marker. Under JDBC and some ORMs, the ? inside the path string is read as a parameter and you get an error that makes no sense. There are three ways around it: pass the whole path as a parameter, wrap it in a cast such as jsonb_path_query(spec, CAST(:path AS jsonpath)), or follow the driver's escaping rule (?? and the like). Which one applies is decided by your connection layer, so before you start using JSONPath in earnest, the quickest move is to get one short path through end to end.jsonb_object_agg — Fold attribute rows into a JSON object
A key-value table holding one attribute per row lets you add fields without adding columns, but readers want one row per entity. jsonb_object_agg folds the rows of a group into a JSON object as key-value pairs. When the same key appears more than once the value that arrives later survives, so you write an ORDER BY inside the aggregate to decide what "later" means.
SELECT id_col, jsonb_object_agg(key_col, val_col ORDER BY ts_col) AS obj FROM table_name GROUP BY id_col; -- last wins, so ascending order leaves the newest
null, but a single row with a NULL key fails the whole statement at run time. If the key column has no NOT NULL, exclude such rows before the aggregate.The device_attrs table holds device attributes, one attribute per row. For each device, fold them into a JSON object whose keys are attr_key and whose values are attr_value. When the same attribute has been recorded more than once, adopt the value with the newest recorded_at. Return device_id, attrs, sorted by device_id ascending.
| device_id | attr_key | attr_value | recorded_at |
|---|---|---|---|
| d-01 | os | iOS 16 | 2026-01-10 |
| d-01 | carrier | NTT | 2026-01-10 |
| d-01 | os | iOS 17 | 2026-03-01 |
| d-02 | os | Android 14 | 2026-02-05 |
| d-02 | storage | NULL | 2026-02-05 |
| d-03 | os | iOS 17 | 2026-03-02 |
| device_id | attrs |
|---|---|
| d-01 | {"os": "iOS 17", "carrier": "NTT"} |
| d-02 | {"os": "Android 14", "storage": null} |
| d-03 | {"os": "iOS 17"} |
SELECT device_id, jsonb_object_agg(attr_key, attr_value ORDER BY recorded_at) AS attrs -- last wins, so the newest survives FROM device_attrs GROUP BY device_id ORDER BY device_id; /* Execution order (logical evaluation order): 1. FROM device_attrs → Read 6 rows 2. GROUP BY device_id → Split into 3 groups 3. ORDER BY recorded_at (in the aggregate) → Fix the apply order inside a group, oldest first 4. SELECT jsonb_object_agg(...) → Fold the key-value pairs into one JSON 5. ORDER BY device_id → device_id ascending */
LEGEND
① FROM device_attrs
FROM device_attrsRead all 6 rows of device_attrs. One row is one attribute, and only d-01 has os recorded twice. The storage value of d-02 is NULL.| device_id | attr_key | attr_value | recorded_at |
|---|---|---|---|
| d-01 | os | iOS 16 | 2026-01-10 |
| d-01 | carrier | NTT | 2026-01-10 |
| d-01 | os | iOS 17 | 2026-03-01 |
| d-02 | os | Android 14 | 2026-02-05 |
| d-02 | storage | NULL | 2026-02-05 |
| d-03 | os | iOS 17 | 2026-03-02 |
jsonb_object_agg(k, v ORDER BY ts) expresses "take the newest" and removes the need for a subquery that first narrows down to the latest row.jsonb normalizes keys into "by length, then by bytes" for storage. Neither the order they were stacked in nor the arrangement of the source table survives. If the key order has to mean something, hold the data in an array (jsonb_agg) or choose the json type.{"storage": null} says "storage was recorded and the value is empty". A missing key says "not recorded yet". On the reading side, keep attrs ? 'storage' (does the key exist) and attrs ->> 'storage' IS NULL (is the value empty) deliberately apart.Rebuilding an array — Keep some elements and restore the order
There is no operator that removes matching elements from a JSON array. The standard move is to expand, filter and rebuild. Rebuilding has two things to watch: the order has to be given by an ORDER BY inside the aggregate, and for a group where no element survives jsonb_agg returns NULL.
SELECT id_col, COALESCE(jsonb_agg(x.elem ORDER BY x.pos) FILTER (WHERE condition), '[]'::jsonb) AS arr FROM table_name t LEFT JOIN LATERAL jsonb_array_elements(t.json_col) WITH ORDINALITY AS x(elem, pos) ON TRUE GROUP BY id_col;
WHERE and a group where no element survives disappears as a group. To keep the row and return an empty array, put the filter in a FILTER clause attached to the aggregate instead.In the carts table, items is an array of line-item objects and each element has sku and qty. Build a cart with the line items whose qty is 0 removed. Preserve the original order, and return the empty array [] for a cart where no line item survives. Return cart_id, items, sorted by cart_id ascending.
| cart_id | items (jsonb) |
|---|---|
| 1 | [{"sku":"A","qty":2},{"sku":"B","qty":0},{"sku":"C","qty":1}] |
| 2 | [{"sku":"D","qty":0}] |
| 3 | [] |
| 4 | [{"sku":"E","qty":5}] |
| cart_id | items |
|---|---|
| 1 | [{"qty": 2, "sku": "A"}, {"qty": 1, "sku": "C"}] |
| 2 | [] |
| 3 | [] |
| 4 | [{"qty": 5, "sku": "E"}] |
SELECT c.cart_id, COALESCE( jsonb_agg(e.elem ORDER BY e.pos) FILTER (WHERE (e.elem ->> 'qty')::int > 0), '[]'::jsonb -- a wiped-out cart becomes an empty array ) AS items FROM carts c LEFT JOIN LATERAL jsonb_array_elements(c.items) WITH ORDINALITY AS e(elem, pos) ON TRUE -- keep the original position in pos GROUP BY c.cart_id ORDER BY c.cart_id; /* Execution order (logical evaluation order): 1. FROM carts → Read 4 rows 2. LEFT JOIN LATERAL … WITH ORDINALITY → Expand line items into rows with a position (6 rows) 3. GROUP BY c.cart_id → Fold back into 4 groups, one per cart 4. FILTER (WHERE qty > 0) → Drop qty 0 line items from what is aggregated 5. SELECT jsonb_agg(... ORDER BY pos) → Rebuild the array in the original order 6. COALESCE(..., '[]') → Turn a cart with nothing left into an empty array 7. ORDER BY c.cart_id → cart_id ascending */
LEGEND
① FROM carts
FROM cartsRead all 4 rows of carts. Cart 1 has 3 line items, carts 2 and 4 have 1 each, and cart 3 holds an empty array. The line items with qty 0 are in cart 1 and cart 2.| cart_id | items (jsonb) |
|---|---|
| 1 | [{"sku":"A","qty":2},{"sku":"B","qty":0},{"sku":"C","qty":1}] |
| 2 | [{"sku":"D","qty":0}] |
| 3 | [] |
| 4 | [{"sku":"E","qty":5}] |
WHERE discards rows before grouping, so a group that loses everything vanishes from the result. FILTER selects the input of one aggregate function, so the group survives and can express the state "nothing to aggregate". The same shape also works when you want several conditional aggregates side by side in one query.WITH ORDINALITY during expansion makes ORDER BY e.pos in the rebuild mean exactly "the original arrangement". Rebuild without keeping the position and the element order is left to the execution plan.[] is "an array with zero elements" and NULL is "no array at all". If the application code reads length assuming an array, finishing the job in SQL with COALESCE(…, '[]'::jsonb) removes one branch.WHERE (e.elem ->> 'qty')::int > 0, cart 2, whose line items were all removed, and cart 3, which was empty to begin with, disappear from the result. Four input rows produce two output rows, and the caller cannot tell whether a missing cart does not exist or is simply empty.replace(items::text, …) breaks the instant a value contains a delimiter. The only ways to edit JSON while keeping the structure intact are expansion plus re-aggregation, or the jsonb_set family.UPDATE touches a single byte, Postgres creates one new row version, so the update cost is proportional to the size of the array. Once line items pass a few hundred and updates start arriving one at a time, that is the sign you have reached the limit of holding them in an array. Lost updates need care too. A single statement that references the column, such as UPDATE … SET items = jsonb_set(items, …), makes a later update wait for the row lock and then apply to the updated row, so concurrent writes to different elements both survive. What does get lost is the read-modify-write shape: the application reads an old document, rebuilds it in memory and writes the whole thing back, and the change that landed first is overwritten, last writer wins. If the read and the write have to be separate, put a SELECT … FOR UPDATE or a version comparison between them. As soon as element-level updates enter the requirements, consider splitting the line items into their own table.