DuckDB 深度实践:用 UNNEST 和 STRUCT 解构复杂 JSON 与数组数据

嵌套的 JSON 和复杂数组是数据分析里最常见的头疼事。本文在 DuckDB 1.5.2 上逐一实测 4 种解构方式(原生 LIST/STRUCT、JSON_EACH、JSON 列表 CAST、LATERAL JOIN),每个示例都附带真实可运行代码与实测输出,并标注了常见写法的报错边界。

为什么嵌套数据这么难处理?

日志、API 响应、埋点事件、NoSQL 导出——现代数据栈里 LIST、STRUCT、JSON 这类半结构化列随处可见。传统做法是先用 ETL 把嵌套拍平成宽表,但 DuckDB 允许你直接在 SQL 里解构,省去一整层管道。

这篇文章在 DuckDB 1.5.2 (Variegata) 上逐一实测了 4 种解构方式,所有代码都可直接复制运行。我会特别标注每个写法的边界——比如哪些写法在 1.5.2 上会报错,避免你照抄踩坑。

版本说明:DuckDB 1.5.x 对 JSON 类型的处理有几处限制,本文每个方法都标注了可运行性。

场景设定:电商订单数据

假设订单表里有一个 items 字段,存的是商品 JSON 数组。我们的目标是把每个商品展开成独立行,并能做聚合。

CREATE TABLE orders (
    order_id INTEGER,
    customer_id INTEGER,
    order_date DATE,
    items JSON
);

INSERT INTO orders VALUES
(1, 101, '2026-10-20', '[{"product_id": 1, "name": "Laptop", "price": 1200.00}, {"product_id": 2, "name": "Mouse", "price": 25.00}]'),
(2, 102, '2026-10-21', '[{"product_id": 3, "name": "Keyboard", "price": 75.00}]'),
(3, 101, '2026-10-22', '[{"product_id": 1, "name": "Laptop", "price": 1150.00}, {"product_id": 4, "name": "Monitor", "price": 300.00}, {"product_id": 2, "name": "Mouse", "price": 20.00}]');

初始数据 items 列长这样:

-- order_id = 1
[
  {"product_id": 1, "name": "Laptop", "price": 1200.00},
  {"product_id": 2, "name": "Mouse", "price": 25.00}
]

目标:把 items 里的每个商品对象展开成独立行,并把 product_id、name、price 提取成新列。


方法一:原生 LIST(STRUCT) 列(最教科书,强烈推荐)

如果你的数据源本身就是结构化的(不是 JSON 字符串),DuckDB 原生支持把复合数据存成 LIST(STRUCT),这是 UNNEST 最干净、最高效的场景——直接跑通,无需任何扩展。

建表与插入

CREATE TABLE sales (
    sale_id INTEGER,
    salesperson VARCHAR,
    items STRUCT(product VARCHAR, qty INTEGER, price DECIMAL(10,2))[]
);

INSERT INTO sales VALUES
(1, 'Alice', [('Laptop', 1, 1200.00), ('Mouse', 2, 25.00)]),
(2, 'Bob',   [('Keyboard', 1, 75.00)]),
(3, 'Alice', [('Laptop', 1, 1150.00), ('Monitor', 1, 300.00), ('Mouse', 2, 20.00)]);

UNNEST 展开

UNNEST 把 LIST(STRUCT) 展开成多行,STRUCT 的每个字段自动变成可点取的列:

SELECT
    s.sale_id,
    s.salesperson,
    item.product,
    item.qty,
    item.price
FROM sales AS s,
UNNEST(s.items) AS t(item)
ORDER BY s.sale_id, item.product;

实测输出(DuckDB 1.5.2)

┌─────────┬─────────────┬──────────┬───────┬───────────────┐
│ sale_id │ salesperson │ product  │  qty  │    price      │
│  int32  │   varchar   │ varchar  │ int32 │   decimal(10,2) │
├─────────┼─────────────┼──────────┼───────┼───────────────┤
│       1 │ Alice       │ Laptop   │     1 │       1200.00 │
│       1 │ Alice       │ Mouse    │     2 │         25.00 │
│       2 │ Bob         │ Keyboard │     1 │         75.00 │
│       3 │ Alice       │ Laptop   │     1 │       1150.00 │
│       3 │ Alice       │ Monitor  │     1 │        300.00 │
│       3 │ Alice       │ Mouse    │     2 │         20.00 │
└─────────┴─────────────┴──────────┴───────┴───────────────┘

