Skip to content
/ Chapter 22 · Lists, Structs, and JSON
Lesson 22.2 · garden_shop
Lesson 22.2

Unnesting Lists in SQL

unnest is list() in reverse: one row per item. Once a list is rows again, you can join, filter, and aggregate it like any other table.

Lists also arrive from outside: an export with a column of tags, an API that sends a list of product ids, a Parquet file with an array column. You can't join a list to a table directly. unnest turns it back into rows first:

Pattern
select unnest(['/', '/blog', '/checkout']) as path

One list in, three rows out, one per item.

The other columns repeat

Put unnest in a SELECT beside ordinary columns and each row becomes as many rows as its list has items, with the ordinary columns copied onto each one. Garden Shop is planning gift bundles, each a list of product ids:

One row per product in each bundle
with bundles(bundle_name, product_ids) as (
  values ('Starter Kit', [3, 10, 12, 15]),
         ('Patio Glow',  [21, 22, 24])
)
select bundle_name,
       unnest(product_ids) as product_id
from bundles

Two bundles become seven rows: four for Starter Kit and three for Patio Glow. That's a table of bundle and product pairs, ready to join to products.

An empty list makes no rows

A row whose list is empty, or NULL, has nothing to unnest, so it disappears, the way an inner join drops a row with no match:

Gift Card vanishes
with bundles(bundle_name, product_ids) as (
  values ('Patio Glow', [21, 22, 24]),
         ('Gift Card',  []::integer[])
)
select bundle_name,
       unnest(product_ids) as product_id
from bundles

Only Patio Glow's three rows come back. To keep every bundle, unnest in the FROM clause and left join to it. Each row joins to its own list's items, and a row with none keeps a single NULL:

Gift Card stays, with a NULL
with bundles(bundle_name, product_ids) as (
  values ('Patio Glow', [21, 22, 24]),
         ('Gift Card',  []::integer[])
)
select b.bundle_name,
       u.product_id
from bundles as b
left join unnest(b.product_ids) as u(product_id) on true
order by b.bundle_name, u.product_id

Lists stored as text

Often the list isn't a list yet but a string like '3, 10, 12, 15'. string_split turns it into a list of text, and a cast turns each piece into a number:

Comma-separated ids into rows
with imported(bundle_name, product_ids) as (
  values ('Starter Kit', '3, 10, 12, 15')
)
select bundle_name,
       unnest(string_split(product_ids, ','))::integer as product_id
from imported

The pieces keep their spaces (' 10'), which DuckDB's cast to an integer ignores. Before comparing the pieces as text, wrap them in trim.

In other databases

PostgreSQL also has unnest, and its string_to_array does what string_split does here. BigQuery writes CROSS JOIN UNNEST(list), Snowflake uses LATERAL FLATTEN, and SQL Server splits text with CROSS APPLY STRING_SPLIT.

Schema · Garden Shop Table · products 9 columns · 24 rows
Table · products

One row per product, with price, cost, and inventory levels.

9 columns · 24 rows
product_id int product_name text category_id int supplier_id int price decimal cost decimal quantity_on_hand int reorder_level int discontinued bool
Your task

The starter lists three planned gift bundles and the product ids in each. Price them: unnest each bundle's product_ids, join the items to products, and return bundle_name, the number of products as items, the sum of their prices as bundle_price, and low_stock, which is true when any product in the bundle has fewer than 10 on hand. Sort by bundle_name.

·

Run your query to inspect the result.

Expected answer
  • Columns: bundle_name, items, bundle_price, low_stock.
  • Rows: 3, one per bundle, by bundle_name.
  • Patio Glow totals 50.5, and both it and Tomato Bed are low on stock.