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()多转一道
- DuckDB 结果给 Polars:
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 记成一句话:
- CSV → Parquet :DuckDB
COPY - 宽表聚合 :DuckDB SQL →
.pl() - 超内存 ETL :Polars
scan_*→sink_parquet - 增量分区 :路径里带
dt=,重跑幂等 - 质量门禁:DuckDB 校验,Polars 修复,脏数据进 quarantine