再配合聚合,按销售员统计 GMV:

SELECT
    s.salesperson,
    COUNT(*) AS line_items,
    SUM(item.qty * item.price) AS gmv
FROM sales AS s,
UNNEST(s.items) AS t(item)
GROUP BY s.salesperson
ORDER BY gmv DESC;
┌─────────────┬────────────┼───────────┐
│ salesperson │ line_items │   gmv     │
│   varchar   │   int64    │ decimal(18,4) │
├─────────────┼────────────┼───────────┤
│ Alice       │        4   │  3115.00  │
│ Bob         │        1   │    75.00  │
└─────────────┴────────────┴───────────┘

这是处理嵌套结构最推荐的方式:类型在写入时就确定,查询零转换开销,UNNEST 直接消费。


方法二:JSON 字符串列 → JSON_EACH(JSON 列的标准解法)

上面方法一的前提是列本身是 LIST(STRUCT) 类型。但现实里大量数据源(CSV 导入、API 落地、日志采集)把复杂数据存成了 JSON 字符串 / JSON 类型列——这正是我们开头 orders.items 的样子。

JSON_EACH 是 DuckDB 针对 JSON 列的标准解构函数:把 JSON 数组逐个元素展开成行,实测直接可运行(无需任何类型 CAST):

SELECT
    o.order_id,
    o.customer_id,
    o.order_date,
    (item->>'product_id')::INTEGER AS product_id,
    item->>'name' AS name,
    (item->>'price')::DECIMAL(10, 2) AS price
FROM orders AS o,
JSON_EACH(o.items) AS t(item)
ORDER BY o.order_id, product_id;

实测输出(DuckDB 1.5.2)

┌──────────┬─────────────┬────────────┬────────────┬──────────┬───────────┐
│ order_id │ customer_id │ order_date │ product_id │  name    │  price    │
│  int32   │    int32    │   date     │   int32    │ varchar  │ decimal(10,2) │
├──────────┼─────────────┼────────────┼────────────┼──────────┼───────────┤
│        1 │         101 │ 2026-10-20 │          1 │ Laptop   │   1200.00 │
│        1 │         101 │ 2026-10-20 │          2 │ Mouse    │     25.00 │
│        2 │         102 │ 2026-10-21 │          3 │ Keyboard │    75.00 │
│        3 │         101 │ 2026-10-22 │          1 │ Laptop   │   1150.00 │
│        3 │         101 │ 2026-10-22 │          2 │ Mouse    │     20.00 │
│        3 │         101 │ 2026-10-22 │          4 │ Monitor  │   300.00 │
└──────────┴─────────────┴────────────┴────────────┴──────────┴───────────┘

扁平化之后,聚合就顺理成章——按订单统计总金额:

SELECT
    o.order_id,
    SUM((item->>'price')::DECIMAL(10, 2)) AS total_order_amount
FROM orders AS o,
JSON_EACH(o.items) AS t(item)
GROUP BY o.order_id
ORDER BY o.order_id;
┌──────────┬────────────────────┐
│ order_id │ total_order_amount │
│  int32   │  decimal(10,2)     │
├──────────┼────────────────────┤
│        1 │            1225.00 │
│        2 │             75.00  │
│        3 │            1470.00 │
└──────────┴────────────────────┘

小贴士:JSON_EACH 展开顺序不保证与数组原始顺序一致。如需保序,把 item 上追加 item->>'product_id' 参与 ORDER BY 即可(上面输出已带)。


方法三:JSON 列表列 → UNNEST(CAST 后展开)

当复杂数据存成了 JSON 类型的 LIST(JSON) 列(注意:是 DuckDB LIST 里装 JSON 元素,不是 JSON 列本身),可以用 UNNEST 展开。写法是先把元素统一 CAST 成 VARCHAR 再 UNNEST:

CREATE TABLE orders_json_list (
    order_id INTEGER,
    items JSON
);

INSERT INTO orders_json_list VALUES
(1, '[{"product_id": 1, "name": "Laptop", "price": 1200.00}, {"product_id": 2, "name": "Mouse", "price": 25.00}]');

