大多数 Python 数据工程师最早学的是 pandas。因为它是行业标准,能用而且一直够用,所以一般也没人质疑过它。
Pandas 设计于 2008 年,面向的是那个时代的数据问题:假设每个操作都要立即返回结果,假设单个 CPU 核心足够,假设数据能放进内存。这些假设成立了很多年。随着 Pipeline 规模增长,它们越来越站不住脚。
这里说的并不是 pandas 好不好,因为它很好而且我现在也在用。现在的问题是问题在于它是否还匹配你实际在跑的工作负载。
对于超过约 1 GB 的文件型 ETL 来说,并不是pandas 出了问题,而是整个生态已经为这类工作产出了一个结构上更优越的工具——Polars。两者之间的差距不是量级上的小打小闹。
Polars 为何在结构上更快
谈基准测试数据之前,先搞清楚原理。下图并排展示了两种执行模型。
Pandas 的单线程即时执行(eager execution)vs. Polars 的多线程惰性执行(lazy execution)。
Pandas 的执行模型
Pandas 运行在 NumPy 之上,大多数操作是单线程的。Python 的全局解释器锁(GIL)阻止了并行执行,即便有多个线程可用。每个操作都立即执行,所以无论是否需要中间结果都会被创建并存入内存。对一个 5 GB 的 DataFrame 做五步变换,就会产生五份中间副本,内存占用迅速叠加。
Pandas 默认将字符串存储为 NumPy object 数组:每个值都是一个 Python 对象指针,每个唯一字符串大约占 67 字节,而列式存储只需 4 字节的额外开销。100 万个 18 字符的字符串,在 pandas object dtype 下约占 75 MB,同样的列在 Arrow 格式下约占 22 MB。
Polars 的执行模型
Polars 用 Rust 编写,完全运行在 GIL 之外。数据以 Apache Arrow 列式格式存储:每列是一块连续的类型化内存,对 CPU 缓存友好,也支持 SIMD 指令。调用
scan_parquet
时,不会读取任何数据。Polars 先构建逻辑执行计划,在触碰任何字节之前完成优化:谓词下推到扫描层、未使用的列在读取前裁剪、Join 顺序重排以提升效率。
执行采用 morsel 驱动模式。每个 CPU 线程取一块输入,用自己独立的本地状态处理,最后合并结果,计算过程中没有锁竞争,所有核心同时运行。
处理一个 10 GB Parquet 文件时,pandas 读取全部 10 GB,在单核上顺序处理,并在途中产生中间副本。同样的 Pipeline 在 Polars 中,只读取满足过滤和聚合所需的列和行组,在所有核心上并行处理,不会实体化任何不必要的中间结果。每次给 Pipeline 计时,这种差距都能感受到:pandas 已经在读取时,Polars 还在规划,但 Polars 先完成。
基准测试
下表汇总了不同规模和负载类型下的结果。
三个独立基准测试,分别对比了查询 Pipeline、生产 ETL 迁移和调度负载场景下 Polars 与 pandas 的表现。
PDS-H 基准测试
Polars 团队于 2025 年 5 月发布了更新的 PDS-H 测试结果,运行环境为 AWS c7a.24xlarge 实例(96 vCPU、192 GB RAM)。测试对 10 GB CSV 数据集运行全部 22 条 TPC-H 衍生查询。Polars 流式处理耗时 3.89 秒,pandas 2.2.3(启用 PyArrow dtype)耗时 365.71 秒——整条多步分析 Pipeline 下来,差距达到 94 倍。
在 Scale Factor 100(100 GB)的场景下,pandas 被直接排除在外。单线程执行加上缺乏查询优化器,导致在完成基准测试之前就触发了内存溢出(OOM)。Polars 流式处理耗时 23.94 秒。
这是厂商自己跑的基准测试,可以把它当作方向性参考。在普通硬件上的独立测试差距通常小一些,单个操作一般在 5 到 22 倍之间。那个标题级别的倍数是查询优化器和多线程在完整 Pipeline 中叠加后才出现的。
生产环境的实证
荷兰出行服务商 Check Technologies 在一个 Sprint 内将全部 100 多个 Airflow DAG 从 pandas 迁移到了 Polars。驱动力不是性能基准,而是最数据密集的 Pipeline 上反复出现的 OOM 错误。迁移耗时不到两周:最问题严重的 DAG 速度提升了 3.3 倍,几乎所有其他 DAG 提升约 2 倍,云基础设施成本降低了 25%。Check 的高级数据工程师 Paul Duvenage 表示,团队一旦切换到声明式表达式 API,迁移过程就非常顺畅。
DB Systel(德国铁路子公司)用 Polars 0.20 重写了一个列车调度重处理任务。该任务原本需要 96 分钟,Polars 版本只需 5.5 分钟——在真实生产负载上提升了 17.5 倍,而不是在合成基准上。
在小数据上,差距基本消失有时甚至会反转。Polars 的查询优化器存在规划开销,当数据集小于几百 MB 时,这个开销会超过计算节省。对于小 DataFrame 上的快速脚本pandas 写起来更快,跑起来也够快。
安装与运行
安装只需一条命令:
# 创建项目并添加 Polars
uv init polars-pipeline
cd polars-pipeline
uv add polars pyarrow # 标准安装
uv run pipeline.py
无需编译,无需系统依赖。Polars 以预编译 wheel 形式发布Rust 运行时已打包在内。
⚠️ 开始之前有一点值得注意:在 Apple Silicon(M 系列 Mac)上,标准 polars wheel 会触发 CPU 兼容性警告,并建议使用运行时兼容版本。
# Apple Silicon:改用这个
uv add "polars[rtcompat]" pyarrow
Linux 和 Intel Mac 上使用标准安装即可。在 M 系列硬件上,
polars[rtcompat]
可避免警告,并确保使用适合该处理器的正确 SIMD 指令。
并排对比:两个库实现相同操作
第一次看 Polars 代码时,语法感觉很陌生。表达式 API 需要一天时间适应,适应之后,发现它比 pandas 的等价写法更易读,而不是更难读。下面的代码执行完全相同的 ETL 变换:读取 Parquet 文件、过滤行、按类别聚合、排序结果。先是 pandas 版本,然后是 Polars LazyFrame 版本。
# pandas 版本
importpandasaspd
importtime
start=time.perf_counter()
df=pd.read_parquet("sales.parquet")
result= (
df[df["revenue"] >1000]
.groupby("category")
.agg(
total_revenue=("revenue", "sum"),
avg_price=("price", "mean"),
order_count=("order_id", "count"),
)
.sort_values("total_revenue", ascending=False)
.reset_index()
)
print(f"pandas: {time.perf_counter() -start:.3f}s")
print(result)
# Polars 版本——带谓词下推和投影下推的惰性执行
importpolarsaspl
importtime
start=time.perf_counter()
result= (
pl.scan_parquet("sales.parquet") # 此时不读取任何数据
.filter(pl.col("revenue") >1000) # 下推到扫描层
.group_by("category")
.agg(
total_revenue=pl.col("revenue").sum(),
avg_price=pl.col("price").mean(),
order_count=pl.col("order_id").count(),
)
.sort("total_revenue", descending=True)
.collect() # 执行在这里发生
)
print(f"Polars: {time.perf_counter() -start:.3f}s")
print(result)
结构上的关键差异在于
scan_parquet
对比
read_parquet
。Pandas 版本立即读取整个文件;Polars 版本构建执行计划,只读取满足过滤和聚合所需的行和列。对于一个 1 GB、20 列、实际只需要 3 列的文件,Polars 读取的字节数可能不到 pandas 的 20%。
流式处理超出内存容量的数据
当数据集超过可用内存时,在
collect
中加入
engine="streaming"
。Polars 以称为 morsel 的批次处理数据,自适应地溢出到磁盘,始终不将完整数据集保留在内存中。
result= (
pl.scan_parquet("large_dataset/*.parquet")
.filter(pl.col("status") =="active")
.group_by("region")
.agg(pl.col("amount").sum())
.sort("amount", descending=True)
.collect(engine="streaming") # 核外执行
)
如需直接写入磁盘而完全不在内存中实体化,使用
sink_parquet
:
(
pl.scan_parquet("raw/*.parquet")
.filter(pl.col("error_code").is_null())
.sink_parquet("clean/output.parquet") # 分批流式写入磁盘
)
生产环境注意事项
在生产环境中显式设置线程数,以避免与其他进程争抢资源:
importos
os.environ["POLARS_MAX_THREADS"] ="8" # 必须在导入 polars 之前设置
importpolarsaspl # Polars 在导入时初始化线程池——
# 导入后再设置此变量无效
在
pyproject.toml
中锁定 Polars 版本。1.x API 已稳定,但小版本更新会引入新特性,在边缘情况下可能改变行为。锁定版本,在与生产 OS 一致的容器中测试,有意识地升级。
Pandas 仍然占优的场景
Polars 是 1 GB 以上文件型 ETL 的更好选择,下图按数据规模和负载类型梳理了决策框架。
按数据规模和负载类型选择工具,并明确标注了机器学习生态系统的约束。
大多数机器学习库以 pandas DataFrame 作为原生输入格式。scikit-learn、statsmodels 以及很多绘图库,要么明确要求 pandas,要么默认如此。为了避免一次
.to_pandas()
调用而重写整个技术栈,这笔买卖不值得做。
在每一个需要给模型喂数据的 Pipeline 中,实践中的做法是混合方案:用 Polars 做繁重的计算工作,最后一步再转换:
# 用 Polars 做重活
features= (
pl.scan_parquet("events/*.parquet")
.group_by("user_id")
.agg([
pl.col("session_duration").mean().alias("avg_session"),
pl.col("purchase").sum().alias("total_purchases"),
pl.col("event_date").max().alias("last_seen"),
])
.collect(engine="streaming")
)
# 在边界处零拷贝转换
importsklearn
X=features.to_pandas() # 基于 Arrow,几乎瞬间完成
转换几乎瞬间完成,因为 Polars 和新版 pandas 共享 Apache Arrow 内存格式——当两边都使用 Arrow 类型时,不会发生数据拷贝。
Pandas 3.0 带来了什么变化
2026 年 1 月发布的 pandas 3.0,将 PyArrow 支持的字符串设为字符串列的默认类型,并将写时复制(Copy-on-Write)设为唯一执行模式。这些改动使字符串密集型数据集的内存占用降低了最多 70%,并消除了一类静默的变异 bug。
这是真实的改进。它在 I/O 和内存方面实质性地缩小了差距。但多核执行的差距依然存在——pandas 的聚合操作在任何版本中仍然是单线程、即时执行的,也没有查询优化器。如果瓶颈是计算而非内存,3.0 版本并没有改变这一判断。
总结
有三个信号说明一条 Pipeline 已经到了该迁移的时候:
第一:在生产规模的数据上触发 OOM,或者已经为了绕过内存限制加入了分块逻辑。第二:本该几秒完成的任务跑了好几分钟,性能分析显示 pandas 的 groupby 或 join 操作占据了主要运行时间。第三:运行在一台多核机器上,执行期间那些 CPU 核心基本闲着。
迁移的时候可以选最慢的那条Pipeline或者最常 OOM 的那条。只用 Polars LazyFrame 重写计算密集的部分,在输出端保留 pandas 边界以衔接机器学习或绘图,然后在生产规模的数据上基准测试,再推上线。如果在超过 1 GB 的数据集上看不到至少 3 倍的提升瓶颈可能根本不在 DataFrame 库上。
Pandas 不是遗留技术,它是精准的专用工具;Polars 是另一种工作的精准专用工具。为每项工作选择合适的,不是迁移项目,是工程判断力。
https://avoid.overfit.cn/post/da28f678d72c42729ccef7af469c1731
作者:Nevenka Lukic