Polars vs Pandas:千万级数据实测,迁移到底值不值?

pl.col Polars Pandas amount .alias
发布于 2026-06-12
3

我们非常重视原创文章,为尊重知识产权并避免潜在的版权问题,我们在此提供文章的摘要供您初步了解。如果您想要查阅更为详尽的内容,访问作者的公众号页面获取完整文章。

扫码阅读
手机扫码阅读

TL;DR

  • Polars 不是更快的 Pandas——它是一个查询编译器,把"计算步骤"重排为最优执行计划
  • 查询优化器(谓词下推 + 投影裁剪 + CSE)+ Arrow 列式引擎 + Rust 全核并行 = 10-50× 加速
  • 最快迁移:先把 read_csvscan_parquet,再逐步重写 GroupBy/Join,最后一并 .collect()

先看一组数字。

同样是 1000 万行交易数据做 GroupBy 聚合,Pandas 跑了 87 秒,吃了 8GB 内存。Polars:13 秒,内存只用 3GB。

不是 20% 的"哦快了一点"。是 6 倍

你感受到的不是 Python 慢——是逐行执行遇上千万级数据的物理极限。

我在一台 24GB 内存的 MacBook Pro 上跑的这个对比。Pandas 那 87 秒里,风扇全程在嘶吼,Chrome 切标签页都卡。Polars 跑完 13 秒我甚至没意识到它开始了——风扇纹丝不动。

这不是"哪个库更好"的信仰之争。这是两种计算范式之间的差距。你感受到的不是 Python 慢——是逐行执行遇上千万级数据的物理极限。

这篇东西帮你搞清楚三件事:Polars 为什么快、快多少、你要怎么把现有的 Pandas 代码搬过去。

6.7×Polars 对 Pandas 在千万级 GroupBy 场景的平均加速比(来源:第三方 benchmark,2025-2026)

01当 Pandas 撞上数据墙

我先说一个你一定经历过的场景。

你写了个 ETL 脚本,本地几百行数据跑得飞起。部署到生产环境当天,数据量涨到 300 万行,同一个脚本跑了 8 分钟。你看了一眼 htop——一个 CPU 核心跑满,另外 7 个在围观。

这就是 Pandas 的原罪。不是"Pandas 写得不好"——它从一开始就没被设计成并行执行的。底层 NumPy 数组虽然快,但一次只调用一个 CPU 核心。Python 的 GIL(全局解释器锁)卡在那里,你有 16 个核也得排队。

Pandas 2.0 引入了 PyArrow 后端,内存占用确实降了一些。但这个改动只修了"数据怎么存"的问题,没修"计算怎么调"的问题。Eager 执行模式下,你的每一行代码都会立刻触发一次全数据遍历:

# 这 3 行代码 = 3 次独立全表扫描,没有优化 df_filtered = df[df['amount'] > 100] # 扫描 1:过滤 df_grouped = df_filtered.groupby('cat') # 扫描 2:分组 result = df_grouped['amount'].mean() # 扫描 3:聚合 

数据 500 万行时,3 次全扫描 ≈ 1500 万行 Python 对象操作。慢是物理定律级别的。

那真正的问题是什么?不是 Pandas 不好——是缺乏一个编译优化层。你写的是"计算步骤",Pandas 照单全收;你真正需要的是"计算意图",然后让系统替你决定最优执行顺序。

你写的不是计算步骤,是计算意图——让优化器替你决定怎么跑最快。


02查询编译器:Polars 的真正内核

Polars 跟你熟悉的 Pandas 最大的区别,不在语法——在一个你看不到的组件:查询优化器(Query Optimizer)

打个比方。你去餐厅点菜,跟服务员说"宫保鸡丁,少辣,打包",然后坐下来等。厨房里发生的事情你不需要管——先切哪样菜、哪个灶同时开火、出菜顺序怎么排——厨师团队自己优化。

Pandas 是你自己进厨房。切完葱再切姜,热完油发现鸡肉还没解冻,手忙脚乱。每一步都做了,但总时间最长。

Polars 的 Lazy API 就是"点菜模式"。你写:

