SQL JSON — Applied Recordsets, Path Queries, Rebuilding

ADVJSONjsonb_to_recordsetWITH ORDINALITYJSONPathjsonb_object_aggRebuilding arraysPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

jsonb_to_recordset — Open JSON array line items into typed rows

jsonb_to_recordsetLATERALNo line items
Background

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
Rows that cannot be opened disappear: for a row whose array is empty the function returns zero rows, so with 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.
Problem

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.

Tables used
▸ orders
order_idcustomeritems (jsonb)
1Sato[{"sku":"A-1","qty":2,"unit_price":1200},{"sku":"B-2","qty":1,"unit_price":3000}]
2Suzuki[{"sku":"A-1","qty":1,"unit_price":1200}]
3Tanaka[{"sku":"C-3","qty":3,"unit_price":800},{"sku":"A-1","qty":2,"unit_price":1200},{"sku":"B-2","qty":1,"unit_price":3000}]
4Takahashi[]
Expected Output
order_idcustomertotal_amount
1Sato5400
2Suzuki1200
3Tanaka7800
4Takahashi0
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT o.order_id, o.customer, COALESCE(SUM(i.qty * i.unit_price), 0) AS total_amount FROM orders o LEFT JOIN LATERAL jsonb_to_recordset(o.items) AS i(sku text, qty int, unit_price numeric) ON TRUE GROUP BY o.order_id, o.customer ORDER BY o.order_id;
LEGEND
Rows read / loaded
① 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.
1 / 6
order_idcustomeritems (jsonb)
1Sato[{"sku":"A-1","qty":2,...},{"sku":"B-2","qty":1,...}]
2Suzuki[{"sku":"A-1","qty":1,...}]
3Tanaka[{"sku":"C-3","qty":3,...},{"sku":"A-1","qty":2,...},{"sku":"B-2","qty":1,...}]
4Takahashi[]
All 4 rows read
Key points
The types are declared by the reader: 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 LATERAL protects the rows: a set-returning function changes the result in a peculiar way when it returns zero rows — the driving row vanishes. Writing LEFT JOIN … ON TRUE keeps an order with no line items as a NULL row, which you can then give meaning to with COALESCE.
SUM over an empty set is NULL: 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.
Anti-patterns
Listing the function after a comma: 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.
Looping over the array in the application: fetching every row and adding things up in a loop makes both the transfer volume and the round trips proportional to the row count. Push the expansion and the aggregation into SQL and only one row per order comes back.
From the field: fold line items into JSON, or split them into a table?
While you want to store an order API's response as it arrived, or the line-item fields differ from partner to partner, keeping line items in a JSON array has the advantage. In exchange, every aggregate, join and constraint at line-item granularity pays the cost of expansion, and neither foreign keys nor 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.
QUESTION 2

WITH ORDINALITY — Take the array position out as a number

WITH ORDINALITYjsonb_array_elements_textGuaranteed order
Background

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
Never rely on the order rows come out in: an expansion function does return rows in array order, but once a join or an aggregate sits above it, nothing guarantees the arrangement unless you write ORDER BY. If the position carries meaning, always turn it into a column before you sort.
Problem

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.

Tables used
▸ playlists
playlist_idnametracks (jsonb)
1morning["Aurora","Bloom","Cinder","Drift"]
2focus["Ember","Frost"]
3night["Glow","Halo","Iris"]
Expected Output
playlist_idpostitle
11Aurora
12Bloom
13Cinder
21Ember
22Frost
31Glow
32Halo
33Iris
Model Answer
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
  */
Explanation (table transitions & key points)
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) WHERE t.pos <= 3 ORDER BY p.playlist_id, t.pos;
LEGEND
Rows read / loaded
① 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.
1 / 5
playlist_idnametracks (jsonb)
1morning["Aurora","Bloom","Cinder","Drift"]
2focus["Ember","Frost"]
3night["Glow","Halo","Iris"]
All 3 rows read
Key points
Turn the position into a column before you use it: "the head of the array" and "the Nth element" are expressed by the 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.
The sequence restarts on every call: because the function is called once per row inside 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 …).
The _text variant and the plain one: 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.
Anti-patterns
Taking "the first three tracks" with LIMIT: 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.
Omitting ORDER BY and trusting the expansion order: in a simple query the rows do appear to come back in array order, but that is a consequence of the plan, not a guarantee. The order changes the instant a parallel scan or a merge join is chosen, and the bug will not reproduce in tests.
From the field: when ordered data lives in a JSON array
Playlists, workflow steps, form questions — data whose order is itself information fits a JSON array well, but updates get heavy. Swapping just the third and fourth tracks means writing the entire array back, and any update that lands between the read and the write back is lost, last writer wins. If reordering is frequent, a line-item table with a 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.
QUESTION 3

JSONPath — Pull matching elements out of a nested array

jsonb_path_queryJSONPathFilter expression
Background

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
A row with no match disappears: 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 @?.
Problem

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.

