RSS
菜单
全部文章快讯开发科技深度热点

第 56 章 Polars 与 pandas 对比选型

内容摘要

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 数据分析的底层格局有完整认识。

📖 本篇简介: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 选型思路,拿一份虚拟游戏运营数据,走完整的"留存、付费、活跃"分析流程。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 56 章 Polars 与 pandas 对比选型》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。