Three orders went into the report. Only two came out. Nothing failed: one order simply had an empty items array. Expanding that array produced no rows, and the order disappeared.

This is the important decision in nested-data work: are you building a table of items, or a table that must still account for every order? We will build both, then count the items correctly.

Explore the idea

Where did Ben’s order go?

Switch between an item report and an order-preserving report.

2 of 3

order IDs remain

An empty array contributes no item rows

Ada produces two item rows and Cy produces one. Ben’s empty list produces none. Three output rows still represent only two orders.

Meet the documents before flattening them

Each input row contains an order ID and one JSON document. Ada ordered two different items, Ben has none, and Cy ordered one. A quantity of two means two units of one item; it does not mean two item rows.

incoming3 rows

Three complete order documents

Every document is shown. Ben has an empty items array; his order still exists.

idpayload
101{"customer":"Ada","items":[{"sku":"MUG","qty":2},{"sku":"TEA","qty":1}]}
102{"customer":"Ben","items":[]}
103{"customer":"Cy","items":[{"sku":"MUG","qty":1}]}

Start with a field and the array length. You can inspect JSON without expanding it:

Read a field before expanding the array

SELECT id, payload ->> '$.customer' AS customer,
       json_array_length(payload, '$.items') AS item_count
FROM incoming ORDER BY id;
Expected result · sample data3 rows
id customer item_count
101 Ada 2
102 Ben 0
103 Cy 1

The ->> operator extracts text. json_array_length tells us the array contains two, zero, and one elements respectively. All three orders still exist at this stage.

The setup uses json_transform to give the nested document a SQL shape: a customer string and a list of item structs, each with sku and qty. That makes order_data.customer and item.sku typed field access rather than repeated JSON path expressions. The complete transformation is in the setup disclosure.

One row per item

An UNNEST in the select list expands each list into rows. For an item-level report, that is exactly what we want:

Expand one order into its items

SELECT id, order_data.customer AS customer,
       unnest(order_data.items).sku AS sku
FROM orders
ORDER BY id, sku;
Expected result · sample data3 rows
id customer sku
101 Ada MUG
101 Ada TEA
103 Cy MUG

Ada contributes two rows; Cy contributes one. Ben contributes zero. There are three output rows, but that happens to equal the number of input orders. Matching row counts do not prove that all order IDs survived. Check the keys and the intended meaning of a row.

DuckDB documents that empty lists and NULL lists unnest to zero rows. Missing arrays therefore need an explicit policy too. Here, the inputs are known, valid order documents; a missing or incorrectly typed items field should be validated before treating it as an ordinary empty cart.

Preserve the parent order

Use a left join to the unnested list when every parent must remain. ON true associates each order with all of its items; the left join supplies a NULL placeholder when there are none.

Keep the order with no items

SELECT o.id, o.order_data.customer AS customer,
       item.sku AS sku, item.qty AS qty
FROM orders o
LEFT JOIN UNNEST(o.order_data.items) AS u(item) ON true
ORDER BY o.id, sku;
Expected result · sample data4 rows
id customer sku qty
101 Ada MUG 2
101 Ada TEA 1
102 Ben NULL NULL
103 Cy MUG 1

Scroll for more columns →

Now there are four rows. Ben is present, with NULL item fields. That placeholder is useful evidence, not an item that someone bought.

This also explains why COUNT(*) is the wrong item count after this join: it counts the placeholder. COUNT(item.sku) counts non-NULL SKU values. Our contract requires every real item to have a SKU; if your real data allows missing SKUs, choose a different reliable item marker.

Your turn · check your answer

Count items, including an empty order

Return id and item_count for every order. Count item rows, not units, and keep orders with zero items.

See the expected result
Your target result3 rows
id item_count
101 2
102 0
103 1
Sample data is loaded for you.

Preparing the editor…

Need a hint?

After a LEFT JOIN, count a non-NULL item field. COUNT(*) also counts the placeholder row for Ben.

Show a solution
SELECT o.id, count(item.sku) AS item_count
FROM orders o
LEFT JOIN UNNEST(o.order_data.items) AS u(item) ON true
GROUP BY o.id ORDER BY o.id;

Keep the shape of the result deliberate

A few questions prevent surprising totals:

  • Does one output row mean an order, an item, or a unit? Summing qty answers a different question from counting item rows.
  • Must orders with empty arrays survive? Use the left-join form when the answer is yes.
  • Are missing arrays allowed? Preserve the original payload so you can distinguish absent, NULL, and explicitly empty values before flattening.

Avoid expanding two independent arrays at once unless you have decided how their elements relate. Two table-function unnests joined together can multiply combinations; side-by-side select-list unnests follow different rules. Test the intended relationship on a tiny document first.

Next, use JSON Schema validation to check the document contract, or explore join multiplication to see how a valid expansion can still break a revenue report.