Tables used
▸ products
product_idspec (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":[]}
Expected Output
product_idcolorstock
1blue12
2black4
2white7
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT p.product_id, v.elem ->> 'color' AS color, (v.elem ->> 'stock')::int AS stock FROM products p CROSS JOIN LATERAL jsonb_path_query(p.spec, '$.variants[*] ? (@.stock > 0)') AS v(elem) ORDER BY p.product_id, color;
LEGEND
Rows read / loaded
① 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.
1 / 5
product_idspec (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":[]}
All 4 rows read
Key points
Path and predicate in one string: $.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.
Types matter inside a filter expression: @.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.
Fetching versus testing: when you want the elements themselves, use 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.
Anti-patterns
Trying to write a range with the containment operator: 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.
Building the path string in the application: concatenating user input into a path expression is the JSONPath flavour of injection. Pass values as variables instead: jsonb_path_query(spec, '$.variants[*] ? (@.stock > $min)', jsonb_build_object('min', 0)).
From the field: when driver placeholders collide with ?
A JSONPath filter starts with ?, 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.
QUESTION 4

jsonb_object_agg — Fold attribute rows into a JSON object

jsonb_object_aggORDER BY in aggregateLast key wins
Background

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
A key can never be NULL: a row whose value is NULL becomes a JSON 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.
Problem

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.

Tables used
▸ device_attrs
device_idattr_keyattr_valuerecorded_at
d-01osiOS 162026-01-10
d-01carrierNTT2026-01-10
d-01osiOS 172026-03-01
d-02osAndroid 142026-02-05
d-02storageNULL2026-02-05
d-03osiOS 172026-03-02
Expected Output
device_idattrs
d-01{"os": "iOS 17", "carrier": "NTT"}
d-02{"os": "Android 14", "storage": null}
d-03{"os": "iOS 17"}
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT device_id, jsonb_object_agg(attr_key, attr_value ORDER BY recorded_at) AS attrs FROM device_attrs GROUP BY device_id ORDER BY device_id;
LEGEND
Rows read / loaded
① 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.
1 / 5
device_idattr_keyattr_valuerecorded_at
d-01osiOS 162026-01-10
d-01carrierNTT2026-01-10
d-01osiOS 172026-03-01
d-02osAndroid 142026-02-05
d-02storageNULL2026-02-05
d-03osiOS 172026-03-02
All 6 rows read
Key points
The ORDER BY inside the aggregate decides the winner: as long as a duplicate key means last-wins, the result is undefined until you decide which row comes last. The single phrase 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.
Key order is not preserved: 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.
A NULL value and a missing row are different things: {"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.
Anti-patterns
Omitting the ORDER BY inside the aggregate: even without a sort, a small table often leaves the newest value simply because the rows are read in insertion order. But read order is not guaranteed, so the moment the row count grows or the plan switches to an index scan, an old value is adopted instead. This is the kind of regression tests never catch.
Putting every attribute in the key-value table: once fields that always exist and are always searched are stored as rows too, fetching a single entity turns into a row of self-joins. Without drawing a line — only the leaf attributes vary — a rebuilding query like this one ends up running all the time.
From the field: key-value rows or a JSON column?
A key-value (EAV) design lets you add an attribute by inserting data alone, and it keeps the history of when and by whom each value was written as rows. In exchange, assembling one entity requires an aggregate every time. A JSON column reads in a single row, but a partial update means writing the whole document back, and there is no per-value history. In practice the roles are split — attributes that need history and auditing live as rows, attributes that are only read together for display live in a JSON column — and an aggregate like this one is frozen into a materialized view to get both. The lower the update rate and the higher the read rate, the more that freezing pays.
QUESTION 5

Rebuilding an array — Keep some elements and restore the order

jsonb_aggFILTEREmpty array
Background

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;
Filtering in WHERE removes the whole row: write the condition in 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.
Problem

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.

Tables used
▸ carts
cart_iditems (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}]
Expected Output
cart_iditems
1[{"qty": 2, "sku": "A"}, {"qty": 1, "sku": "C"}]
2[]
3[]
4[{"qty": 5, "sku": "E"}]
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT c.cart_id, COALESCE( jsonb_agg(e.elem ORDER BY e.pos) FILTER (WHERE (e.elem ->> 'qty')::int > 0), '[]'::jsonb ) AS items FROM carts c LEFT JOIN LATERAL jsonb_array_elements(c.items) WITH ORDINALITY AS e(elem, pos) ON TRUE GROUP BY c.cart_id ORDER BY c.cart_id;
LEGEND
Rows read / loaded
① 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.
1 / 6
cart_iditems (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}]
All 4 rows read
Key points
FILTER chooses what to aggregate while keeping the rows: 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.
State the order with pos: keeping the position with 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.
Do not mix an empty array with NULL: to the receiver, [] 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.
Anti-patterns
Writing the condition in WHERE: with 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.
Trimming elements with string operations: text processing such as 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.
From the field: why updating "just one element" of an array is expensive
Rewriting one element of a JSON array means reading the whole row, rebuilding the array and writing the whole row back. Even if the 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.