列式内存 · Arrow 规范 · 惰性求值 · 查询优化 —— 一条从“为什么快”到“该不该切”的完整决策链
摘要
“Polars 比 pandas 快 8 倍”这类说法在社区里流传甚广,但很少有人说清楚:快在哪一步、对什么数据形态快、以及快出来的这部分是否值得你付出迁移成本。本文不打算复述官方 benchmark 的结论,而是把性能差异拆回到三个可验证的层面——内存布局(列式 vs 块式)、执行模型(惰性查询计划 vs 即时求值)、以及字符串/缺失值/并发的工程实现,再据此提出一套“四问决策框架”,用于判断一个具体项目到底该不该从 pandas 切到 Polars。
全文包含可复现的基准测试方法、迁移路径的代码级对照、以及 Polars 当前仍不适用的边界条件。所有性能数字均标注来源或明确标注为模拟/整合数据,不虚构实验。
目录
一、问题的起点:8 倍到底快在哪里
先说结论:“8 倍”不是一个物理常数,而是一个高度依赖数据形态、操作类型和硬件配置的观测值。在 Polars 官方发布的 benchmark 中(Polars 官方文档 “Benchmarks” 页面,2024 年更新),在 group-by、join、字符串处理等典型 ETL 操作上,Polars 相对 pandas 的加速比从 2 倍到 20 倍以上不等;而在简单的行遍历或小数据集上,两者差距可能缩小到 1.5 倍以内,甚至因为启动开销而互有胜负。
这意味着,任何脱离具体 workload 谈“快几倍”的说法都是不严谨的。本文的分析主线因此确定为:把“快”拆解为可归因的三个层次——内存布局、执行模型、工程实现,然后在此基础上回答“该不该切”。
本文评述:社区里大量“Polars 快 N 倍”的帖子,问题不在于数字错,而在于把“特定条件下的观测”当成了“普遍规律”。一个负责任的性能讨论,必须同时给出数据规模、列类型分布、操作类型和硬件环境,否则这个数字对读者的决策毫无价值。
在展开三个层次之前,先建立一个共同的参照系。pandas 诞生于 2008 年前后(McKinney, W., “pandas: a foundational Python library for data analysis and statistics”, 2011),其核心设计目标是为金融时间序列和表格数据分析提供灵活、易用的内存数据结构,性能从来不是第一优先级。Polars 则诞生于 2020 年前后(Polars 项目始于 Ritchie Vink,2020 年开源),设计目标从一开始就是面向大规模数据的、可并行、可惰性优化的查询引擎。两者目标函数不同,性能差异是设计选择的必然结果,而不是“谁写得更用心”。
理解了这一点,后面的所有技术细节就都能归位:pandas 的很多“慢”,是它为灵活性付出的代价;Polars 的很多“快”,是它用更严格的类型系统和更受限的 API 换来的。
二、第一层:内存布局——列式与 Arrow 规范
2.1 pandas 的块管理器(BlockManager)
pandas 的 DataFrame 底层由 BlockManager 管理。同质(dtype 相同)的列会被合并成二维的 NumPy 数组块,异构列则分属不同块。这个设计在“按列操作”时表现尚可,但在以下场景会暴露问题:
- 插入或删除一列时,可能触发块的重新组织(consolidation),代价随列数增长;
- 混合 dtype(尤其是 object 列)时,块之间无法共享内存布局,缓存局部性差;
- 缺失值处理依赖 NumPy 的 NaN 语义,对整数列会强制升格为 float,造成精度与内存双重损失。
更关键的是,pandas 的字符串默认以 Python object 存储(即 PyObject 指针数组),每个字符串都是独立的堆对象。这在字符串列上带来的内存开销和指针追逐(pointer chasing)开销,是 ETL 场景中最常见的性能瓶颈之一。
2.2 Polars 的列式 + Arrow 内存
Polars 的 Series 底层是 Apache Arrow 规范定义的列式数组(Apache Arrow 项目,2016 年至今,arrow.apache.org)。Arrow 的核心思想是:用一套与语言无关的内存布局描述数据,使得零拷贝(zero-copy)跨系统传递成为可能。具体到实现上:
本文评述:Arrow 的 validity bitmap 设计,是 Polars 在“含缺失值的整数列”上显著优于 pandas 的根本原因。pandas 把缺失整数升格为 float64,不仅浪费一倍内存,还引入了浮点精度问题(大整数无法精确表示)。这个细节在真实 ETL 中极其常见——用户 ID、订单号、时间戳这类字段经常有缺失,而它们本应是整数。
2.3 缓存局部性:为什么列式对 ETL 更友好
现代 CPU 的内存墙问题(memory wall)意味着,数据访问模式对性能的影响,往往超过指令本身的优化。列式布局在聚合、过滤、分组这类 ETL 核心操作上,能让 CPU 以连续的方式读取同一列的数据,最大化缓存命中率;而 pandas 在混合 dtype 场景下,跨列操作会频繁跳转内存地址。
这一层的性能差异,是“结构性”的,不依赖具体实现细节,因此也最稳定、最可预期。这也是为什么即使用户把 pandas 换成同样基于 Arrow 的 PyArrow compute 后端,也能获得可观加速——问题不在 pandas 的代码质量,而在它的历史内存模型。
三、第二层:执行模型——惰性求值与查询优化
3.1 pandas 的即时求值(Eager Evaluation)
pandas 的每一次操作都立即执行并返回结果。这意味着:
# pandas:每一步都产生一个中间 DataFrame
df = pd.read_csv("large.csv") # 中间对象 1
df = df[df["status"] == "active"] # 中间对象 2
df = df.groupby("user_id").agg(...) # 中间对象 3
df = df.sort_values("amount") # 中间对象 4
四个中间对象意味着四次完整的内存分配与拷贝。在数据量大时,这不仅是时间开销,更是内存峰值的主要来源——很多 pandas OOM(内存溢出)问题,根源就是中间对象的累积。
3.2 Polars 的惰性求值与查询计划
Polars 提供 LazyFrame,操作被记录为查询计划,直到调用 .collect() 才真正执行。这带来两个关键能力:
- 谓词下推(predicate pushdown):过滤条件被尽可能推到数据读取阶段,减少后续处理的数据量;
- 投影下推(projection pushdown):只读取用到的列,对 Parquet 等列式存储尤其有效。
# Polars:构建查询计划,最后一次性执行
lf = (
pl.scan_parquet("large.parquet") # 惰性读取
.filter(pl.col("status") == "active")
.group_by("user_id")
.agg(pl.col("amount").sum())
.sort("amount")
)
df = lf.collect() # 此处才执行,且已优化
本文评述:惰性求值的价值,不在于“延迟”本身,而在于它给了引擎一个全局视角去重排、合并、消除操作。这正是数据库查询优化器几十年积累的思路(参考 Selinger 等人 1979 年提出的 System R 优化器经典论文),Polars 把这一套搬到了 DataFrame 层。相比之下,pandas 的即时求值让优化只能发生在单个操作内部,无法跨操作。
3.3 并行执行
Polars 默认使用多线程执行查询计划,基于 Rust 的 Rayon 工作窃取(work-stealing)调度器。pandas 本身是单线程的(除少数操作),并行通常需要借助 Dask、Modin 等外部框架,而这些框架又引入了额外的调度开销和 API 兼容性约束。
需要强调的是,并行不是免费的午餐。在数据量小、操作简单时,线程调度开销可能反而拖慢速度。这也是为什么 Polars 在小数据集上的优势不如大数据集明显——并行收益需要足够的计算量来摊薄调度成本。
四、第三层:工程细节——字符串、缺失值、并发
4.1 字符串处理
字符串是 ETL 中最常见也最容易被忽视的性能陷阱。pandas 的 object 列在字符串操作(如 .str.contains())时,需要逐个 Python 对象调用方法,GIL(全局解释器锁)进一步限制了并行。Polars 的 Utf8 类型基于 Arrow 的连续 buffer,字符串操作可以在 Rust 层并行执行,且无需 Python 对象开销。
在 Polars 官方 benchmark 中,字符串操作是加速比最高的类别之一。这一点对日志解析、文本清洗类 ETL 尤为重要。
4.2 缺失值语义
Arrow 的 validity bitmap 让缺失值成为独立于数据的一层信息,这使得:
- 整数列可以保留整型 dtype 同时含缺失;
- 缺失判断是位运算,比 NaN 的浮点比较更快;
- 缺失与 NaN 语义分离,避免了 pandas 中“NaN 既是缺失又是浮点值”的歧义。
本文评述:这个设计差异看似微小,但在数据质量要求高的场景(如金融、医疗)中,语义清晰本身就是价值。pandas 中“一个整数列因为有一个缺失值就变成浮点列”的行为,长期被用户诟病,Polars 从内存模型层面解决了它。
4.3 并发与 GIL
Python 的 GIL 使得纯 Python 层的多线程无法真正并行。Polars 把核心计算放在 Rust 层,绕开了 GIL,因此能充分利用多核。这是 Polars 在多核机器上优势明显、在单核或受限容器中优势缩小的直接原因。
五、实测:如何自己复现一组可信的基准
与其相信别人的数字,不如自己测。下面给出一套可复现的基准测试方法。需要说明:本文不虚构任何实验数据,读者应按自己的硬件和数据运行;下文表格中的数字为“模拟/整合数据”,仅用于说明量级关系,不代表任何具体机器上的真实结果。
5.1 测试数据构造
import numpy as np
import pandas as pd
import polars as pl
rng = np.random.default_rng(42)
N = 10_000_000
data = {
"user_id": rng.integers(0, 100_000, N),
"amount": rng.random(N) * 1000,
"status": rng.choice(["active", "inactive", "pending"], N),
"ts": rng.integers(1_600_000_000, 1_700_000_000, N),
}
pdf = pd.DataFrame(data)
pldf = pl.DataFrame(data)
数据预处理说明:上述为模拟数据,由 NumPy 随机数生成器(seed=42)生成,包含 1000 万行、4 列,涵盖整数、浮点、字符串、时间戳四种典型类型。真实场景中应替换为业务数据,并注意字符串列的基数(cardinality)会显著影响结果。
5.2 测试操作
本文评述:这张表里的数字是“整合数据”,即综合多个公开 benchmark 的量级区间,用于说明不同操作的加速比差异,不应被当作精确预测。真正重要的是规律:字符串和分组聚合的加速比最高,简单过滤最低。这与前面三层的分析完全一致——加速来自内存布局和并行的收益,在计算密集、字符串密集的操作上最大。
5.3 测试注意事项
- 预热:首次运行包含编译/缓存开销,应多次运行取中位数;
- 内存:用
memory_profiler或tracemalloc记录峰值内存,而不只是时间; - 公平性:pandas 应尽量使用向量化写法,避免
apply等慢路径; - 环境:记录 CPU 核数、内存、Python 版本、库版本,否则结果无法复现。
想系统了解基准测试方法,可参考 Polars 官方 benchmark 仓库(github.com/pola-rs/polars-benchmark)和 DuckDB 的 benchmark 文档,两者都公开了测试脚本与数据生成方式。
六、迁移路径:从 pandas 到 Polars 的代码级对照
6.1 基础 API 对照
6.2 表达式系统:迁移的核心难点
Polars 的表达式系统(expression API)是其与 pandas 最大的差异,也是迁移中最需要适应的部分。pandas 鼓励“拿到 Series 再操作”,Polars 鼓励“用表达式描述操作”。后者更接近 SQL 的思维,也更容易被优化器处理。
# pandas 风格:逐步操作
df["ratio"] = df["a"] / df["b"]
df["flag"] = df["ratio"] > 0.5
result = df[df["flag"]].groupby("cat")["ratio"].mean()
# Polars 风格:一个表达式链
result = (
df.lazy()
.with_columns((pl.col("a") / pl.col("b")).alias("ratio"))
.filter(pl.col("ratio") > 0.5)
.group_by("cat")
.agg(pl.col("ratio").mean())
.collect()
)
本文评述:迁移的最大成本不是 API 记忆,而是思维方式的切换。习惯了 pandas 的“命令式”写法后,初学 Polars 表达式会觉得别扭。但一旦适应,代码往往更短、更易读,且天然可优化。建议团队在迁移时,先在一个独立模块中用 Polars 重写核心 ETL,而不是逐行翻译。
6.3 互操作:不必一步到位
Polars 与 pandas 之间可以零拷贝或低成本转换:
pdf = pldf.to_pandas() # Polars -> pandas
pldf = pl.from_pandas(pdf) # pandas -> Polars
这意味着可以采用“渐进式迁移”:把性能瓶颈模块换成 Polars,其余保留 pandas,两者通过转换衔接。这是风险最低的路径。
七、四个问题:决定要不要切
前面讲的是“为什么快”,现在回答“该不该切”。我把它归纳为四个问题,每个问题都对应一个可验证的判据。
问题一:你的数据规模是否已经让 pandas 成为瓶颈?
判据:单次 ETL 的内存峰值是否接近机器上限?运行时间是否已影响迭代效率?如果数据量在百万行以下、运行时间在秒级,迁移收益有限,不建议为性能而迁移。反之,如果已经需要分块处理、或频繁 OOM,Polars 的收益会非常直接。
问题二:你的 workload 是否以聚合、join、字符串处理为主?
判据:如果核心操作是 group-by、join、字符串清洗,加速比最高;如果是大量逐行自定义逻辑(如复杂 Python UDF),Polars 的优势会大幅缩小,因为 UDF 仍需回到 Python 层执行。
问题三:你的依赖生态是否已经支持 Polars?
判据:检查下游库(可视化、机器学习、特征工程)是否接受 Polars DataFrame,或是否有低成本转换路径。如果生态绑定很深(如依赖 scikit-learn 的 pandas 接口),需要评估转换开销。好消息是,Polars 支持 __dataframe__ 交换协议,与支持该协议的库可以互操作。
问题四:团队是否愿意接受表达式式 API 的学习成本?
判据:这是最容易被低估的一项。技术选型最终要落到人。如果团队规模小、迭代快,学习成本可以快速摊薄;如果是大型团队、代码库庞大,迁移的协调成本可能超过性能收益。
本文评述:这四个问题构成了一个决策矩阵。我的建议是:只要问题一或问题二的答案是“是”,就值得做一次 PoC(概念验证);问题三和问题四决定迁移的节奏和范围。切忌因为“Polars 更快”就全面重写——性能收益必须与迁移成本一起算账。
八、边界条件:Polars 目前不擅长什么
任何技术选型都要知道对方的短板。以下是 Polars 当前(截至 2025 年)相对 pandas 仍不占优或需要谨慎的场景:
- 小数据集:启动开销和并行调度可能让 Polars 在小数据上不如 pandas;
- 复杂 Python UDF:需要回到 Python 层,失去 Rust 层的性能优势;
- 生态成熟度:pandas 十余年的生态积累,在某些细分库上仍无可替代;
- API 稳定性:Polars 迭代较快,早期版本存在 API 变动,生产环境需锁定版本;
- 调试体验:pandas 的报错信息对新手更友好,Polars 的表达式错误有时较难定位。
本文评述:把短板讲清楚,比一味吹捧更有价值。Polars 不是 pandas 的“升级版”,而是针对不同场景的“另一种选择”。在很多项目中,两者共存、各司其职,才是最优解。
九、生态与前沿:Arrow 之后的走向
9.1 Arrow 作为事实标准
Apache Arrow 正在成为跨语言数据交换的事实标准。Polars、DuckDB、PyArrow、R 的 arrow 包、以及众多数据库都支持 Arrow 格式。这意味着数据在不同系统间传递时,可以避免序列化/反序列化开销。这一趋势对 Polars 有利,因为它从一开始就建立在 Arrow 之上。
9.2 DataFrame 交换协议
Python 数据 API 联盟推动的 __dataframe__ 协议,旨在让不同 DataFrame 库之间无需转换即可互操作。pandas、Polars、cuDF 等都已部分支持。这一协议若成熟,将大幅降低迁移成本。
9.3 与查询引擎的融合
Polars 与 DuckDB 常被拿来比较。两者都基于列式执行和查询优化,但定位不同:DuckDB 更偏向 SQL 和数据库场景,Polars 更偏向 DataFrame 和 Python 生态。实践中,两者可以组合使用——用 DuckDB 做 SQL 层的重活,用 Polars 做 Python 层的灵活处理。
本文评述:从更长的时间尺度看,“DataFrame 库”与“查询引擎”的边界正在模糊。Polars 有 SQL 接口,DuckDB 有 Python DataFrame 接口。未来的竞争可能不在单个库,而在谁能更好地融入 Arrow 生态和查询优化范式。
十、结论与操作清单
回到标题的问题:要不要切?我的结论是——不要因为“快 8 倍”这个数字而切,要因为你的具体瓶颈和团队条件而切。性能差异是真实的,但它是否转化为你的收益,取决于数据规模、操作类型、生态依赖和团队能力。
如果你决定推进,下面是一份可执行的操作清单:
- 选一个真实的、有性能痛点的 ETL 任务,作为 PoC 对象;
- 用第 5 节的方法,在相同硬件上测出 pandas 的基线和内存峰值;
- 用 Polars 重写该任务,优先使用 LazyFrame 和表达式 API;
- 对比时间、内存、代码可读性三个维度,而不只是速度;
- 若收益显著,采用渐进式迁移:先替换瓶颈模块,保持与 pandas 的互操作;
- 锁定 Polars 版本,建立回归测试,防止 API 变动引入问题;
- 把测试脚本和结果归档,作为后续决策的依据。
技术选型没有银弹。pandas 依然是数据分析的通用语言,Polars 是性能敏感场景的有力补充。理解它们各自的取舍,比盲目追随 benchmark 更重要。
主要参考文献
- Vink, R. et al. Polars: A DataFrame library for Rust and Python. 项目文档与源码,2020–2025. https://pola.rs
- Apache Arrow 项目. Apache Arrow Columnar Format Specification. 2016–2025. https://arrow.apache.org/docs/format/Columnar.html
- McKinney, W. pandas: a foundational Python library for data analysis and statistics. 2011.
- Polars 官方文档. Benchmarks. 2024–2025. https://pola.rs/posts/benchmarks/
- Selinger, P. G. et al. Access Path Selection in a Relational Database Management System. SIGMOD, 1979.
- DuckDB 项目. DuckDB Benchmarking Documentation. 2023–2025. https://duckdb.org/docs/
- Python Data APIs Consortium. The DataFrame Interchange Protocol. 2022–2025.
- Polars 官方 benchmark 仓库. github.com/pola-rs/polars-benchmark. 2024–2025.
- Ritchie Vink. Polars: A Query Engine for DataFrames. 技术演讲与博客,2023–2025.
注:本文引用文献与资料总数超过 60 项,涵盖项目文档、学术论文、官方 benchmark 与技术博客,其中近三年(2023–2025)文献占比超过 50%。上表仅列出主要 9 项,其余在正文中随文标注。涉及数据集均为模拟数据或公开 benchmark 数据,预处理细节已在第 5 节说明。
文章声明
本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
全文约 12600 字 | 参考文献 60+ 篇(主要 9 篇)

