Polars 深度拆解:当 DataFrame 学会「延迟计算」——用 Rust + Apache Arrow + 查询优化器重写数据分析的心智模型
如果你写 Python 做数据分析,过去十年里
import pandas as pd几乎是肌肉记忆。但 2024 年之后,越来越多的工程师开始把这句换成import polars as pl。Polars 不是"又一个 DataFrame 库",它本质上是用数据库内核的思路重写了一遍你在 pandas 里手写的那些循环——列式内存、向量化执行、多线程并行、以及一个会在你按下collect()之前悄悄重写整棵查询树的优化器。本文从工程师视角,把 Polars 的架构一层层拆开:它到底比 pandas 快在哪、表达式(Expression)为什么是灵魂、查询优化器做了哪些"替你写更好代码"的事,以及 Streaming 流式引擎如何处理比你内存还大的数据。
一、pandas 的黄昏:我们为什么需要 Polars
1.1 pandas 的三道天花板
pandas 是 2008 年由 Wes McKinney 用 Cython 写就的奇迹,它把一个"表格"变成了 Python 数据科学的通用语言。但二十年后,它的设计假设越来越站不住脚:
第一,单线程执行。 pandas 大量运算跑在 Cython / NumPy 的 C 扩展上,但 GIL(全局解释器锁)和"一次只用一个核心"的默认行为,意味着你花大价钱买的 32 核服务器,在 df.groupby(...).agg(...) 时只跑满了一个核。CPU 利用率常年停在 5%~10%。
第二,到处复制的内存模型。 pandas 的 DataFrame 是"表格视角",内部是多个独立的 NumPy 列对象。很多操作(比如 df[mask]、df.assign(new=...))会在底层触发整列的深拷贝。你以为只是过滤几行,背后可能是一次把几 GB 数据完整复制一遍的昂贵操作。在面对 10 GB、100 GB 数据时,内存直接爆炸。
第三,没有查询优化。 这是最致命的。在 pandas 里你写的是命令式脚本:先 df = df[df.col > 0],再 df = df.groupby(...),每一步都立即执行并把中间结果物化到内存。优化器?不存在的。哪怕你最后只用到了 20 列里的 2 列,pandas 也会先把那 20 列全部读进来、全部参与运算,再丢弃。
这三道天花板,本质上不是 pandas "写得不好",而是它的心智模型来自 Excel 时代的"所见即所得",而不是数据库时代的"声明式 + 优化器"。
1.2 Ritchie Vink 的赌注:用数据库内核重写 DataFrame
Polars 的作者 Ritchie Vink 在 2020 年做了一件看似"多此一举"的事:用 Rust 从头写一个 DataFrame 库,并且先写查询引擎,再写 API。这个顺序很关键——它决定了 Polars 的 DNA 是"查询引擎",而不是"表格容器"。
Polars 的设计哲学可以浓缩成三句话:
- 内存就是 Apache Arrow。 列式、零拷贝、跨语言共享。
- 计算就是表达式树 + 向量化执行。 你写的
.sum()、.filter()不是立即执行的命令,而是一棵可以被优化的树。 - 执行前先优化。 在你按
collect()之前,优化器会把这棵树重写、下推、折叠,生成一棵"更聪明的树"。
这三句话,正是数据库(尤其是列式数据库如 ClickHouse、DuckDB)的核心思想。Polars 相当于把 ClickHouse 的列式执行引擎"塞进"了一个 Python 程序员熟悉的 DataFrame API 里。
二、核心概念:DataFrame、LazyFrame 与表达式系统
2.1 两种心智:Eager 与 Lazy
Polars 提供两套几乎同名的 API:
import polars as pl
# Eager 模式:和 pandas 一样,算完立刻给结果
df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
out_eager = df.filter(pl.col("a") > 1)
# Lazy 模式:先建查询计划,按 collect() 才真正执行
lf = pl.LazyFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
out_lazy = lf.filter(pl.col("a") > 1).collect()
DataFrame 走的是 Eager(急切) 路径:每一步立即执行、立即物化。它的存在主要是为了兼容性和交互式探索。
LazyFrame 走的是 Lazy(延迟) 路径:你写的每一行都只是在往一棵查询计划树上挂节点,直到 collect() 那一刻才真正开始计算。正是这个"延迟",给了优化器发挥的空间。
工程建议:默认用 Lazy。 哪怕你手里已经有个
DataFrame,也先用df.lazy()转成LazyFrame再操作,最后collect()。你损失的只是几行代码,换来的可能是 10 倍性能。
2.2 灵魂所在:表达式(Expression)
很多从 pandas 转过来的工程师,第一次用 Polars 会写得很别扭——因为他们还在用"方法链操作整个 DataFrame"的旧习惯。Polars 真正的力量藏在表达式(Expr) 里。
什么是表达式?pl.col("a") 返回的不是一个值,而是一个表达式对象——它描述了"对名为 a 的列,执行某个操作"这个意图,而不是立即去算。
# 一个表达式:意图 = "把 a 列大于 1 的行挑出来"
predicate = pl.col("a") > 1
# 表达式可以无限组合,组合出来的还是表达式
expr = (pl.col("b") * 2 + pl.col("a")).sum().alias("result")
# 表达式必须挂到某个上下文(select / filter / with_columns / group_by().agg)才会执行
df.select(expr)
对比一下 pandas 的写法:
# pandas:命令式,立即执行,且把 b 整列读出来做运算
result = (df["b"] * 2 + df["a"]).sum()
差异看起来只是语法糖,但背后天差地别:
- pandas 的
df["b"] * 2是立即对整列做运算并物化一个新 Series; - Polars 的
pl.col("b") * 2只是往表达式树加一个节点,什么都不算,直到select触发执行。
这个"延迟"让 Polars 能把多个表达式融合(fusion) 成一次向量化遍历,而不是 pandas 那样的"算一步存一次"。
2.3 上下文(Context):表达式在哪执行,决定语义
Polars 里表达式必须放在"上下文"中才有意义,最常见的三个:
# 1. select:选定/计算出若干列(可少于原列数)
df.lazy().select(pl.col("a"), pl.col("b").sum())
# 2. filter:用布尔表达式过滤行
df.lazy().filter(pl.col("a") > 1)
# 3. with_columns:在原表基础上追加新列(列数变多)
df.lazy().with_columns((pl.col("a") + pl.col("b")).alias("c"))
# 4. group_by().agg():分组聚合,agg 里放的是逐组求值的表达式
df.lazy().group_by("region").agg(
pl.col("sales").sum().alias("total"),
pl.col("sales").mean().alias("avg"),
)
理解"表达式 + 上下文"的组合,是掌握 Polars 的分水岭。pandas 用户习惯"操作整个表",Polars 用户习惯"描述列上的意图,并把意图挂到合适的上下文"。
三、架构分析:查询优化器如何"替你写更好的代码"
这是 Polars 最像数据库、也最被低估的部分。当你 collect() 一个 LazyFrame 时,Polars 不会傻乎乎地按你写的顺序一步步执行。它会先跑一遍优化 passes,把查询计划重写。
你可以用 .explain() 亲眼看到优化前后的计划:
lf = pl.scan_csv("huge.csv")
plan = (
lf.select("a", "b", "c")
.filter(pl.col("a") > 10)
.group_by("b")
.agg(pl.col("c").sum())
)
print(plan.explain()) # 优化后的物理计划
print(plan.explain(optimized=False)) # 原始逻辑计划
下面逐一拆解 Polars 优化器做的几件"神兵天降"的事。
3.1 投影下推(Projection Pushdown)
问题: 你的数据集有 100 列,但你最后只用到了 2 列。
pandas 做法: 先把 100 列全读进内存,全程带着它们跑,最后 select 扔掉 98 列。I/O 和内存都浪费了 50 倍。
Polars 做法: 优化器把"最后只需要 a、b、c 列"这个意图下推到最源头——scan_csv 阶段就只解析这三列。如果数据源是 Parquet(列式存储),Polars 甚至只读取磁盘上那三列对应的数据块,I/O 直接砍掉 97%。
# 即便你写了很长的管道,优化器也会把"只需要哪些列"一路推到文件读取层
(
pl.scan_parquet("events.parquet") # 上千列
.filter(pl.col("ts") > cutoff)
.group_by("user_id")
.agg(pl.col("amount").sum())
# 最终只用 user_id 和 amount,优化器下推:读取阶段只解析这两列+ ts
.collect()
)
3.2 谓词下推(Predicate Pushdown)
问题: 你只想分析 2026 年的数据,但文件里有 10 年的数据。
Polars 做法: 把 filter(pl.col("year") == 2026) 这个过滤条件下推到 scan_parquet / scan_csv 阶段。对于 Parquet 这种带行组统计信息(min/max) 的格式,Polars 甚至可以整块跳过那些明显不含 2026 年数据的 row group,连磁盘都不读。
# filter 被下推到扫描层,Parquet 的 row-group 统计能让它跳过无关数据块
(
pl.scan_parquet("logs/year=*/data.parquet")
.filter(pl.col("year") == 2026)
.filter(pl.col("level") == "ERROR")
.collect()
)
这是为什么读一个 100 GB 的 Parquet 目录、只筛几天的数据,Polars 可能几秒就返回——因为它根本没把 100 GB 读进来。
3.3 谓词/表达式简化与常量折叠
优化器会做基本的代数化简,相当于一个编译期计算器:
# 你写:(pl.col("a") * 1) + 0
# 优化器化简:pl.col("a") (乘 1 加 0 是恒等变换,直接删掉)
#
# 你写:pl.col("a") > 5 and pl.col("a") > 10
# 优化器化简:pl.col("a") > 10 (取更紧的约束)
#
# 你写:pl.lit(2 + 3) * pl.col("a")
# 优化器常量折叠:pl.lit(5) * pl.col("a") (2+3 在计划期就算成 5)
这些化简单看微不足道,但在你用循环或函数动态拼出几百个表达式时,化简能砍掉大量冗余计算。
3.4 公共子表达式消除(Common Subplan/Subexpression Elimination)
如果你在同一个查询里重复计算了同一个表达式,优化器会只算一次,结果复用:
# 下面的 (pl.col("sales") / pl.col("sales").sum()) 在 select 和 with_columns 里都出现
# 优化器识别为公共子表达式,物理执行时只计算一次
(
df.lazy()
.with_columns(
(pl.col("sales") / pl.col("sales").sum()).alias("share1")
)
.select(
pl.col("region"),
(pl.col("sales") / pl.col("sales").sum()).alias("share2")
)
.collect()
)
3.5 切片下推(Slice Pushdown)与排序/限制融合
head()、limit() 这类"只要前 N 行"的操作也会被下推。比如你要取"每个地区销量最高的前 3 名商品",Polars 不会先算出全量排名再截断,而是把 limit 融合进 group_by 的聚合阶段,每组只保留 Top-N——复杂度从 O(全量) 降到 O(N·log k)。
(
df.lazy()
.group_by("region")
.agg(
pl.col("product").sort_by("sales", descending=True).head(3)
.alias("top3_products")
)
.collect()
)
小结: 查询优化器是 Polars "免费提速"的来源。你写得越"声明式"(只描述想要什么,不规定怎么算),优化器能发挥的空间就越大。这也是为什么同一个需求,Polars 老手写的比新手写的能快好几倍——差距往往不在 API 调用,而在是否让优化器充分工作。
四、执行引擎:多线程、SIMD 与 Streaming 流式引擎
优化器解决了"算什么、按什么顺序算"的问题,真正"动手算"的是执行引擎。Polars 的执行引擎有三个关键词。
4.1 基于 Rayon 的多线程并行
Polars 用 Rust 的 Rayon 数据并行库做线程池调度。几乎每一个算子(filter、sum、group_by、join)在执行时都会把数据按行切成多个分片(chunks),分给多个线程并行处理,最后再合并结果。
这意味着:你 32 核的机器,跑 group_by 时 32 核真的会一起转。pandas 那个"5% CPU 利用率"的痛点,在 Polars 里基本消失。
# 并行是默认的,你啥都不用做。数据够大时,group_by 自动多线程
(
pl.scan_parquet("big/*.parquet")
.group_by(["user_id", "date"])
.agg(pl.col("amount").sum(), pl.col("cnt").count())
.collect()
)
4.2 SIMD 向量化
Polars 大量使用了 Rust 标准库的 std::simd(以及内部的向量化哈希、向量化比较)。比如对一列整数做 > 0 的判断,Polars 不是写个 for 循环逐个比,而是用 SIMD 指令一次比较 16/32/64 个元素。再叠加"表达式融合"(见 2.2 节),多个列运算能融合成一次遍历内的连续 SIMD 操作,CPU 的流水线几乎不空转。
这也是为什么 Polars 在处理宽表 + 数值运算时,能稳定甩开依赖逐行 Python/NumPy 循环的写法。
4.3 Streaming 流式引擎:处理比你内存还大的数据
这是 Polars 1.x 时代最受关注的架构升级。传统的 Eager 和早期 Lazy 执行是物化(materialized) 模式:每个算子算完,把完整结果写回内存,再交给下一个算子。一旦中间结果超过内存,直接 OOM。
Polars 的 Streaming 引擎改用流水线(pipeline) 模式:
- 数据被切成固定大小的批次(batches);
- 算子之间用流连接,一个批次算完立刻推给下游,不等全集;
- 内存占用被压到"常数级"(只保留当前批次 + 必要的聚合状态);
- 当聚合状态或中间数据过大时,可以落盘(spill to disk),实现真正的 out-of-core(超出内存也能算)。
# 一个 200 GB 的 CSV,机器只有 16 GB 内存
# 用 streaming 引擎 + scan,Polars 会分批读取、流水线聚合、必要时落盘
(
pl.scan_csv("200gb_logs.csv")
.filter(pl.col("status") >= 400)
.group_by("endpoint")
.agg(pl.col("latency_ms").mean().alias("avg_latency"),
pl.len().alias("error_count"))
.collect(engine="streaming") # 显式指定流式引擎
)
经验法则:数据量超过内存的 1/3,或查询包含宽表 join / 大 group_by,就显式加
engine="streaming"。 在 Polars 较新的版本里,引擎选择已在很多场景中自动发生,但主动声明能避免意外的内存尖峰。
4.4 列式内存:Arrow 带来的零拷贝红利
Polars 的内存布局是列式的——同一列的数据在内存里连续存放。好处有三:
- 缓存友好: 只访问
sales列时,CPU 缓存加载的全是sales的数据,没有user_id、region的干扰,缓存命中率极高。 - 压缩友好: 同一列数据类型一致、取值范围接近,压缩率高,Arrow 的字典编码对低基数字符串尤其有效。
- 零拷贝互操作: 因为内存就是 Arrow 格式,Polars 可以直接把内存块交给 DuckDB、Arrow Flight、DataFusion,甚至直接 mmap 一个 Parquet 文件——不需要任何序列化/反序列化。
# 与 Arrow 生态零拷贝互通
import pyarrow as pa
arrow_table = pa.table({"a": [1, 2, 3]})
df = pl.from_arrow(arrow_table) # 几乎零拷贝
back = df.to_arrow() # 再拿回 Arrow
五、代码实战:从 pandas 思维迁移到 Polars 表达式
光能背 API 没用,关键是换脑子。下面用一组真实场景,对比 pandas 写法与 Polars 表达式写法,并讲清楚为什么后者快。
5.1 实战一:读取大 CSV 并做条件聚合
需求: 从一个 30 GB 的订单 CSV 里,统计每个地区的销售额总和与平均值,只看 2026 年的数据。
# ===== pandas 写法(内存爆炸警告)=====
import pandas as pd
df = pd.read_csv("orders.csv") # 30 GB 全部读进内存
df = df[df["order_date"].str.startswith("2026")] # 整列字符串操作 + 复制
result = df.groupby("region")["amount"].agg(["sum", "mean"])
# ===== Polars 写法(lazy + scan + 下推)=====
import polars as pl
result = (
pl.scan_csv("orders.csv") # 只读计划,不碰数据
.filter(pl.col("order_date").str.starts_with("2026")) # 谓词下推
.group_by("region")
.agg(
pl.col("amount").sum().alias("total"),
pl.col("amount").mean().alias("avg"),
)
.collect() # 此刻才真正流式读取+并行聚合
)
差距来源: pandas 把 30 GB 全读进来还复制了一份;Polars 先下推过滤条件,Parquet/CSV 扫描阶段就丢弃无关行,且 group_by 多线程并行。实测这种场景 Polars 往往快 5~20 倍,内存占用低一个数量级。
5.2 实战二:窗口函数(占比、排名、累计)
需求: 计算每条订单在该用户总消费中的占比,以及用户内的消费排名。
result = (
df.lazy()
.with_columns(
# over("user_id") = 按 user_id 分窗
(pl.col("amount") / pl.col("amount").sum().over("user_id"))
.alias("user_share"),
pl.col("amount").rank().over("user_id").alias("user_rank"),
pl.col("amount").cum_sum().over("user_id").alias("user_cumsum"),
)
.collect()
)
over() 是 Polars 表达力最强的特性之一。它背后走的是分组 + 窗口状态机,全程向量化,而不是 pandas 那种 apply 逐组 Python 循环。
5.3 实战三:Join 也能下推优化
orders = pl.scan_csv("orders.csv")
users = pl.scan_csv("users.csv")
joined = (
orders
.join(users, on="user_id", how="left")
.filter(pl.col("vip") == True) # join 后的过滤也会被下推参与优化
.group_by("city")
.agg(pl.col("amount").sum())
.collect(engine="streaming")
)
Polars 的 join 支持谓词下推进 join 之前的扫描、以及流式 join(适合大表)。对于大维度表 join,Polars 会自动选择 hash join 或 sort-merge join。
5.4 实战四:自定义 Rust 表达式插件(进阶)
当内置表达式不够用时,Polars 允许你用 Rust 写一个自定义表达式,编译成插件后在 Python 里像原生 API 一样调用——而且它跑的是原生 Rust、向量化、无 Python 开销:
// my_plugin/src/lib.rs
use polars::prelude::*;
use pyo3_polars::derive::polars_expr;
#[polars_expr(output_type=Float64)]
fn pl_sigmoid(inputs: &[Series]) -> PolarsResult<Series> {
let ca = inputs[0].f64()?;
// apply_values 是向量化的,不会退化为逐行 Python 循环
let out = ca.apply_values(|v| 1.0 / (1.0 + (-v).exp()));
Ok(out.into_series())
}
import my_plugin
df.lazy().with_columns(
my_plugin.pl_sigmoid(pl.col("score")).alias("prob")
).collect()
这是 Polars 相比 pandas 的"降维打击":pandas 的 apply 是 Python 逐行回调,Polars 的插件是编译进引擎的原生向量化算子。
六、性能优化:榨干硬件的 7 个实战技巧
把 API 用对只是及格,下面 7 条是"工程级"提速清单:
① 永远 lazy + scan_\*。 避免 pl.read_csv(eager 全量读入),改用 pl.scan_csv / pl.scan_parquet。让优化器有整棵树可以下推。
② 用表达式 API,打死不用 .apply()。 apply 会把列"降维"成 Python 对象逐行回调,向量化和多线程全废。99% 的 apply 都能用 pl.col(...).xxx() 原生表达式替代。实在要写复杂逻辑,用 5.4 节的 Rust 插件。
③ 精确 select,触发投影下推。 只 select 你真正要的列,不要用 df 一路带着所有列跑。列越少,I/O 和内存越小。
④ 尽早 filter,配合谓词下推。 过滤条件写得越靠前、越简单(单列比较),越容易被下推到扫描层,甚至让 Parquet 跳过整块 row group。
⑤ 大数据集显式 engine="streaming"。 防 OOM 的第一道防线。配合 out-of-core,比内存大的数据也能算。
⑥ 聚合内联到 agg(),别先 with_columns 再 group_by。 前者在分组态内直接算,后者会先把整列物化出来再分组,多一次全量遍历和一次内存分配。
⑦ 善用 over()、.cum_sum()、.rank()、.shift() 等原生窗口表达式,它们走的是专用向量化窗口实现,远比自己拼循环快。需要排序但不在乎顺序时,加 maintain_order=False 让 Polars 跳过昂贵的全局保序。
七、横向对比:Polars vs pandas vs DuckDB vs Modin
很多工程师问:"我该用哪个?"答案是看场景,而不是"谁最快"。
| 维度 | pandas | Polars | DuckDB | Modin |
|---|---|---|---|---|
| 执行模型 | 单线程 eager | 多线程 lazy+streaming | 嵌入式 SQL 列式引擎 | pandas API 多进程 |
| 内存模型 | NumPy 列对象,易复制 | Arrow 列式,零拷贝 | Arrow/自有列式 | 同 pandas |
| 查询优化 | 无 | 有(下推/化简/CSE) | 有(完整 SQL 优化器) | 有限 |
| 超内存数据 | 基本不行 | streaming out-of-core | 原生支持 | 部分 |
| 上手成本 | 极低 | 中(要学表达式心智) | 中(要写 SQL) | 极低(API 兼容) |
| 典型最佳场景 | 小数据探索、原型 | 中大数据 ETL、特征工程 | 超大数据 SQL 分析 | 不想改代码的 pandas 加速 |
选型建议:
- 数据 < 1 GB、纯探索、脚本一次性的 → pandas 依然够用、最快上手;
- 数据 1 GB ~ 内存级别、要做 ETL/特征工程、追求 Python 原生表达力 → Polars 是甜区;
- 数据 >> 内存、你习惯写 SQL、做即席分析 → DuckDB 更顺手;
- 已有庞大 pandas 代码、不想改 → Modin 用多进程"无痛"加速(但有 API 覆盖盲区)。
Polars 和 DuckDB 不是敌人——很多现代数据栈让 Polars 负责"进程内 DataFrame 变换",DuckDB 负责"超大数据集 SQL",两者通过 Arrow 零拷贝互通。
八、总结与展望
Polars 的意义,远不止"一个更快的 pandas"。它代表了一种心智模型的迁移:数据分析师从"写命令"转向"写声明",把"怎么算"交给优化器,把"算什么"留给自己。
回顾本文拆开的几层:
- 内存层:Arrow 列式布局带来缓存友好、压缩友好、零拷贝互操作;
- API 层:表达式(Expr)+ 上下文(select/filter/agg)的组合,让"意图"可组合、可优化;
- 优化层:投影下推、谓词下推、常量折叠、公共子表达式消除、切片下推,免费替你重写查询;
- 执行层:Rayon 多线程 + SIMD 向量化 + Streaming 流水线,把多核和现代 CPU 榨到极限;
- 生态层:Rust 插件让你把自定义逻辑编译进引擎,Arrow 让你无缝接入整个数据生态。
展望未来,Polars 的路线图有几个值得关注的方向:
- Polars Cloud / Connect:把查询计划序列化后在集群上分布式执行,让"本地写的 lazy 查询"能直接推到远程引擎跑——这是 Polars 对标分布式数据处理的野心。
- GPU 加速:社区正在探索把部分算子下沉到 GPU(实验性),对于超大规模数值聚合有进一步提速空间。
- 更聪明的 Streaming:流式引擎覆盖的算子范围在持续扩大,最终目标是让"流式"成为默认且无感的选择。
- 插件生态繁荣:随着
#[polars_expr]这类宏成熟,越来越多领域算子(金融、生物、地理)会以原生插件形式出现,避免 Python 回退的性能惩罚。
给工程师的一句话建议:从今天起,新项目默认用 Polars 的 lazy API 起手。 你不需要一次性重写所有 pandas 代码,但每多写一个 LazyFrame,你就离"免费的性能红利"更近一步。DataFrame 已经学会了"延迟计算",接下来,是时候让我们自己的代码也学会——先想清楚要什么,再让引擎去想怎么算。
参考资料:Polars 官方文档(docs.pola.rs)、pola-rs/polars GitHub 仓库 Release Notes(截至 2026 年中已迭代至 1.10+ 系列)、Apache Arrow 规范、以及作者 Ritchie Vink 关于查询引擎设计的公开技术分享。文中性能对比为定性结论,实际倍率取决于数据形状、硬件与查询复杂度,建议以你自己的数据集基准测试为准。