DuckDB 一招:用 LIST_ZIP 合并平行数组,告别手工配对

学会用 DuckDB 的 LIST_ZIP 函数将平行数组合并为结构体数组,一行代码替代 Python 循环配对,数据转换效率提升 10 倍。

痛点:平行数组配对太繁琐

假设你有一份产品数据,配件名和价格分别存在两个平行数组中:

CREATE TABLE products AS SELECT * FROM (VALUES
    (1, 'Laptop',    ['CPU','RAM','SSD'],    [4000, 2000, 1500]),
    (2, 'Phone',     ['Screen','Battery'],   [3000, 1500]),
    (3, 'Tablet',    ['Screen','CPU','Battery'], [2000, 3000, 1000])
) AS t(id, name, parts, prices);

现在你需要把每个配件和对应的价格配对,生成结构化数据。传统做法是什么?

# Python 传统做法
for part, price in zip(row['parts'], row['prices']):
    print(f"{part}: {price}")

在纯 SQL 中,很多人会尝试 UNNEST 两次然后 JOIN,但平行数组的索引对齐是个大坑——两个数组长度不同,JOIN 会产生笛卡尔积而不是逐行配对。


一招解决:LIST_ZIP

DuckDB 内置的 LIST_ZIP 函数专门解决这个问题:

SELECT id, name, list_zip(parts, prices) AS priced_parts
FROM products;

结果:

┌─────┬─────────┬────────────────────────────────────────────────┐
│ id  │  name   │                  priced_parts                  │
├─────┼─────────┼────────────────────────────────────────────────┤
│ 1   │ Laptop  │ [(CPU, 4000), (RAM, 2000), (SSD, 1500)]        │
│ 2   │ Phone   │ [(Screen, 3000), (Battery, 1500)]              │
│ 3   │ Tablet  │ [(Screen, 2000), (CPU, 3000), (Battery, 1000)] │
└─────┴─────────┴────────────────────────────────────────────────┘

每个元组自动配对,长度不同的数组自动截断。 一行 SQL 搞定。


效果量化

维度传统 UNNEST + JOINLIST_ZIP
代码行数10-15 行(子查询 + JOIN + 过滤)1 行
长度不一处理需要手动处理笛卡尔积陷阱✅ 自动截断
可读性❌ 复杂嵌套✅ 一目了然
执行效率中等(两次 UNNEST + JOIN)✅ 单次扫描

核心收益:15 行 SQL 压缩为 1 行,开发时间从 30 分钟降到 2 分钟。


进阶用法

1. 展开为独立行

配合 UNNEST 将配对结果展开:

SELECT p.id, p.name, z[1] AS part, z[2] AS price
FROM products p,
UNNEST(list_zip(p.parts, p.prices)) AS t(z);
┌─────┬─────────┬──────────┬───────┐
│ id  │  name   │  part    │ price │
├─────┼─────────┼──────────┼───────┤
│ 1   │ Laptop  │ CPU      │  4000 │
│ 1   │ Laptop  │ RAM      │  2000 │
│ 1   │ Laptop  │ SSD      │  1500 │
│ 2   │ Phone   │ Screen   │  3000 │
│ 2   │ Phone   │ Battery  │  1500 │
│ 3   │ Tablet  │ Screen   │  2000 │
│ 3   │ Tablet  │ CPU      │  3000 │
│ 3   │ Tablet  │ Battery  │  1000 │
└─────┴─────────┴──────────┴───────┘

2. 自定义结构体字段名

LIST_TRANSFORM 配合 STRUCT_PACK 生成带字段名的结构体:

SELECT id, name,
    list_transform(
        list_zip(parts, prices),
        lambda x: struct_pack(part => x[1], price => x[2])
    ) AS items
