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 plresult = (
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 等价写法:
pd.read_csv("f.csv") | pl.read_csv("f.csv") | |
pd.read_parquet("f.pq") | pl.scan_parquet("f.pq") | |
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")) | |
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") | |
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() | |
df.to_parquet("o.pq") | df.write_parquet("o.pq") | |
df.dropna(subset=["c"]) | df.drop_nulls("c") | |
df.pivot_table(...) | df.pivot(values="v", index="i", columns="c") | |
df.head(10) | df.head(10) | |
pl.from_pandas(pd_df)pl_df.to_pandas() |
搞清楚了语法对照,来看 3 个真实迁移场景。
场景 1:ETL 管道——从 CSV 到聚合结果
# Pandas 写法:3 次全表扫描
df = pd.read_csv("sales_2026.csv") # 读全表
df = df[df['date'] >= '2026-01-01'] # 扫描 1
monthly = df.groupby('month')['revenue'].sum() # 扫描 2# Polars 写法:1 次扫描,优化器重排
monthly = (
pl.scan_csv("sales_2026.csv")
.filter(pl.col("date") >= "2026-01-01")
.group_by("month")
.agg(pl.col("revenue").sum())
.collect()
)
场景 2:多表 Join——订单 + 用户画像
# Polars:join 前自动过滤,减少 join 数据量
result = (
orders.lazy()
.filter(pl.col("status") == "completed")
.join(users.lazy().select("user_id", "tier"), on="user_id")
.group_by("tier")
.agg(pl.col("amount").sum())
.collect()
)
场景 3:混合策略——Polars 做重活,Pandas 做最后一公里
# 80% 的 ETL 用 Polars 扛,最后交给 Pandas + Seaborn 画图
import polars as pl
import pandas as pddf_heavy = (
pl.scan_parquet("huge_transactions/*.parquet")
.filter(pl.col("amount") > 1000)
.group_by(["user_id", "date"])
.agg([
pl.col("amount").sum().alias("total_spent"),
pl.col("order_id").n_unique().alias("order_count")
])
.collect() # ← Polars 扛住重计算
)
pdf = df_heavy.to_pandas() # ← 瘦身后的数据交给 Pandas
pdf.plot(kind="bar") # Seaborn/Matplotlib 生态随便用
这不是"二选一"。这是Polars 当发动机,Pandas 当方向盘。
05当内存不够时:Streaming 引擎
说了这么多,还有一个场景没覆盖——数据比内存大。
你的 MacBook pro 24GB 内存,要处理 50GB 的 Parquet 文件。Pandas 直接 OOM 挂掉。传统的解法是上 Dask 或 Spark——布一套分布式环境,配 YARN,调 JVM。对于"我就想跑个聚合"来说,这前置成本大到荒谬。
Polars 的答案是 collect(streaming=True):
# 处理 50GB+ 数据,16GB 内存就够了
result = (
pl.scan_parquet("giant_table/*.parquet") # 100 个 Parquet 文件
.filter(pl.col("date").is_between("2025-01-01", "2026-05-01"))
.group_by(["category", "month"])
.agg([
pl.col("amount").sum(),
pl.col("quantity").mean()
])
.sort("amount", descending=True)
.collect(streaming=True) # 分批读、分批算、分批出
)
Streaming 把查询计划切成微批次(micro-batch):读一批 → 过滤 → 聚合 → 写出中间结果 → 读下一批。全程内存占用可控,不需要加机器、不需要配集群、不需要管 YARN。
少一个组件,少一个会炸的点。
有代价吗?有。全局排序(sort)在 streaming 模式下需要落盘——因为第 1000 行的值可能比第 1 行小,必须全看完才知道顺序。还有一些操作暂时不支持 streaming(如 pivot、rolling_mean),官方在每个版本的 release note 里在逐步补齐。
这个能力在生产环境代表什么?你原来需要"Pandas + Dask + 定时任务 + 监控"的一条管线,现在一个 Python 脚本 + pl.scan_parquet + streaming=True 就够了。少一个组件,少一个会炸的点。
06Polars 在 AI 数据管线里的位置
最后聊一下 AI。
Polars 在 AI 管线里的定位很清晰:数据预处理层的加速器。具体三个场景:
LLM 调用日志分析。你把所有 API 请求日志存成 Parquet,每天几百万行。想查"哪种 prompt 的 token 消耗最大""哪个模型的延迟在涨"——Polars Lazy + Parquet 的 predicate pushdown 让你只读需要的日期范围,跳过不相关的列。这在 Pandas 里要先加载全表再 filter。
RAG 文档预处理。你的知识库有 10 万个文档块(chunk),要清洗、去重、统计长度分布、过滤空块。Polars 的 .with_columns() 批量操作,管道式处理,全程并行。
Embedding 后处理。你拿到了 100 万条 embedding 向量(1536 维),要做归一化、聚类标记、相似度筛选。LanceDB(也是 Arrow-native 的向量数据库)可以直接从 Polars DataFrame 读数据——零拷贝,同一个 Arrow 内存格式。
# AI 日志分析:从海量调用记录中快速定位异常
import polars as pllogs = (
pl.scan_parquet("llm_api_logs/2026/*.parquet")
.filter(
pl.col("model").is_in(["claude-sonnet-4-6", "gpt-4o"]),
pl.col("latency_ms") > 5000# 只关注慢请求
)
.group_by(["model", pl.col("timestamp").dt.hour().alias("hour")])
.agg([
pl.col("latency_ms").mean().alias("avg_latency"),
pl.col("tokens_total").sum().alias("total_tokens"),
pl.len().alias("request_count")
])
.sort(["model", "hour"])
.collect()
)
print(logs)
上下滑动查看更多源码
# RAG 文档预处理管道:清洗 + 统计 + 过滤
docs = (
pl.scan_parquet("knowledge_base/*.parquet")
.with_columns(
pl.col("content").str.len_chars().alias("char_count"),
pl.col("content").str.split(" ").list.len().alias("word_count")
)
.filter(
pl.col("char_count") > 200, # 过滤太短的文档块
pl.col("char_count") < 8000# 过滤太长的文档块
)
.with_columns(
(pl.col("char_count") / 1000).alias("char_count_k")
)
.collect()
)print(f"保留 {docs.height} 个有效文档块,平均长度 {docs['char_count'].mean():.0f} 字")
这就是"Polars + AI"的交集——不是让你用 Polars 训模型,而是你训模型之前和之后的数据工程,全在 Polars 的加速范围内。
07你不需要"学一个新库"
回到最开始的问题——迁移值不值?
如果你每天的 Pandas 操作就是 df.describe() 和画几张图,Polars 不值得。你的瓶颈不在数据处理速度上。
但如果你的管线有 GroupBy 跑几分钟等结果的经历,有 Join 后 OOM 的经历,有"要不加个 Dask 试试"的冲动——那 Polars 的学习成本,就是 20 个语法对照 + 一个 .collect() 心智切换。一个下午的事。
迁移不需要一步到位。最快路线:
今天:把最大的 Pandas 脚本里的 pd.read_csv()换成pl.scan_csv(),最后加个.collect().to_pandas()。I/O 直接快 3 倍,后面代码一行不动。这周:挑一个最慢的 GroupBy / Join 操作,用 Polars 表达式重写。对照上面的 20 个语法表,边写边查。 下个月:重构整个 ETL 为单根 Lazy 管线,一个 .collect()收尾。这时候你会看到 10 倍以上的端到端加速。
Polars 的出现不是 Pandas 的"死亡通知"。它是一个信号——Python 数据处理的基建层正在从"手工作坊"向"编译执行"跃迁。UV 管包、Ruff 管代码质量、ty 管类型、Polars 管数据——Astral 全家桶的逻辑是一致的:用 Rust 重写一切,让 Python 从"能跑"变"跑得飞起"。
不是让你一口气全学。每次只换一个组件,换完立刻有性能反馈——这就是最好的学习动力。
这些工具不用一口气全学。每次只换一个组件,换完立刻有性能反馈——这本身就是最好的学习动力。
附:完整 Benchmark 脚本(可直接运行)
把下面这段复制到你的环境里,改一下 num_rows 看不同数据量下的差距:
上下滑动查看更多源码
"""Polars vs Pandas 性能对比脚本——在你的机器上跑一下看看差距"""
import time
import numpy as np
import pandas as pd
import polars as plNUM_ROWS = 5_000_000# 改成你机器的量级:100万 / 500万 / 1000万
print(f"生成 {NUM_ROWS:,} 行测试数据...")
data = {
"category": np.random.choice(["A", "B", "C", "D", "E"], NUM_ROWS),
"value_1": np.random.randn(NUM_ROWS),
"value_2": np.random.randn(NUM_ROWS),
"amount": np.random.randint(1, 1000, NUM_ROWS),
"date": np.random.choice(pd.date_range("2025-01-01", "2026-05-20"), NUM_ROWS),
}
pdf = pd.DataFrame(data)
print(f"Pandas DataFrame 内存: {pdf.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
# ── Pandas 测试 ──
t0 = time.perf_counter()
agg_pd = (
pdf.groupby("category")
.agg(mean_val=("value_1", "mean"), sum_amt=("amount", "sum"), count=("value_2", "count"))
.sort_values("sum_amt", ascending=False)
)
t_pandas = time.perf_counter() - t0
print(f"\nPandas GroupBy + Agg: {t_pandas:.2f}s")
# ── Polars 测试 ──
pdf_pl = pl.from_pandas(pdf) # 公平对比:同一份数据
t0 = time.perf_counter()
agg_pl = (
pdf_pl.group_by("category")
.agg([
pl.col("value_1").mean().alias("mean_val"),
pl.col("amount").sum().alias("sum_amt"),
pl.col("value_2").count().alias("count"),
])
.sort("sum_amt", descending=True)
)
t_polars = time.perf_counter() - t0
print(f"Polars GroupBy + Agg: {t_polars:.2f}s")
speedup = t_pandas / t_polars
print(f"\n⚡ Polars 快了 {speedup:.1f} 倍")
print(f" 节省时间: {t_pandas - t_polars:.1f} 秒")
运行前先装库:
pip install pandas polars pyarrow numpy
把 NUM_ROWS 从 100 万调到 1000 万,看 speedup 倍数的变化——你会看到数据量越大,差距越悬殊。这就是我说的"物理极限"。
#Python #数据分析 #Polars #Pandas #数据工程 #性能优化 #AI数据管线