Polars vs Pandas:千万级数据实测,迁移到底值不值?
版权声明
我们非常重视原创文章,为尊重知识产权并避免潜在的版权问题,我们在此提供文章的摘要供您初步了解。如果您想要查阅更为详尽的内容,访问作者的公众号页面获取完整文章。
TL;DR
Polars 不是更快的 Pandas——它是一个查询编译器,把"计算步骤"重排为最优执行计划 查询优化器(谓词下推 + 投影裁剪 + CSE)+ Arrow 列式引擎 + Rust 全核并行 = 10-50× 加速 最快迁移:先把 read_csv换scan_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 代码搬过去。
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() 触发的那一刻,查询优化器接管:
谓词下推(Predicate Pushdown):把 filter(amount > 100)推到 Parquet 文件读取层。Parquet 文件内部有每一批数据的 min/max 统计信息——优化器可以直接跳过不满足条件的 row group,磁盘 I/O 直接砍掉一大半。投影裁剪(Projection Pushdown):你的查询只用了 amount和category两列,优化器告诉读取层"其他 50 列别碰"。Pandas 会先读全部 52 列再扔掉 50 列。公共子计划消除(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 等价写法: