别让 pandas 一个人扛了:DuckDB + Polars 保姆级实操,从装包到出图
我最近把一条很常见的数据分析链路重新跑了一遍:读 CSV、清洗、分组聚合、落 Parquet、再出图。
结果挺直观。200 万行 CSV,pandas 读入 0.77 秒,占 194 MB 内存;同一份文件,Polars 只花 0.04 秒。再跑一个普通的分组聚合,pandas 0.066 秒,DuckDB 0.005 秒——差了约 13 倍。
但我不太想把它写成又一篇“pandas 已死”的性能榜。实际用下来,更顺手的办法不是三选一,而是让三者各干自己擅长的那一段。下面就从装包开始,把这条链路完整跑一遍。
运行环境:M5 pro Mac,Python 3.11,duckdb 1.5.5,polars 1.43.2,pandas 3.0.3。你机器的数字可能不同,但趋势一致。
01先别急着换掉 pandas,给三个人重新分工
先把结论放前面:这篇不是来选“唯一真神”的。
Pandas、Polars、DuckDB 放在同一条数据流水线里,其实并不冲突。很多时候,最省事的做法不是把旧代码全推倒,而是把最吃力的那几段工作重新分出去。
我自己现在更愿意这么理解它们:
pandas 还是那个最熟的入口。读表、清洗、画图,生态完整,很多已有代码也都围着它写。但它是 eager 架构,操作会立即执行,数据通常整块进内存。本文这 200 万行已经占到 194 MB;数据再往上长,内存压力很快就会变得明显。 Polars 更像是“编程式清洗”这一段的加速器。Arrow 列式内存加 lazy 模式,让它可以先搭查询计划、最后统一执行,并尽量只读真正需要的列。至少在本文这份 CSV 上,读入速度和 pandas 已经不是一个量级。 DuckDB 则更适合把 SQL 分析这件事接过去。它是嵌入式列式引擎,不用起服务, duckdb.connect()就能用;更舒服的是,它可以直接查 Parquet/CSV,很多时候连“先读进 DataFrame”这一步都省了。
后面整篇就围着这一条链路展开:Polars 做转换,Parquet 做交接,DuckDB 做 SQL 分析,pandas 留在你最熟的生态入口。 每一步我都把本地运行结果放出来,不靠“理论上更快”来撑结论。
02第一步:装包,30 秒先把环境搭起来
先建一个干净虚拟环境,把三个包一起装上。DuckDB 和 Polars 都有现成 wheel,这一步通常没什么戏剧性。
python3 -m venv .venv
source .venv/bin/activate
pip install duckdb polars pandas
装完先看一眼版本。性能数字最怕“拿不同版本硬比”,所以这一步别省:
import duckdb, polars as pl, pandas as pd
print("duckdb", duckdb.__version__) # duckdb 1.5.5
print("polars", pl.__version__) # polars 1.43.2
print("pandas", pd.__version__) # pandas 3.0.3
本文后面的运行结果都来自这三个版本。你如果还在用 Polars 0.20.x 一类老版本,部分 API 名称会不同,例如 groupby 后来改成了 group_by。想直接照抄代码,建议先升到 1.x。
03七步完整实操指南
第二步:自己造数据,省得先折腾下载
我不太喜欢那种教程一开头先让你下载几百 MB 数据集,链接一失效,后面全没法跑。
这里直接生成 200 万行电商订单数据,字段也尽量贴近真实分析:用户、商品、地区、渠道、状态、数量、单价、日期和折扣,并且故意留 5% 缺失值。这样后面读文件、清洗、聚合、空值处理都能一起测。
import random, time
import polars as plrandom.seed(42)
n = 2_000_000
product_prices = {pid: round(random.uniform(9.9, 1999.0), 2) for pid in range(1, 4001)}
regions = [f"R{chr(65 + i % 26)}{i // 26:02d}" for i in range(200)]
df = pl.DataFrame({
"order_id": range(1, n + 1),
"user_id": [random.randint(1, 800_000) for _ in range(n)],
"product_id": [random.randint(1, 4000) for _ in range(n)],
"region": [random.choice(regions) for _ in range(n)],
"channel": [random.choice(["app", "web", "mini_program", "tiktok", "shop"]) for _ in range(n)],
"status": [random.choice(["paid", "shipped", "delivered", "refunded", "cancelled"]) for _ in range(n)],
"quantity": [random.randint(1, 10) for _ in range(n)],
"unit_price": [product_prices[random.randint(1, 4000)] for _ in range(n)],
"order_date": [f"2025-{random.randint(1,12):02d}-{random.randint(1,28):02d}" for _ in range(n)],
"discount": [None if random.random() < 0.05 else round(random.uniform(0, 0.4), 2) for _ in range(n)],
})
t0 = time.perf_counter()
df.write_csv("orders.csv")
print(f"生成 {n:,} 行,写入 {time.perf_counter() - t0:.2f}s")
这里先记住两个细节,后面都会回来:
我直接用 Polars 生成并写 CSV。本文这 200 万行写盘用了 2.62 秒,先提前感受一下它处理大表时的速度。 discount字段故意放了 5% 缺失值(None)。真实数据很少像教材一样干净,后面顺手把这个坑也处理掉。
生成的文件长这样(前几行):
order_id,user_id,product_id,region,channel,status,quantity,unit_price,order_date,discount
1,123225,396,RP04,tiktok,refunded,1,580.6,2025-07-19,0.03
2,899130,2877,RE34,web,delivered,2,1428.7,2025-01-17,
3,401182,90,RG49,mini_program,paid,7,321.5,2025-03-08,0.11
注意第 2 行 discount 后面是空的——那就是缺失值。
第三步:同一份 CSV,三个引擎直接跑
环境和数据都有了,先做最直观的一轮:三种方式读同一份 2,000,000 行 CSV,不做花活,直接计时。
pandas(你熟悉的方式):
import pandas as pd, time
t0 = time.perf_counter()
pdf = pd.read_csv("orders.csv")
print(f"pandas 读入: {time.perf_counter()-t0:.3f}s, 内存 {pdf.memory_usage(deep=True).sum()/1e6:.0f} MB")
# pandas 读入: 0.766s, 内存 194 MB
Polars(lazy 模式):
import polars as pl, time
t0 = time.perf_counter()
lf = pl.scan_csv("orders.csv") # 只建计划,不读数据
plf = lf.collect() # 现在才真正执行
print(f"polars 读入: {time.perf_counter()-t0:.3f}s")
# polars 读入: 0.038s
DuckDB(建表即入库):
import duckdb, time
con = duckdb.connect("orders.duckdb")
t0 = time.perf_counter()
con.execute("CREATE OR REPLACE TABLE orders AS SELECT * FROM read_csv_auto('orders.csv', header=true)")
print(f"duckdb 建表: {time.perf_counter()-t0:.3f}s")
# duckdb 建表: 0.199s
这一轮跑完,差距已经很明显:同样 200 万行 CSV,pandas 0.766 秒,Polars 0.038 秒,DuckDB(含建表)0.199 秒。Polars 比 pandas 快约 20 倍。
内存这里我只报自己真的测过的数字:pandas 占用了 194 MB。Polars 的列式 + lazy 架构通常会更省,但本文没有单独记录它的峰值内存,所以不硬凑一个漂亮数字。
为什么会拉开这么大?关键不是 API 写法,而是执行方式。pandas 的 read_csv 会直接把整张表解析进内存;Polars 的 scan_csv 先记下“我要读这个文件”,等 collect() 时再执行,而且可以结合后续查询只处理需要的列。DuckDB 同样是列式路线,只不过这里把读 CSV 和建表合成了一条 SQL,200 万行不到 0.2 秒。
Polars 的 .explain() 很适合用来确认它到底准备干什么。比如:
print(lf.group_by("region").agg(pl.len()).explain())
输出里会看到类似 PROJECT 4/10 COLUMNS、ESTIMATED ROWS: 2214271。最关键的是前者:10 列里只打算处理 4 列。这就是投影裁剪(projection pushdown)。你开始能看见 lazy 模式到底省在了哪里,而不只是背一句“Polars 更快”。
3.5 我最喜欢 DuckDB 的一点:文件可以直接查
前面为了对比,先把 CSV 建成了 orders 表。但实际临时分析时,我经常连这一步都懒得做:DuckDB 可以直接把文件当表查。
con.execute("""
SELECT count(*), sum(quantity * unit_price) AS gmv
FROM read_csv_auto('orders.csv', header=true)
""").df()
# rows=2000000, gmv=11119534345.67
这条查询没有提前建表,SQL 直接落在 CSV 上。对于“我就想快速看一眼这个文件里有多少行、GMV 大概多少”这种临时需求,这个体验很舒服:不用先 pandas.read_csv,也不用设计数据库表,文件在哪,SQL 就查到哪。
前面的 CREATE TABLE AS SELECT 实测是 0.199 秒,所以建不建表在这份 200 万行数据上都不算重;真正的区别是操作路径短了很多。
第四步:轮到最常见的 groupby 了
读文件快是一回事,真正分析时更常见的是分组聚合。这里统一做“地区 × 渠道”的订单数和 GMV(成交额),三个引擎各跑一遍。
pandas:
pdf["gmv"] = pdf["quantity"] * pdf["unit_price"]
g_pd = pdf.groupby(["region", "channel"], observed=True)["gmv"] \
.agg(["count", "sum"]).sort_values("sum", ascending=False)
# 0.066s
Polars(eager 和 lazy 都试试):
plf = plf.with_columns((pl.col("quantity") * pl.col("unit_price")).alias("gmv"))
g_pl = plf.group_by(["region", "channel"]) \
.agg(pl.len().alias("orders"), pl.col("gmv").sum()) \
.sort("gmv", descending=True)
# 0.032sg_pll = (lf.with_columns((pl.col("quantity") * pl.col("unit_price")).alias("gmv"))
.group_by(["region", "channel"])
.agg(pl.len().alias("orders"), pl.col("gmv").sum())
.sort("gmv", descending=True)
.collect())
# 0.051s
DuckDB(一条 SQL):
g_dk = con.execute("""
SELECT region, channel,
count(*) AS orders,
sum(quantity * unit_price) AS gmv
FROM orders
GROUP BY region, channel
ORDER BY gmv DESC
""").df()
# 0.005s
实测结果(200 万行):
| 0.005 s |
这一轮里,DuckDB 比 pandas 快约 13 倍。
不过这里别被“13 倍”带跑偏:200 万行时,0.066 秒和 0.005 秒,人手点一次几乎感受不到。这个差距真正有意义的场景,是你开始连续跑很多变体查询,或者数据规模继续往上走。倍数会在重复工作里被放大,而不是在一次点击里制造奇迹。
4.5 真正混用时,数据怎么来回接
三引擎想协作,最现实的问题不是理念,而是“数据怎么递过去”。下面几种转换我自己会反复用到:
# polars → pandas
pandas_df = plf.to_pandas()# pandas → polars
polars_df = pl.from_pandas(pandas_df)
# duckdb 查询结果 → pandas
result = con.execute("SELECT * FROM orders LIMIT 5").df()
# pandas DataFrame 注册成 duckdb 表,直接用 SQL 查
con.register("orders_pd", pdf)
con.execute("SELECT count(*) FROM orders_pd").fetchone() # (2000000,)
最后这条尤其适合已有 pandas 项目:con.register() 可以把内存里的 DataFrame 直接“挂”给 DuckDB,然后用 SQL 查它,不需要先导 CSV、再读回来。
这也是我觉得最容易落地的升级方式:原来的 pandas 流程先不动,只把最慢的聚合片段交给 DuckDB。 不用为了追性能,把整套分析代码重写一遍。
第五步:先别庆祝,结果得对得上
性能数字再漂亮,结果不一致都没意义。所以我接着做了一个很朴素的校验:同一份数据、同一个查询,把三个引擎算出的 GMV 总和对一遍。
pd_agg = g_pd.reset_index().rename(columns={"count": "orders", "sum": "gmv"})
pl_agg = g_pll.to_pandas()
dk_agg = g_dkprint("GMV 总和 → pandas:", pd_agg.gmv.sum())
print("GMV 总和 → polars:", pl_agg.gmv.sum())
print("GMV 总和 → duckdb:", dk_agg.gmv.sum())
实测输出:
GMV 总和 → pandas: 11119534345.67
GMV 总和 → polars: 11119534345.67
GMV 总和 → duckdb: 11119534345.67
这一组结果完全一致,至少说明本文这条查询链路没有因为换引擎出现口径漂移。
这一步看起来很“无聊”,但我反而建议实际项目一定保留:性能优化最怕的是跑快了,数字却悄悄变了。后面“Polars 清洗 → Parquet → DuckDB 查询”之所以敢串起来,先得把这个底打牢。
顺手看一眼业务结果——按 GMV 口径(成交额 = 数量 × 单价,未扣折扣)的 Top 3 地区:
region gmv
RN06 57458431.43
RH00 57315775.81
RW04 57190448.93
数据是随机生成的,数字本身没有业务含义,但查询链路是真实的——你可以把这份代码原样换成自己的业务数据。上面的配图和这里用的是同一个口径(GMV),后面第六步会改用扣折扣后的净额(net_gmv),注意区分。
第六步:把三段真正串起来
前面都是单项测试。现在开始做我认为最实用的一段:Polars 清洗 → Parquet 落盘 → DuckDB 查询。场景很普通:
Polars 做清洗和特征工程:填充缺失值、计算净成交额(扣除折扣)、只保留要用的列; 中间结果用 Parquet 落盘:Parquet 是列式二进制格式,带 schema,比 CSV 快且省空间; DuckDB 直接用 SQL 查 Parquet:不用先加载进内存, read_parquet()零拷贝读取。
# 第 1 步:Polars 清洗 + 特征工程
fe = (
plf.with_columns(
pl.col("discount").fill_null(0).alias("discount_filled"),
(pl.col("quantity") * pl.col("unit_price")).alias("gmv"),
)
.with_columns((pl.col("gmv") * (1 - pl.col("discount_filled"))).alias("net_gmv"))
.select(["order_id", "region", "channel", "order_date", "net_gmv"])
)
fe.write_parquet("fe.parquet")# 第 2 步:DuckDB 直接查 Parquet(不用 import)
result = con.execute("""
SELECT region, sum(net_gmv) AS net_gmv
FROM read_parquet('fe.parquet')
GROUP BY region
ORDER BY net_gmv DESC
LIMIT 10
""").df()
print(result.head())
实测全程耗时 0.025 秒,返回 Top region RN06 net_gmv=46700993.53。
这里值得停一下:DuckDB 可以直接 read_parquet(),不需要先把整个文件塞进 DataFrame。 数据到了几百 MB、几个 GB,这个差别就开始重要——按需读列、边读边聚合,比“全部加载完再算”更容易控制内存。
这里再补一个外部参照,但别和本文实测混在一起:codecentric 的第三方 TPC-H 基准里,140 GB 单文件 Parquet 场景下 DuckDB 峰值内存 1.3 GB,Polars 17 GB,pandas 在 5 GB 时 OOM。这个数字不是我本机跑出来的,只用来说明为什么“直接查 Parquet”这条路能撑到远大于内存的数据规模。
6.5 再跑一个更像真实工作的案例
只看 Top 10 还不太过瘾。下面做一个更接近日常分析的问题:算每个渠道每个月的净 GMV 和订单数,再找出增长最快的渠道。
# Polars:清洗 + 分组聚合 + 排序
monthly = (
plf.with_columns(
pl.col("order_date").str.slice(0, 7).alias("month"), # "2025-01"
(pl.col("quantity") * pl.col("unit_price")).alias("gmv"),
)
.with_columns((pl.col("gmv") * (1 - pl.col("discount").fill_null(0))).alias("net_gmv"))
.group_by(["month", "channel"])
.agg(pl.len().alias("orders"), pl.col("net_gmv").sum())
.sort(["month", "net_gmv"], descending=[False, True])
)# 写 Parquet
monthly.write_parquet("monthly.parquet")
# DuckDB:直接用 SQL 找增长最快的渠道
growth = con.execute("""
WITH base AS (
SELECT month, channel, sum(net_gmv) AS net_gmv
FROM read_parquet('monthly.parquet')
GROUP BY month, channel
),
min_max AS (
SELECT channel,
min(month) AS first_month,
max(month) AS last_month
FROM base
GROUP BY channel
)
SELECT mm.channel,
round(
(SELECT net_gmv FROM base b
WHERE b.channel = mm.channel AND b.month = mm.last_month)
-
(SELECT net_gmv FROM base b
WHERE b.channel = mm.channel AND b.month = mm.first_month),
2
) AS growth
FROM min_max mm
ORDER BY growth DESC
""").df()
print(growth.head())
实测输出(200 万行):
channel growth
tiktok 1558946.39
shop 787825.22
mini_program 651168.54
app -1784181.23
web -2237368.87
这里我确实踩了一个坑。
第一版 SQL 用的是更顺手的窗口函数写法:first_value / last_value + OVER (PARTITION BY channel ORDER BY month)。结果在 DuckDB 1.5.5 里和后面的 GROUP BY channel 打架,直接报:column "last_gmv" must appear in the GROUP BY clause。
最后我改成先用 min(month) / max(month) 找首末月,再用标量子查询取对应值,才跑通。
这也是我写实操教程时比较在意的一点:“按语法看应该能跑”和“我真的跑过”是两回事。 下面留下的是最终跑通的版本,不是脑补出来的示例。
顺着这段代码看,三者的分工会比前面的概念解释更直观:
清洗和特征工程(算净额、取月份、填充空值)全在 Polars 里,表达式清晰、可读性好; 窗口函数、WITH 子句、排序这些分析型 SQL,全在 DuckDB 里,写法是 SQL 用户熟悉的姿势; Parquet 是它们的交接点——Polars 的输出直接就是 DuckDB 的输入,中间没有任何格式转换成本。
到这里,它已经不太像“为了 benchmark 拼出来的玩具”了:数据进来,做清洗,落中间结果,再跑分析 SQL,基本就是日常分析的节奏。
本文这条 Polars 清洗 → Parquet → DuckDB 查询链路,在 200 万行上实测耗时 0.025 秒。换成你自己的业务数据,绝对时间会变,但这套分工方式本身很容易复用。
6.6 最后把结果画出来
分析做到最后,多半还是要把数字变成图。这里不再引入新框架,直接用 matplotlib 把前面的 Top 10 地区 GMV 画成横向柱状图。
import matplotlib.pyplot as plt# 用 DuckDB 查各渠道 GMV(数据已在 orders 表)
ch = con.execute("""
SELECT channel, sum(quantity * unit_price) AS gmv
FROM orders
GROUP BY channel
ORDER BY gmv DESC
""").df()
# 画饼图(autopct 显示百分比)
fig, ax = plt.subplots(figsize=(7, 5))
ax.pie(ch["gmv"], labels=ch["channel"], autopct="%1.1f%%", startangle=90,
colors=["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"])
ax.set_title("各渠道 GMV 占比(200 万行订单)")
plt.tight_layout()
plt.savefig("channel_gmv.png", dpi=150)
macOS 上如果图中的中文标题显示成方框,加一行字体配置:
import matplotlib
matplotlib.rcParams["font.sans-serif"] = ["PingFang SC", "Hiragino Sans GB", "STHeiti"]
matplotlib.rcParams["axes.unicode_minus"] = False
这张图也是用本文这份数据实际生成的:
以后要换成 Top 地区、月度趋势或者别的维度,改 SELECT 和 GROUP BY 就行。前面的数据处理链路不需要跟着重写,画图只是在结果最后再接一段。
第七步:到底什么时候用谁
跑完这一圈,再回头看三者就没那么容易陷入“谁替代谁”的争论了。我会按下面这个顺序选:
数据小于 100 万行、一次性小任务 → 直接用 pandas,省心,生态全。 数据几百 MB 到几 GB,编程式处理(清洗、特征工程) → 用 Polars。lazy 模式 + 投影裁剪,写起来表达力强,跑起来快。 要做大量聚合分析、SQL 顺手、或者直接查 Parquet → 用 DuckDB。聚合快一个量级,内存可控。 数据超过单机内存,或者清洗和查询都复杂 → 用组合:Polars 清洗 → Parquet → DuckDB 查询。
两个边界也要说清楚,避免误解:
DuckDB 是分析型(OLAP)引擎,不适合高并发写入的在线业务场景(那是数据库该干的事)。 Polars 是 DataFrame 库,不是数据库,没有 SQL server 语义——它和 DuckDB 是互补关系,不是替代。
所以最后还是那句话:pandas 完全不用退场,但也没必要什么都让它扛。
Polars 负责把数据收拾干净,DuckDB 负责快速算答案,Parquet 负责在两者之间交接;pandas 则继续留在你熟悉的分析和可视化生态里。
本文这条协同链路在 200 万行上实测不到 0.1 秒,而且全程就是一台 M5 pro Mac。对很多原本已经开始让笔记本喘气的数据任务来说,先重新分工,往往比先换机器更值得试。
参考材料
DuckDB 官方文档 — duckdb.org/docs Polars 官方文档 — docs.pola.rs codecentric TPC-H 内存基准(140GB DuckDB 1.3GB / Polars 17GB / Pandas OOM)— codecentric.de DuckDB GitHub 仓库 — github.com/duckdb/duckdb