FROM products;
┌─────┬─────────┬─────────────────────────────────────────────────────┐
│ id  │  name   │                      items                          │
├─────┼─────────┼─────────────────────────────────────────────────────┤
│ 1   │ Laptop  │ [{part: CPU, price: 4000}, ...]                     │
│ 2   │ Phone   │ [{part: Screen, price: 3000}, ...]                  │
│ 3   │ Tablet  │ [{part: Screen, price: 2000}, ...]                  │
└─────┴─────────┴─────────────────────────────────────────────────────┘

3. 处理不等长数组

当两个数组长度不一致时,LIST_ZIP 默认截断到较短的数组。如果希望用 NULL 填充,传入 false

-- 默认截断(较短的数组决定长度)
SELECT list_zip(['a','b','c'], [1, 2]) AS truncated;
-- 结果: [(a, 1), (b, 2)]

-- 用 NULL 填充较长数组的多余元素
SELECT list_zip(['a','b','c'], [1, 2], false) AS extended;
-- 结果: [(a, 1), (b, 2), (c, NULL)]

4. Python 中使用

import duckdb

con = duckdb.connect(":memory:")
con.execute("""
    CREATE TABLE products AS SELECT * FROM (VALUES
        (1, 'Laptop',    ['CPU','RAM','SSD'],    [4000, 2000, 1500]),
        (2, 'Phone',     ['Screen','Battery'],   [3000, 1500]),
        (3, 'Tablet',    ['Screen','CPU','Battery'], [2000, 3000, 1000])
    ) AS t(id, name, parts, prices)
""")

# 一行查询搞定配对
df = con.execute("""
    SELECT id, name, list_zip(parts, prices) AS priced_parts
    FROM products
""").df()

print(df['priced_parts'].iloc[0])
# [(CPU, 4000), (RAM, 2000), (SSD, 1500)]

常见陷阱

陷阱 1:不要用 UNNEST 两次再 JOIN

很多人会这样写:

-- ❌ 错误:产生笛卡尔积而非逐行配对
SELECT a.id, unnest_a.val AS part, unnest_b.val AS price
FROM products a,
UNNEST(a.parts) AS unnest_a,
UNNEST(a.prices) AS unnest_b;

这会生成 3×2=6 行(Laptop)和 2×2=4 行(Phone)的笛卡尔积,而不是 3+2=5 行的正确配对。

陷阱 2:结构体字段访问

LIST_ZIP 返回的是无名结构体数组,访问时需要用数字索引 [1][2],不能用 z.part 这种点号语法。如果需要命名字段,配合 LIST_TRANSFORM + STRUCT_PACK 使用。


延伸思考

LIST_ZIP 体现了 DuckDB 对嵌套数据类型的一等公民支持。在数据分析中,平行数组是一个非常常见的数据模式——从 API 返回的 JSON、从传感器采集的时序数据、从推荐系统输出的标签和分数。

使用 LIST_ZIP 的几个典型场景:

  1. 标签-分数配对:推荐系统的物品 ID 列表和对应评分列表
  2. 时序对齐:时间戳数组和测量值数组
  3. 键值对构建:属性名和属性值分别存储时的配对
  4. 数据清洗:将平行数组转换为首尾一致的 JSON 结构

与其他工具对比

能力DuckDB LIST_ZIPPython zip()SparkPandas
SQL 原生⚠️ UDF
不等长处理✅ 可选截断/填充截断到最短需自定义截断到最短
返回结构化✅ 结构体数组元组列表需自定义需自定义
引擎内执行

DuckDB 的优势:在 SQL 中直接完成配对,无需导出数据到 Python 处理。


📖 更多 DuckDB 实战技巧 → duckdblab.org

💡 觉得有用?Subscribe to DuckDB Lab,每周三获取一篇即学即用的 DuckDB 实战快讯!

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

使用 Hugo 构建
主题 StackJimmy 设计

⚠️ 本站为独立社区项目,与 DuckDB 基金会及 DuckDB 官方项目无任何从属、背书或赞助关系。

"DuckDB" 是 DuckDB 基金会的注册商标,本站仅以事实描述方式使用该名称。

本站内容仅供教育与社区推广用途,不构成任何商业服务。