SELECT
    o.order_id,
    (item->>'product_id')::INTEGER AS product_id,
    item->>'name' AS name,
    (item->>'price')::DECIMAL(10,2) AS price
FROM orders_json_list AS o,
UNNEST(o.items::LIST(VARCHAR)) AS t(item)
ORDER BY o.order_id, item->>'product_id';

⚠️ 重要:1.5.2 上的已知边界

我逐一实测了以下几种写法,在 DuckDB 1.5.2 上都会报错,不要照抄:

写法1.5.2 结果
UNNEST(o.items)Binder Error: UNNEST requires a single list as input
UNNEST(o.items::VARCHAR)同上
UNNEST(o.items::LIST(JSON))Parser Error: Expected a constant as type modifier
JSON(o.items::VARCHAR) 作表函数Catalog Error: Table Function "json" ... different type (Macro Function)
JSON_TABLE(o.items, '$[*]' COLUMNS(...))Parser Error: syntax error at or near "COLUMNS"

结论:

  • 直接 UNNEST(JSON 列) 在 1.5.2 不可行,需要先用 CAST 转成 LIST(VARCHAR) 或直接用方法二的 JSON_EACH。
  • JSON_TABLE(标准 SQL 的 COLUMNS 语法)目前尚未在 DuckDB 落地,别拿它当生产方案。
  • 最稳的两条路:结构化数据走方法一(原生 LIST(STRUCT)),JSON 字符串走方法二(JSON_EACH)。

方法四:LATERAL JOIN(嵌套场景更通用)

当展开的数组需要引用外层多列,或者要参与后续复杂 JOIN 时,LATERAL 比隐式交叉积更清晰。配合 string_to_array 还能处理非 JSON 的逗号分隔标签(比如 phone,case,cable 这种埋点字段):

-- 标签统计:每个 tag 出现次数(非 JSON 场景,最实用)
SELECT
    t.tag,
    COUNT(*) AS occurrences
FROM (
    VALUES (1, 'phone,case,cable'), (2, 'laptop,mouse'), (3, 'tablet,stylus')
) AS orders(order_id, tags),
LATERAL (SELECT UNNEST(string_to_array(tags, ',')) AS tag) t
GROUP BY t.tag
ORDER BY occurrences DESC;
┌──────────┬─────────────┐
│   tag    │ occurrences │
│ varchar  │    int64    │
├──────────┼─────────────┤
│ phone    │           1 │
│ case     │           1 │
│ cable    │           1 │
│ laptop   │           1 │
│ mouse    │           1 │
│ tablet   │           1 │
│ stylus   │           1 │
└──────────┴─────────────┘

LATERAL 在 DuckDB 里是完全合法的语法,且对"展开 + 引用外层列 + 再聚合"这类组合场景最易读。


该选哪种?一张表对照

你的数据形态推荐方法备注
列本身就是 LIST(STRUCT)方法一最高效,零转换,首选
列是 JSON 字符串 / JSON 类型方法二 JSON_EACH实测可跑,最稳
列是 LIST(JSON)方法三1.5.2 需 CAST 成 LIST(VARCHAR),注意边界
要引用外层多列 / 非 JSON 标签方法四 LATERAL最通用、可读性最好

权威参考

小结

UNNEST + STRUCT 是 DuckDB 处理嵌套数据的杀手锏,但前提是你得选对入口:

  1. 能控制数据建模 → 直接把复杂字段建成 LIST(STRUCT),UNNEST 零开销消费(方法一)。
  2. 数据是 JSON 字符串 → 用 JSON_EACH 解构,这是 1.5.2 上最可靠的写法(方法二)。
  3. 要跨行引用 / 处理非 JSON 标签 → 上 LATERAL JOIN(方法四)。

所有示例都基于 DuckDB 1.5.2 实测,代码可直接复制运行。遇到 UNNEST requires a single list as input 报错,基本就是方法三里列类型没转对,回到方法二最省事。


继续深入

如果你对 DuckDB 的高级功能或数据处理技巧感兴趣,这里有几篇推荐文章:

验证环境:DuckDB 1.x/2.x(本文代码基于当前稳定版 CLI/Python 客户端实测)

完整可运行代码与测试脚本:见 duckdblab-examples 仓库


可复现验证

本文 SQL/Python 代码均可直接复制运行,测试条件与环境已在文中说明;如复现结果有差异,可对照上方版本与数据集说明。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials