数据STUDIO

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

Image

TL;DR

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

先看一组数字。

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

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

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

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

Image

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

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

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

01当 Pandas 撞上数据墙

Image

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

你写了个 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)。

Image

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

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):你的查询只用了 amount 和 category 两列,优化器告诉读取层"其他 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 内存布局是物理基础

Image

优化器解决了"怎么算最快"的问题。但真正让计算跑起来的物理层,是 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 核心上同时算。

Image

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 + 1df.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")
取前 N 行
df.head(10)df.head(10)
Pandas ↔ Polars
—
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 pd

df_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 生态随便用

Image

这不是"二选一"。这是Polars 当发动机,Pandas 当方向盘。


05当内存不够时:Streaming 引擎

说了这么多,还有一个场景没覆盖——数据比内存大。

Image

你的 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。

Image

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 pl

logs = (
    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() 心智切换。一个下午的事。

迁移不需要一步到位。最快路线:

  1. 今天:把最大的 Pandas 脚本里的 pd.read_csv() 换成 pl.scan_csv(),最后加个 .collect().to_pandas()。I/O 直接快 3 倍,后面代码一行不动。
  2. 这周:挑一个最慢的 GroupBy / Join 操作,用 Polars 表达式重写。对照上面的 20 个语法表,边写边查。
  3. 下个月:重构整个 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 pl

NUM_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数据管线


Image