DuckDB + Polars 混合实战:从 CSV 到数据湖的 5 个生产级 Pipeline(附 Arrow 交换与避坑)

DuckDB + Polars 混合实战:从 CSV 到数据湖的 5 个生产级 Pipeline(附 Arrow 交换与避坑)

摘要 :Polars 和 DuckDB 不是二选一,而是"同一份 Parquet 上的两种计算引擎":DuckDB 擅长用 SQL 做即席探查、多表 JOIN、窗口聚合;Polars 擅长用表达式做清洗、特征工程、超内存流式 ETL。本文给出 5 个可直接落地的生产级 Pipeline:CSV 落湖、DuckDB 宽表聚合 → Polars 特征工程、Polars 流式 ETL、按日期分区增量更新、数据质量门禁。所有示例按 Polars 1.44.x + DuckDB 1.5.x 稳定口径编写,并说明 Arrow 交换边界与常见 OOM 陷阱。

关键词:DuckDB、Polars、Apache Arrow、Parquet、数据湖、ETL、Python 大数据、流式处理、查询下推、零拷贝


文章目录

  • [DuckDB + Polars 混合实战:从 CSV 到数据湖的 5 个生产级 Pipeline(附 Arrow 交换与避坑)](#DuckDB + Polars 混合实战:从 CSV 到数据湖的 5 个生产级 Pipeline(附 Arrow 交换与避坑))
    • 一、先建立心智模型:谁干什么
    • [二、Pipeline 1:CSV 原始区 → Parquet 数据湖(DuckDB 落盘)](#二、Pipeline 1:CSV 原始区 → Parquet 数据湖(DuckDB 落盘))
      • [2.1 场景](#2.1 场景)
      • [2.2 代码](#2.2 代码)
      • [2.3 为什么用 DuckDB 做这步](#2.3 为什么用 DuckDB 做这步)
      • [2.4 勘正说明](#2.4 勘正说明)
      • [2.5 避坑](#2.5 避坑)
    • [三、Pipeline 2:DuckDB 做宽表聚合,Polars 接手特征工程](#三、Pipeline 2:DuckDB 做宽表聚合,Polars 接手特征工程)
      • [3.1 场景](#3.1 场景)
      • [3.2 第一步:DuckDB 出聚合宽表](#3.2 第一步:DuckDB 出聚合宽表)
      • [3.3 第二步:Polars 做用户级特征](#3.3 第二步:Polars 做用户级特征)
      • [3.4 勘正说明](#3.4 勘正说明)
      • [3.5 为什么混合](#3.5 为什么混合)
    • [四、Pipeline 3:Polars 流式 ETL,超内存也不 OOM](#四、Pipeline 3:Polars 流式 ETL,超内存也不 OOM)
      • [4.1 场景](#4.1 场景)
      • [4.2 正确写法:Lazy + sink](#4.2 正确写法:Lazy + sink)
      • [4.3 什么时候会退化到内存引擎](#4.3 什么时候会退化到内存引擎)
      • [4.4 避坑](#4.4 避坑)
    • [五、Pipeline 4:按日期分区的数据湖增量更新](#五、Pipeline 4:按日期分区的数据湖增量更新)
      • [5.1 目录约定](#5.1 目录约定)
      • [5.2 DuckDB:只处理当天分区](#5.2 DuckDB:只处理当天分区)
      • [5.3 Polars:读多个分区,但只算需要的部分](#5.3 Polars:读多个分区,但只算需要的部分)
      • [5.4 生产要点](#5.4 生产要点)
    • [六、Pipeline 5:数据质量门禁(DuckDB 校验 + Polars 修复)](#六、Pipeline 5:数据质量门禁(DuckDB 校验 + Polars 修复))
      • [6.1 第一步:DuckDB 跑校验 SQL](#6.1 第一步:DuckDB 跑校验 SQL)
      • [6.2 第二步:Polars 做可解释的修复](#6.2 第二步:Polars 做可解释的修复)
    • [七、Arrow 交换:什么时候真的"零拷贝"](#七、Arrow 交换:什么时候真的"零拷贝")
      • [7.1 准确说法](#7.1 准确说法)
      • [7.2 推荐写法](#7.2 推荐写法)
    • 八、生产部署速查清单
    • 九、版本口径与参考资料
    • 十、总结

一、先建立心智模型:谁干什么

阶段 推荐工具 原因
原始 CSV / JSON 落地 DuckDB COPY ... TO parquet 一行 SQL 完成读、清洗、落盘,不进 Python 内存
多表 JOIN / 报表聚合 DuckDB SQL 优化器强、窗口函数/日期函数丰富、分析师可直接改
复杂列表达式、特征构造 Polars 表达式系统比 SQL 字符串好维护,可单元测试
超内存 ETL Polars Lazy + sink_parquet 流式执行,结果不整体物化到 RAM
给 Sklearn / Plotly Pandas(最后一段) 生态对接,不在中间层用

核心原则:DuckDB 管"查出来的结果对不对",Polars 管"数据怎么被改出来"。

DuckDB 可以直接查询当前 Python 作用域里的 Polars DataFrame,也可以把查询结果用 .pl() 转回 Polars;两边通过 Apache Arrow 做高效互操作。前提:pyarrow 必须安装 ,否则 .pl() 会退化为逐行转换。


二、Pipeline 1:CSV 原始区 → Parquet 数据湖(DuckDB 落盘)

2.1 场景

把 landing/orders_*.csv 转成 lake/orders/dt=2026-10-20/orders.parquet,顺手做类型固定和脏数据过滤。

2.2 代码

python 复制代码
# python
import duckdb
from datetime import date

con = duckdb.connect()  # 进程内,不落库文件

con.execute("""
    COPY (
        SELECT
            CAST(order_id AS BIGINT)          AS order_id,
            TRIM(customer_id)                 AS customer_id,
            CAST(amount AS DECIMAL(12,2))      AS amount,
            TRY_CAST(ordered_at AS TIMESTAMP) AS ordered_at,
            LOWER(status)                     AS status
        FROM read_csv_auto(
            'landing/orders_*.csv',
            header=true,
            sample_size=20000
        )
        WHERE ordered_at IS NOT NULL
          AND amount > 0
    )
    TO 'lake/orders/dt=2026-10-20/orders.parquet'
    (
        FORMAT PARQUET,
        COMPRESSION ZSTD,
        PARTITION_BY ('dt'),
        OVERWRITE TRUE
    );
""")

2.3 为什么用 DuckDB 做这步

  • read_csv_auto 支持 glob 通配符
  • TRY_CAST 转换失败返回 NULL,比 Pandas 静默转 object 更安全
  • COPY ... TO parquet 不把全量结果先变成 Python 对象
  • 生产里"CSV → Parquet"这种批量转换,不需要 Polars 出场

2.4 勘正说明

上一版错误 修正
PARTITION_BY 传变量 修正:PARTITION_BY 只接受字面量列名(字符串),分区值由输出路径中的 dt=... 体现
params=[run_day] 多余 移除:路径已硬编码日期,无需参数绑定
未说明 pyarrow 依赖 补充:DuckDB ↔ Polars 互操作需要 pip install pyarrow

2.5 避坑

  • 别用 pd.read_csv().to_parquet() 处理大文件:全量进 RAM,且类型容易漂
  • CSV 只适合做数据交接格式,不适合当分析主存储

三、Pipeline 2:DuckDB 做宽表聚合,Polars 接手特征工程

3.1 场景

运营需要"每个用户每天的总金额、订单数、客单价、最近一次购买距今天数",后面还要做 RFM 特征。

3.2 第一步:DuckDB 出聚合宽表

python 复制代码
# python
import duckdb
import polars as pl

con = duckdb.connect()

daily_user = con.sql("""
    SELECT
        customer_id,
        CAST(ordered_at AS DATE) AS order_day,
        COUNT(*)                 AS orders,
        SUM(amount)              AS gmv,
        AVG(amount)              AS avg_order_value,
        MAX(ordered_at)          AS last_order_at
    FROM 'lake/orders/dt=*/orders.parquet'
    GROUP BY 1, 2
""").pl()   # 直接拿 Polars DataFrame,Arrow 层交换

.pl() 会把 DuckDB 结果转成 Polars DataFrame。在做完过滤、聚合、JOIN 之后再转,避免把原始大表先搬进 Python。

3.3 第二步:Polars 做用户级特征

python 复制代码
# python
user_features = (
    daily_user
    .sort(["customer_id", "order_day"])
    .group_by("customer_id")
    .agg(
        pl.col("orders").sum().alias("total_orders"),
        pl.col("gmv").sum().alias("total_gmv"),
        pl.col("avg_order_value").mean().round(2).alias("avg_aov"),
        pl.col("order_day").n_unique().alias("active_days"),
        (pl.lit(date.today()).sub(pl.col("last_order_at").cast(pl.Date)))
        .alias("days_since_last_order"),
    )
    .with_columns(
        pl.when(pl.col("total_gmv") >= 10000)
          .then("high")
          .when(pl.col("total_gmv") >= 1000)
          .then("mid")
          .otherwise("low")
          .alias("value_tier")
    )
)

user_features.sink_parquet("lake/user_features/uf.parquet")

3.4 勘正说明

上一版错误 修正
dt.offset_by("1d").sub(...) 链式错误 修正:用 pl.lit(date.today()).sub(col.cast(pl.Date))
max().cast(pl.Date) 嵌套在 offset 里 修正:先算 max,再算日期差
days_since_last_order 逻辑不清晰 修正:今天 - 最后下单日 = 距今天数

3.5 为什么混合

  • DuckDB 写 GROUP BY 快、SQL 好审、分析师能直接改
  • Polars 的 when/then/otherwise、窗口、滚动统计比拼 SQL 字符串好测
  • 结果再给 Sklearn / LightGBM,不必全程 Pandas

四、Pipeline 3:Polars 流式 ETL,超内存也不 OOM

4.1 场景

lake/events/*.parquet 有 200 GB,机器只有 32 GB 内存,要过滤、打标签、写新 Parquet。

4.2 正确写法:Lazy + sink

python 复制代码
# python
import polars as pl

(
    pl.scan_parquet("lake/events/*.parquet")
      .filter(pl.col("event_type").is_in(["impression", "click", "purchase"]))
      .filter(pl.col("user_id").is_not_null())
      .with_columns(
          (pl.col("ts").dt.truncate("1h")).alias("hour"),
          pl.when(pl.col("event_type") == "purchase")
            .then(1).otherwise(0).alias("is_conv"),
      )
      .group_by(["user_id", "hour"])
      .agg(
          pl.col("event_type").count().alias("events"),
          pl.col("is_conv").sum().alias("conversions"),
          pl.col("price").sum().alias("revenue"),
      )
      .sink_parquet("lake/event_hourly/eh.parquet")
)

sink_parquet 让 Polars 走流式执行:数据以 morsel 方式流动,最终结果不整体物化到 Python 内存。

4.3 什么时候会退化到内存引擎

  • pivot / 复杂 over() 未排序窗口 / map_elements 多的时候
  • 退化不代表报错,但内存优势消失
  • 用 lf.explain(engine="streaming") 查看哪些节点不是 streaming

4.4 避坑

python 复制代码
# ❌ 大结果先 collect 再写 → OOM
df = pl.scan_parquet("lake/events/*.parquet").collect()
df.write_parquet("out.parquet")

# ✅ 流式写出
lf.sink_parquet("out.parquet")

五、Pipeline 4:按日期分区的数据湖增量更新

5.1 目录约定

text 复制代码
lake/orders/
  dt=2026-10-19/orders.parquet
  dt=2026-10-20/orders.parquet

5.2 DuckDB:只处理当天分区

python 复制代码
# python
import duckdb

con = duckdb.connect()
con.execute("""
    COPY (
        SELECT *
        FROM read_csv_auto('landing/orders_2026-10-20.csv')
        WHERE amount > 0
    )
    TO 'lake/orders/dt=2026-10-20/orders.parquet'
    (FORMAT PARQUET, COMPRESSION ZSTD, OVERWRITE TRUE);
""")

5.3 Polars:读多个分区,但只算需要的部分

python 复制代码
# python
import polars as pl

lf = (
    pl.scan_parquet("lake/orders/dt=*/orders.parquet")
      .filter(pl.col("status") == "paid")
)

paid_monthly = (
    lf.group_by(pl.col("dt").str.slice(0, 7).alias("month"))
      .agg(pl.col("amount").sum().alias("paid_gmv"))
      .sort("month")
)

print(paid_monthly.collect())

5.4 生产要点

  • 分区列最好在路径里 (dt=2026-10-20/),DuckDB/Polars 都能做分区裁剪
  • 别把"日期"只放在文件里而不放路径,否则只能全扫
  • 写幂等:重跑某天用 OVERWRITE TRUE 或先删分区再写

六、Pipeline 5:数据质量门禁(DuckDB 校验 + Polars 修复)

6.1 第一步:DuckDB 跑校验 SQL

python 复制代码
# python
import duckdb

con = duckdb.connect()

checks = con.sql("""
    SELECT
        COUNT(*)                                               AS total_rows,
        COUNT(*) FILTER (WHERE order_id IS NULL)               AS null_order_id,
        COUNT(*) FILTER (WHERE amount < 0)                     AS negative_amount,
        COUNT(*) FILTER (WHERE ordered_at > CURRENT_TIMESTAMP) AS future_order,
        APPROX_COUNT_DISTINCT(order_id)                        AS distinct_order_id
    FROM 'lake/orders/dt=2026-10-20/orders.parquet'
""").fetchone()

total, null_oid, neg_amt, future, distinct = checks

assert null_oid == 0, "order_id 有空值"
assert neg_amt == 0, "amount 出现负数"
assert future == 0, "ordered_at 晚于当前时间"
assert total == distinct, "order_id 不是唯一主键"

DuckDB 的 SUMMARIZE 也很适合探查:

sql 复制代码
SUMMARIZE SELECT * FROM 'lake/orders/dt=2026-10-20/orders.parquet';

它会返回每列 min/max、null 占比、近似唯一值,比先 df.describe() 再全量读进来省得多。

6.2 第二步:Polars 做可解释的修复

python 复制代码
# python
import polars as pl

df = pl.read_parquet("lake/orders/dt=2026-10-20/orders.parquet")

clean = (
    df
    .with_columns(
        pl.col("amount").clip_lower(0).alias("amount"),
        pl.col("status").fill_null("unknown"),
    )
    .filter(pl.col("ordered_at") <= pl.col("ordered_at").max())
)

clean.sink_parquet("lake/orders_clean/dt=2026-10-20/orders.parquet")

修复逻辑要可审计:别悄悄 drop_nulls(),要把被修掉的记录写一份 quarantine/ 分区给数据治理审计。


七、Arrow 交换:什么时候真的"零拷贝"

7.1 准确说法

  • DuckDB ↔ Polars 都基于 Arrow 内存模型
  • duckdb.sql(...).pl() / 直接查询作用域里的 Polars DataFrame,通常避免一次 Python 层序列化
  • 但不是"任何情况都比特级零成本" :String 列、兼容级别、是否走 Relation 都会影响
  • 最稳的写法:
    • DuckDB 结果给 Polars:.pl()
    • Polars 给 DuckDB:让 Polars DataFrame 留在当前作用域,直接 FROM polars_df
    • 别先 .to_arrow() 再 .from_arrow() 多转一道

7.2 推荐写法

python 复制代码
# ✅ 推荐
import duckdb, polars as pl
df = pl.read_parquet("lake/orders/dt=2026-10-20/orders.parquet")
out = duckdb.sql("SELECT customer_id, SUM(amount) AS gmv FROM df GROUP BY 1").pl()

# ❌ 没必要多转一道
arrow_tbl = df.to_arrow()
out = duckdb.from_arrow(arrow_tbl).sql("...").arrow().to_polars()

八、生产部署速查清单

项目 建议
原始层格式 CSV / JSON(只做交接)
分析层格式 Parquet + ZSTD 压缩
SQL 聚合 / JOIN DuckDB sql() + .pl()
清洗 / 特征 Polars scan_* + with_columns + sink_parquet
大结果物化 禁止 collect(),用 sink_*
分区字段 放路径 dt=2026-10-20/,不放文件内
类型声明 DECIMAL / TIMESTAMP / Categorical 显式指定
质量门禁 DuckDB SQL 断言 + SUMMARIZE
修复记录 写 quarantine/ 分区,不静默丢弃
Pandas 使用 只在最后给 Sklearn / Matplotlib 用
CI 集成 SUMMARIZE + 行数/主键/空值断言

九、版本口径与参考资料

  • Polars 1.44.x 稳定版:scan_parquet / sink_parquet / collect(engine="streaming")
  • DuckDB 1.5.x :read_parquet / COPY ... TO parquet / SUMMARIZE / .pl()
  • pyarrow ≥ 25.x:DuckDB ↔ Polars 集成依赖

官方依据:

  • DuckDB 可直接查询 Polars DataFrame,结果可用 .pl() 转回 Polars
  • DuckDB Parquet 写入支持 PARTITION_BY、COMPRESSION、OVERWRITE
  • Polars sink_parquet 走流式引擎,不整体物化结果

十、总结

生产数据湖不是"Polars 替代 DuckDB",而是:

DuckDB 把文件当表查,Polars 把表当表达式改,Arrow 让两者不吵架,Parquet 让磁盘不爆炸。

5 个 Pipeline 记成一句话:

  1. CSV → Parquet :DuckDB COPY
  2. 宽表聚合 :DuckDB SQL → .pl()
  3. 超内存 ETL :Polars scan_* → sink_parquet
  4. 增量分区 :路径里带 dt=,重跑幂等
  5. 质量门禁:DuckDB 校验,Polars 修复,脏数据进 quarantine

相关推荐
l1t19 小时前
测试DuckDB v2.1.0-alpha49002的bignum乘法
数据库·sql·算法·duckdb
l1t1 天前
DeepSeek总结的本地优先的分析不需要数据库服务器
运维·服务器·数据库·人工智能·duckdb
npupengsir6 天前
parquet存储格式
parquet
l1t16 天前
DeepSeek总结的一个不含数据的 DuckDB 数据库
服务器·数据库·duckdb
l1t1 个月前
测试DuckDB 2.1的match_recognize模式匹配语句
数据库·duckdb
DarkAthena1 个月前
【DuckDB】在DuckDB中查询ORACLE的数据的三种方式
oracle·duckdb
不剪发的Tony老师1 个月前
DuckDB教程:常用文本函数
数据库·sql·数据分析·duckdb
l1t2 个月前
DeepSeek总结的DuckDB在 CI 中为 release 和 glibc CLI 构建启用 LTO - #24225
开发语言·数据库·ci/cd·duckdb
l1t2 个月前
DeepSeek总结的DuckDB访谈:DuckDB的崛起、扩展与AWS收购
数据库·云计算·aws·duckdb