import polars as pl result = (  pl.scan_parquet("transactions_10m.parquet") # 还没读文件  .filter(pl.col("amount") > 100) # 还没过滤  .group_by("category") # 还没分组  .agg(pl.col("amount").mean()) # 还没聚合  .collect() # ← 现在才真正执行 ) 

.collect() 之前,上面的每一行都没有碰到数据。你只是在构建一棵逻辑计划树(Logical Plan).collect() 触发的那一刻,查询优化器接管:

  1. 谓词下推(Predicate Pushdown):把 filter(amount > 100) 推到 Parquet 文件读取层。Parquet 文件内部有每一批数据的 min/max 统计信息——优化器可以直接跳过不满足条件的 row group,磁盘 I/O 直接砍掉一大半。
  2. 投影裁剪(Projection Pushdown):你的查询只用了 amountcategory 两列,优化器告诉读取层"其他 50 列别碰"。Pandas 会先读全部 52 列再扔掉 50 列。
  3. 公共子计划消除(CSE):如果你在多个分支里复用了同一个计算,优化器自动插入 CACHE 节点,只算一次。

想知道优化器到底做了什么?Polars 给你看:

lf = (  pl.scan_parquet("data.parquet")  .filter(pl.col("amount") > 100)  .group_by("category")  .agg(pl.col("amount").mean()) ) print(lf.explain()) # 打印优化后的查询计划 

输出大概长这样:

AGGREGATE  [col("amount").mean()] BY [col("category")]  FILTER col("amount") > 100        ← 注意:filter 已经被推到 SCAN 前面了  SCAN data.parquet  PROJECT 2/52 columns              ← 只读了 2 列 

同样的逻辑,Pandas 是你手动优化(先把数据量砍小再 groupby——但你经常懒得做),Polars 是自动的。这就是为什么 Benchmarks 里 Lazy 模式比 Eager 还能再快 3-5 倍。

.collect() 之前的每一行都没碰到数据——你在画图纸,不是在做工件。

03列式引擎:Arrow 内存布局是物理基础

优化器解决了"怎么算最快"的问题。但真正让计算跑起来的物理层,是 Apache Arrow 列式内存 + Rust SIMD 向量化。

Pandas 底层是 NumPy,数据一行一行存。你取一列 df['amount'] 的时候,NumPy 要跳过所有其他列,在内存里跳跃读取——每次跳都是 CPU 缓存未命中。

Arrow 列式存储不同。一列的所有值在内存里是连续排列的:所有 amount 值 → 所有 category 值 → 所有 date 值。当 CPU 取 amount[0] 时,缓存行(64 字节)会把 amount[0]amount[7] 一起拉进 L1 缓存。下一个值已经在缓存里了,零等待。

这还不止。列式数据天然适配 SIMD(Single Instruction Multiple Data)——一条 CPU 指令同时操作多个数据。Polars 的 Rust 核心在底层大量用 SIMD 做加法、比较、字符串匹配。Pandas 在 Python 层的操作做不到这个。

还有一个被低估的差异:并行。Pandas 单线程是硬伤。Polars 的表达式系统支持自动多核:

# 3 个新列的表达式——自动并行计算 df.with_columns([  (pl.col("revenue") - pl.col("cost")).alias("profit"),  (pl.col("profit") / pl.col("revenue")).alias("margin"),  (pl.col("amount") * 1.13).alias("amount_with_tax"), ]) 

在 Pandas 里你要写 3 行,一行等一行。Polars 检测到这 3 个表达式互相独立,分配到你的所有 CPU 核心上同时算。

Pandas 的 Eager 模式:你说一句它做一句。Polars Lazy:你画完图纸,它把整条流水线一次开动。

有人问:那为什么不全用 Polars?我的回答是——看场景。1000 行数据做 EDA,Pandas 的快感(.describe() 一把梭、Seaborn 直接吃 DataFrame)还在。但一旦数据上 100 万行,Eager 逐行执行的代价会以平方级膨胀。到 1000 万行级别,差距不再是"几秒 vs 几分",而是"能不能跑"。

04搬家伙:20 个 Pandas 操作一键转 Polars

好,到实操部分。

先说最重要的心智切换:Polars 没有 index。如果你以前用 .loc[].iloc[]df.index 做操作,先把 index 转成一列。第二个差异:Polars 的列不是 df['col'] 的字符串,而是 pl.col("col") 表达式对象——这让它可以延迟执行。

下面是最常用的 20 个操作对照。左边是你写的 Pandas,右边是 Polars 等价写法:

操作
Pandas
Polars
读 CSV
pd.read_csv("f.csv") pl.read_csv("f.csv")
读 Parquet(推荐)
pd.read_parquet("f.pq") pl.scan_parquet("f.pq")
← Lazy
选列
df[["a","b"]] df.select("a","b")
过滤
df[df.x > 5] df.filter(pl.col("x") > 5)
新增列
df["new"] = df.x + 1 df.with_columns((pl.col("x")+1).alias("new"))
GroupBy + 均值
df.groupby("g").y.mean() df.group_by("g").agg(pl.col("y").mean())
重命名
df.rename(columns={"o":"n"}) df.rename({"o":"n"})
排序
df.sort_values("col") df.sort("col")
Join
pd.merge(a, b, on="k") a.join(b, on="k")
填充空值
df["col"].fillna(0) df.with_columns(pl.col("col").fill_null(0))
去重
df["col"].unique() df.select("col").unique()
字符串包含
df.col.str.contains("x") pl.col("col").str.contains("x")
转日期
pd.to_datetime(df.col) pl.col("col").str.to_datetime()
滚动均值
df.col.rolling(3).mean() pl.col("col").rolling_mean(window_size=3)
累计和
df.col.cumsum() pl.col("col").cum_sum()
写 Parquet
df.to_parquet("o.pq") df.write_parquet("o.pq")
删除空行
df.dropna(subset=["c"]) df.drop_nulls("c")
Pivot
df.pivot_table(...) df.pivot(values="v", index="i", columns="c")

数据STUDIO

点击领取《Python学习手册》,后台回复「福利」获取。『数据STUDIO』专注于数据科学原创文章分享,内容以 Python 为核心语言,涵盖机器学习、数据分析、可视化、MySQL等领域干货知识总结及实战项目。

158 篇文章
浏览 204K

还在用多套工具管项目?

一个平台搞定产品、项目、质量与效能,告别整合之苦,实现全流程闭环。

加入社区微信群
与行业大咖零距离交流学习
PMO实践白皮书
白皮书上线