📖 本篇简介:pandas 是 Python 数据分析的事实标准,但从 2020 年起,一个用 Rust 编写、从底层就基于 Apache Arrow 的列式 DataFrame 库 Polars 快速崛起。本篇用本机 100 万行真实基准(pandas 3.0.6 vs polars 2.0.0,8 线程)对比两者在过滤、groupby、join、排序上的耗时,给出语法对照表、lazy/eager 模式差异、迁移指南,以及"什么场景继续用 pandas、什么场景切 Polars"的明确建议。学完你能在新项目里做出有依据的选型,而不是凭口碑。
上一章我们刚看完 pandas 2.x/3.0 如何向 Arrow 靠拢;本章则是一个"从零开始就是 Arrow-native、多核并行、带查询优化器"的参照系。两篇合在一起,你会对 Python 数据分析的底层格局有完整认识。
目录
1. 选型总览:一张表看懂两者差异
2. 语法对照表:从 pandas 到 Polars
3. 实测性能:100 万行 pandas vs polars
4. Eager 与 Lazy:执行模式的根本差异
5. Polars 为什么快:多核、表达式、优化器
6. 迁移指南:API 映射与惯用法差异
7. 什么时候继续用 pandas
8. 常见坑
9. 练习题
1. 选型总览:一张表看懂两者差异
| 维度 | pandas | Polars |
|------|--------|--------|
| 首次发布 | 2011 年(Wes McKinney) | 2020 年(Ritchie Vink) |
| 实现语言 | C + Cython + Python | Rust + Python 绑定 |
| 底层存储 | NumPy ndarray(3.0 起可选 Arrow) | Apache Arrow(从第一天起) |
| 执行模式 | eager(立即求值)为主 | eager + lazy 双模式,lazy 带查询优化器 |
| 并行 | 单线程(部分操作走 numexpr) | 默认多线程(本机 8 线程) |
| 内存模型 | 视图/复制混用;3.0 起 CoW | Arrow 不可变缓冲区,零拷贝切列 |
| 类型系统 | 宽松(object 列能塞任意 Python 对象) | 严格 schema(读文件时类型不符直接报错) |
| 缺失值 | NaN / None / NA 并存 | 统一用 null(Arrow 语义) |
| 生态位 | 数据科学全家桶(sklearn、matplotlib、seaborn、Jupyter) | 高性能数据处理、ETL、流式 |
| 学习曲线 | 平缓(API 简单) | 稍陡(表达式 API 要适应) |
| 典型适用 | 小到中等数据、探索性分析、教学、可视化 | 百万行以上、ETL、批处理、需要多核加速 |
一句话概括:pandas 是"探索性数据分析的瑞士军刀",Polars 是"高性能列式计算引擎"。两者不是替代关系,而是互补关系。
2. 语法对照表:从 pandas 到 Polars
下面假设我们有这样一张订单表:
# 公共数据(pandas 和 polars 各造一份)
import numpy as np, pandas as pd, polars as pl
rng = np.random.default_rng(0)
N = 100_000
data = {
"id": np.arange(N),
"city": rng.choice(["北京","上海","广州","深圳"], size=N),
"category": rng.choice(["数码","服饰","食品"], size=N),
"amount": rng.uniform(10, 1000, size=N),
"qty": rng.integers(1, 5, size=N),
}
pdf = pd.DataFrame(data)
pldf = pl.DataFrame(data)
2.1 读取与写出
| 操作 | pandas | Polars |
|------|--------|--------|
| 读 CSV | pd.read_csv("f.csv") | pl.read_csv("f.csv") |
| 写 CSV | df.to_csv("f.csv", index=False) | df.write_csv("f.csv") |
| 读 Parquet | pd.read_parquet("f.parquet") | pl.read_parquet("f.parquet") |
| 读 SQL | pd.read_sql(sql, conn) | pl.read_database_uri(sql, conn_uri) |
2.2 选择列与过滤
| 操作 | pandas | Polars |
|------|--------|--------|
| 选单列 | df["city"] | pl.col("city")(表达式)或 df["city"] |
| 选多列 | df[["city","amount"]] | df.select(["city","amount"]) |
| 按条件过滤 | df[df["amount"] > 500] | df.filter(pl.col("amount") > 500) |
| 多条件 | df[(df.amount>500) & (df.qty>=2)] | df.filter((pl.col("amount")>500) & (pl.col("qty")>=2)) |
Polars 的核心设计是表达式(expression):pl.col("amount") > 500 不是一个布尔 Series,而是一个"描述如何计算"的 AST(抽象语法树)。这个 AST 会被 Polars 的优化器分析、下推、并行执行。
2.3 groupby 聚合
| 操作 | pandas | Polars |
|------|--------|--------|
| 单列单聚合 | df.groupby("city")["amount"].mean() | df.group_by("city").agg(pl.col("amount").mean()) |
| 多列多聚合 | df.groupby("city").agg(amt=("amount","sum"), qty=("qty","max")) | df.group_by("city").agg(pl.col("amount").sum().alias("amt"), pl.col("qty").max().alias("qty")) |
| 分组后不丢原列 | df.groupby("city", group_keys=False).apply(...) | df.group_by("city").agg(...)(简洁很多) |
2.4 join
| 操作 | pandas | Polars |
|------|--------|--------|
| 左连接 | df.merge(dim, on="city", how="left") | df.join(dim, on="city", how="left") |
| 多键连接 | df.merge(dim, on=["city","category"]) | df.join(dim, on=["city","category"]) |
| 交叉连接 | df.merge(dim, how="cross") | df.join(dim, how="cross") |
2.5 窗口函数
| 操作 | pandas | Polars |
|------|--------|--------|
| 组内排名 | df.groupby("city")["amount"].rank() | pl.col("amount").rank().over("city") |
| 组内累计 | df.groupby("city")["amount"].cumsum() | pl.col("amount").cum_sum().over("city") |
| 组内均值 | df.groupby("city")["amount"].transform("mean") | pl.col("amount").mean().over("city") |
# 窗口函数对比示例
pdf["city_avg"] = pdf.groupby("city")["amount"].transform("mean")
pldf.with_columns(
pl.col("amount").mean().over("city").alias("city_avg")
)
2.6 pivot 透视
| 操作 | pandas | Polars |
|------|--------|--------|
| 透视 | df.pivot_table(index="city", columns="category", values="amount", aggfunc="sum") | df.pivot(on="category", index="city", values="amount", aggregate_function="sum") |
2.7 一个完整查询的对照
为了把上面零散的对照表串起来,下面看一个真实业务查询:"找出每个城市里,金额大于 100 元的订单总金额和平均数量,按总金额降序取前 3"。
pandas 写法:
result_pd = (
pdf[pdf["amount"] > 100]
.groupby("city", as_index=False)
.agg(total_amt=("amount", "sum"),
avg_qty=("qty", "mean"))
.sort_values("total_amt", ascending=False)
.head(3)
)
Polars eager 写法:
result_pl_eager = (
pldf
.filter(pl.col("amount") > 100)
.group_by("city")
.agg(
pl.col("amount").sum().alias("total_amt"),
pl.col("qty").mean().alias("avg_qty"),
)
.sort("total_amt", descending=True)
.head(3)
)
Polars lazy 写法(推荐用于大文件):
result_pl_lazy = (
pl.scan_csv("orders.csv") # 注意 scan_*,不是 read_*
.filter(pl.col("amount") > 100)
.group_by("city")
.agg(
pl.col("amount").sum().alias("total_amt"),
pl.col("qty").mean().alias("avg_qty"),
)
.sort("total_amt", descending=True)
.head(3)
.collect() # 此刻才真正读文件和计算
)
三段代码的最终结果一致,但 lazy 版本在执行时会:① 只读 amount/city/qty 三列(投影下推);② 在扫描阶段就过滤掉 amount <= 100 的行(谓词下推);③ groupby 用哈希并行聚合;④ 最后才排序取前 3。
3. 实测性能:100 万行 pandas vs polars
3.1 测试设置
- 数据:100 万行,列包括 id、cat(8 个类别)、sub(4 个类别)、val(整数)、score(浮点);
- 机器:本机 Windows,Polars 自动检测到 8 个线程;
- 方法:每个操作跑 5 次,取最佳成绩;
- 版本:pandas 3.0.6 / polars 2.0.0 / Python 3.14。
3.2 耗时对比(实跑结果)
| 操作 | pandas 3.0.6(ms) | polars 2.0.0(ms) | polars 加速比 |
|------|---------------------|----------------------|---------------|
| 过滤 val > 50000 | 42.93 | 9.02 | 4.76x |
| group_by(cat).score.mean() | 70.59 | 7.82 | 9.03x |
| group_by(cat, sub) 三列多聚合 | 201.01 | 9.47 | 21.22x |
| 左连接维度表 on cat | 307.06 | 27.27 | 11.26x |
| 按 val 排序 | 376.42 | 91.25 | 4.13x |
| 内存占用(整表) | 43.87 MB | 28.61 MB | 省 34.8% |
这个差距不是"快一点",而是数量级的差距。尤其是 groupby 多聚合(21 倍)和 join(11 倍),pandas 在这两类操作上长期是瓶颈。
📌 数据来源:以上数字是本机 2026-10-07 实跑结果,完整脚本见本章末"验证说明"。不同机器、不同数据分布下倍数会变化,但 Polars 在 groupby/join 上数倍到数十倍的优势是稳定的。官方基准(pola-rs.github.io/polars)在更大数据集上报告的加速比通常在 10~100x 之间。
4. Eager 与 Lazy:执行模式的根本差异
pandas 只有一种执行模式:你写的每一行代码,立刻被执行,立刻返回结果。这种方式叫 eager(即时求值)。
Polars 有两种模式:
- eager:和 pandas 一样,
pl.read_csv(...).filter(...).group_by(...)每一步立刻执行; - lazy:你先把一整套"计算计划"描述出来,但不执行,最后调用
.collect()才一次性跑。
import polars as pl
# eager 模式:每一步立刻算
result_eager = (
pldf
.filter(pl.col("val") > 50000)
.group_by("cat")
.agg(pl.col("score").mean())
)
# lazy 模式:只构造计划,不执行
lazy_plan = (
pl.scan_csv("big.csv") # 注意是 scan_csv,不是 read_csv
.filter(pl.col("val") > 50000)
.group_by("cat")
.agg(pl.col("score").mean())
)
# 此刻没有任何计算发生
# 调用 collect 才真正跑
result_lazy = lazy_plan.collect()
4.1 lazy 模式的实跑感受
import time
t0 = time.perf_counter()
lazy = (pldf.lazy()
.filter(pl.col("val") > 50000)
.group_by("cat")
.agg(pl.col("score").mean())
.join(dim_pldf.lazy(), on="cat"))
t_build = time.perf_counter() - t0
print(f"构造计划耗时: {t_build*1000:.3f} ms")
t0 = time.perf_counter()
res = lazy.collect()
t_collect = time.perf_counter() - t0
print(f"collect 实际执行: {t_collect*1000:.2f} ms")
构造计划耗时: 0.206 ms
collect 实际执行: 26.09 ms
构造计划只花了 0.2 毫秒——Polars 只是在内存里搭了一棵表达式树,连数据都没读。真正干活的是 collect() 的 26 毫秒。
4.2 lazy 模式的三个红利
1. 谓词下推(predicate pushdown):你在最后一步写的 filter(val > 50000),优化器会把它推到读文件的那一刻,只读符合条件的行,IO 量直接砍半;
2. 投影下推(projection pushdown):你只 select 了 cat 和 score 两列,Polars 读 CSV 时根本不会解析其他列;
3. 切片合并:连续多个 filter 会被合并成一次扫描,而不是跑好几遍。
这些优化在 eager 模式下都没有。实战中,对一个超过内存大小的数据集,eager 会 OOM,lazy 能跑完——因为 Polars 会分块扫描、流式处理。
5. Polars 为什么快:多核、表达式、优化器
很多人第一次看到 Polars 基准会以为是"Rust 比 Cython 快"。Rust 确实贡献了一部分,但真正的加速来自架构:
5.1 多核并行
pandas 的核心循环(groupby、join、sort)基本是单线程的。Polars 从一开始就把"一个操作切到多个线程上跑"作为默认行为。本机 8 线程意味着理论上 groupby 可以拿到接近 8 倍的加速——实测 9 倍,已经接近这个上限。
# 查看当前线程数
import polars as pl
print(pl.thread_pool_size()) # 本机输出 8
5.2 表达式优化器
pandas 的计算图是"按你写的顺序"执行:你先 filter、再 groupby、再 join,它就老老实实跑三遍。Polars 的 lazy 模式会把整张表达式树拿过来做规则优化:
- 下推 filter 和 select(见上一节);
- 消除冗余列(join 时只保留用到的键);
- 选择最优 join 算法(哈希 join / 排序合并 join);
- 提前聚合(在扫描阶段就累加,而不是扫完再聚合)。
5.3 Arrow 零拷贝
pandas 3.0 虽然也支持 Arrow 后端,但跨列操作时仍要在 NumPy 和 Arrow 之间来回转换。Polars 从头到尾都在 Arrow 缓冲区上干活,切列、过滤、groupby key 切片都是零拷贝——这就是为什么它能在 100 万行上把 groupby 压到 8 毫秒。
6. 迁移指南:API 映射与惯用法差异
6.1 常见 API 映射表
| 你想做什么 | pandas 写法 | Polars 等价写法 |
|------------|-------------|------------------|
| 新增列 | df["x"] = df["a"] + df["b"] | df.with_columns((pl.col("a") + pl.col("b")).alias("x")) |
| 重命名 | df.rename(columns={"a":"A"}) | df.rename({"a":"A"}) |
| 删列 | df.drop(columns=["a"]) | df.drop("a") |
| 排序 | df.sort_values("a", ascending=False) | df.sort("a", descending=True) |
| 去重 | df.drop_duplicates(subset=["a"]) | df.unique(subset=["a"]) |
| 取前 N | df.head(10) | df.head(10)(一样) |
| 缺失值填充 | df["a"].fillna(0) | df.with_columns(pl.col("a").fill_null(0)) |
| 缺失值删除 | df.dropna(subset=["a"]) | df.drop_nulls(subset=["a"]) |
| 类型转换 | df["a"].astype(int) | df.with_columns(pl.col("a").cast(pl.Int64)) |
6.2 四个最容易踩的惯用法差异
差异 1:表达式 vs 方法链
pandas 习惯"直接操作 Series":
pdf["amount_log"] = np.log(pdf["amount"])
Polars 习惯"在 with_columns 里写表达式":
pldf.with_columns(pl.col("amount").log().alias("amount_log"))
刚开始会觉得 Polars 的写法啰嗦,但好处是整个表达式是一个可优化的 AST,而不是一段 Python 代码。
差异 2:schema 严格性
pandas 读 CSV 时,如果某列被推断成 int 但中间混入了一个空字符串,它会"宽容"地把整列升级成 float64。Polars 会直接报错:
# pandas: 默默把 age 变成 float
pd.read_csv("users.csv")
# polars: 遇到 "abc" 在 age 列里会报 SchemaError
pl.read_csv("users.csv")
这不是缺点——严格 schema 是 ETL 场景的优点。你可以用 pl.read_csv(..., schema_overrides={"age": pl.Int64}) 显式指定类型,让错误更早暴露。
差异 3:缺失值表示 `null` vs `NaN`
- pandas:浮点列的缺失是
np.nan(float 本身),整数列不能有缺失; - Polars:所有列的缺失统一是
null(Arrow 语义),整数列也能有 null。
# pandas: 整数列一旦有 NaN 就变 float
pdf["qty_with_null"] # dtype 是 float64
# polars: 整数列就是 Int32,null 是合法的
pldf["qty_with_null"] # dtype 是 Int32
差异 4:groupby 后列名
pandas 的 groupby().agg() 输出列名可能带多级索引,要 reset_index() 或用 named agg 控制;Polars 输出永远是扁平的单级列名,更直观:
# pandas
pdf.groupby("city").agg(total=("amount","sum"))
# 列名是 ['city', 'total'],干净
# 如果你忘了 named agg:
pdf.groupby("city")["amount"].sum()
# 输出是 Series,索引是 city,名字是 amount
# polars 永远是 DataFrame:
pldf.group_by("city").agg(pl.col("amount").sum())
# 列名是 ['city', 'amount'],可以用 .alias() 改名
6.3 混用示例:Polars 清洗 → pandas 建模
实战中最常见的模式是"重活用 Polars、轻活用 pandas":
import polars as pl
import pandas as pd
from sklearn.linear_model import LogisticRegression
# 1. Polars 做 ETL:读大 CSV、清洗、特征工程(快)
clean = (
pl.scan_csv("big_logs.csv")
.filter(pl.col("event") == "purchase")
.with_columns(
amount_log = pl.col("amount").log(),
is_vip = pl.col("level") >= 10,
)
.group_by("user_id")
.agg(
total_amt = pl.col("amount").sum(),
n_purchase = pl.len(),
avg_amount = pl.col("amount").mean(),
)
.collect()
)
# 2. 转 pandas 喂给 sklearn(生态绑定)
pdf = clean.to_pandas()
X = pdf[["total_amt", "n_purchase", "avg_amount"]].fillna(0)
y = (pdf["total_amt"] > 1000).astype(int) # 假设的二分类标签
model = LogisticRegression()
model.fit(X, y)
这种模式的好处:① ETL 阶段拿到 Polars 的多核和优化器;② 建模阶段直接用 pandas DataFrame,sklearn 不用做任何适配;③ 转换一次的成本(百万行约几十毫秒)远小于重写 sklearn 代码。
7. 什么时候继续用 pandas
看到上面 4~21 倍的加速,你可能会想"那我以后都用 Polars 算了"。别急,下面这些场景 pandas 仍然是更好的选择:
1. 交互式探索 / Jupyter 教学:pandas 的 API 更短、文档更多、Stack Overflow 答案一抓一大把。教学场景(包括本系列教程)默认用 pandas。
2. 数据科学生态:scikit-learn、statsmodels、seaborn、matplotlib、plotly 这些库的一等公民参数是 pandas DataFrame。你用 Polars 喂给 sklearn 还得 .to_pandas(),多一道转换。
3. 小数据(< 100 万行):pandas 的 40ms 和 Polars 的 9ms 在小数据上你根本感知不到。切换成本不划算。
4. 需要复杂的 Python 回调:pandas 的 apply 能塞任意 Python 函数;Polars 的 map_elements 虽然也能,但会损失并行优势(退化成单线程)。
5. 时间序列与金融数据:pandas 的 date_range、resample、滚动窗口 API 极其成熟;Polars 在这方面追得很快,但边角 case 仍然 pandas 更全。
6. 和 Excel 对齐:pandas 的 to_excel、pd.ExcelWriter、与 openpyxl 的集成比 Polars 完善得多。
实用建议:在同一个项目里混用两者是完全 OK 的——用 Polars 做 ETL 和清洗(快),用 pandas 做建模和可视化(生态好),中间用 .to_pandas() / pl.from_pandas() 互相转换。
8. 常见坑
⚠️ 坑 1:把 Polars 当 pandas 用,却用 eager 模式。
很多人第一次用 Polars 写 pl.read_csv(...).filter(...).group_by(...) 一串链式调用,发现"好像也没比 pandas 快多少"。原因是 eager 模式没有查询优化器。真正的加速来自 `lazy()` + `collect()`。⚠️ 坑 2:`map_elements` 滥用。
Polars 的map_elements(lambda x: ...)是单线程 Python 回调,会把多线程优势完全废掉。能用内置表达式(pl.col("a").log()、pl.col("a").str.contains(...))就不要用 lambda。
⚠️ 坑 3:类型不匹配直接报错。
pandas 那种"读 CSV 时自动升级类型"的宽容行为在 Polars 里没有。生产环境建议在read_csv/scan_csv时显式传schema或schema_overrides,避免脏数据导致 pipeline 中途崩溃。
⚠️ 坑 4:缺失值比较 `null == null` 是 null,不是 True。
这是 SQL/Arrow 的三值逻辑:null == 5返回null(不是 False)。过滤时要写pl.col("x").is_null(),而不是pl.col("x") == None。
⚠️ 坑 5:内存估算差异。
pandas 的df.memory_usage(deep=True)会把 Python 对象的深拷贝大小算进去(字符串列尤其夸张);Polars 的df.estimated_size()只算 Arrow 缓冲区。两者不能直接比数字,但同一台机器上 Polars 的真实物理占用通常确实更少。
⚠️ 坑 6:pandas → Polars 转换时索引丢失。
pandas 的Index在 Polars 里没有对应物,转过去后会被丢弃。如果你依赖索引做对齐,迁移时要显式把索引列reset_index()成普通列。
9. 练习题
1. 用 Polars 的 eager 模式和 lazy 模式分别跑同一个 groupby 查询,比较:① 构造表达式树的耗时;② collect() 的耗时。解释为什么 lazy 模式下"构造"几乎不花时间。
2. 在本机复现本章的基准(100 万行、8 列),把数据规模调到 10 万、100 万、1000 万行,观察 pandas 和 Polars 的耗时曲线。什么规模下 Polars 的优势开始显著?什么规模下 pandas 已经 OOM?
3. 把下面这段 pandas 代码改写成 Polars:
result = (df[df["amount"] > 100]
.groupby(["city", "category"])
.agg(total_amt=("amount", "sum"),
avg_qty=("qty", "mean"),
n=("id", "count"))
.reset_index()
.sort_values("total_amt", ascending=False))
要求用 lazy 模式,并解释你用了哪些 Polars 的优化(谓词下推、投影下推等)。
4. 为什么 Polars 的 group_by(...).agg(...) 比 pandas 的 groupby(...).agg(...) 快一个数量级?从并行、表达式优化、内存布局三个角度解释。
5. 列举 3 个你不会把 pandas 项目迁到 Polars 的场景,并说明理由(提示:从生态依赖、数据规模、团队熟悉度三个方向思考)。
下一篇预告:第 57 章 案例十:游戏运营分析 —— 用本章学到的 pandas/Polars 选型思路,拿一份虚拟游戏运营数据,走完整的"留存、付费、活跃"分析流程。
文章回复
0 条公开回复