从 pandas 迁移到 Polars:概念差异、表达式思维与代码改写实战指南
导读:本文是官方用户指南中「Coming from Pandas」(pandas.md)的深度扩展版,面向已有 pandas 经验、希望转向 Polars 的开发者。文章先厘清两大库在索引、内存格式、并行执行、求值模式与类型系统上的根本差异,再逐条演示数据选择、惰性查询、并行列赋值、窗口函数与缺失值处理的 pandas → Polars 改写套路,并结合仓库源码与配套示例讲解其底层原理,帮助读者建立"表达式优先"的 Polars 心智模型。
概念层面:pandas 与 Polars 的根本差异
pandas 与 Polars 虽然都面向表格型数据,但底层设计哲学截然不同。看懂下面这组概念差异,是写出高质量 Polars 代码的前提。
Polars 没有索引(index),更没有 MultiIndex
pandas 为每一行打上一个标签(index),因此有 .loc / .iloc、set_index、reset_index 等一系列围绕索引展开的操作与隐患。而 Polars 中每一行由它在表中的整数位置唯一确定:
- Polars 的 DataFrame 始终是一个二维、异构类型的表。列的数据类型可以嵌套(如
List、Struct),但表结构本身不会因为"索引操作"而变形; - 诸如重采样(resampling)等需求,由专门的函数/方法以"动词(verb)"的方式作用在表上,并显式声明其操作的列;
- 查询的语义不会因为索引状态或一次
reset_index调用而改变,从而保证结果可预期、查询可读; - 官方文档的立场是:去掉索引让事情更简单、更显式、更可读、更少出错。
需要澄清的是:Polars 作为优化手段也会在内部构建类似数据库的 index 数据结构,只是它从不暴露给用户语义层。从 Python API 看,数据选择与修改的入口都集中在 frame.py 中,方法名(select、with_columns、filter 等)本身就是"动词",替代了 pandas 中围绕 .loc 的各种读写套路。
内存表示:Apache Arrow 列式格式 vs NumPy 数组
pandas 默认以 NumPy 数组承载数据,而 Polars 严格遵循 Apache Arrow 内存规范(对应仓库中的 polars-arrow crate,其 Cargo 清单见 polars-arrow/Cargo.toml)。Arrow 是内存列式分析的事实标准,可以带来更快的加载速度、更低的内存占用和更快的计算,并且天然支持与其他 Arrow 生态工具零拷贝互操作。
若需要把 Polars 数据交给 NumPy 生态处理,官方提供了显式转换通道:to_numpy 方法。
并行能力:线程级并发 vs 单线程核心
pandas 只有部分操作是多线程的,核心仍是单线程;要并行化通常得额外引入 Dask 之类的框架。Polars 用 Rust 编写,充分利用 Rust 的并发安全能力把大量操作拆到多线程并行执行。
这一点在仓库结构上非常直观:整个计算栈被拆分为 polars-mem-engine(内存态执行器,其实现位于该 crate 的 src/executors)与 polars-stream(流式执行器,src/nodes 目录下是按算子拆分的 153 个节点实现)。表达式层面,凡是在同一个 select / with_columns / filter 上下文内的操作都可以并行,而不像 pandas 需要靠外部框架分片。
注:原指南文档称"Polars 比所有并行化 pandas 代码的开源方案都快",这是项目官方文档中的表述;实际相对性能取决于硬件、数据形态与具体算子,建议以自己场景的基准测试为准。
多引擎支持:内存引擎、流式引擎与 GPU 引擎
Polars 原生提供三类执行引擎,并保证语义一致性(各引擎输出相同的结果):
- 内存(in-process)引擎:针对适合装入内存的数据集优化;
- 流式(streaming)引擎:面向超过内存容量的大规模数据,配合惰性查询做增量流水线处理;
- CuDF 支持的 GPU 引擎:把查询下推到 GPU 执行,详见 GPU engine 指南。
这些引擎共享同一个查询优化器。pandas 虽然也可以在 NumPy 与 PyArrow 后端之间切换,但由于其类型约束松散,两个后端可能产生不同的 dtype 与语义,容易埋下隐蔽 bug;Polars 用统一类型系统规避了这一点。
惰性求值与自动查询优化
- Eager(即时)求值:代码一执行就出结果;
- Lazy(惰性)求值:执行某行代码只是把逻辑加入一棵"查询计划(query plan)",并不真正计算。
pandas 只支持 eager;Dask 通过生成查询计划支持 lazy。Polars 两种都支持,而且 lazy 模式的价值在于:查询优化器会在真正执行前分析整棵计划树,寻找加速查询或降低内存的手段(详见后文"查询优化")。
在 Python 侧,.lazy() 到 collect() 的完整链路由 lazyframe/frame.py 承载,可参看 Lazy API 使用指南 与 执行 Lazy 查询。
严格类型系统
Polars 对数据类型非常严格:类型解析取决于操作图,由优化器统一推导。pandas 则会宽松地"隐式转型",例如在整型列中引入缺失值后,整型列会被悄悄转成 float 列。Polars 的做法带来更少 bug 与更可预测的行为——整型列里的缺失值就是 null,列仍保持整型。
基于表达式的、更通用的 API
pandas 没有表达式系统,复杂逻辑常需借助 Python lambda 表达。Polars 几乎所有操作(select、filter、with_columns、group_by.agg…)都接受表达式(Expr)输入——你只要学会一次表达式,知识就能在整个 API 中迁移复用。Polars 把"必须写 Python lambda"视为 API 表达力不足的信号,并尽量提供原生支持。
表达式的 Python 实现集中在 expr/expr.py,条件分支三件套 when/then/otherwise 见 expr/whenthen.py。后文的大量示例都会体现这一点。
关键语法差异:从 pandas 逐行改写
官方文档把最关键的一句话总结为:
polars != pandas
如果你的 Polars 代码看起来像 pandas 代码,它可能能跑,但大概率比应有的速度慢——因为 pandas 的写法(逐行 eager 变换、lambda、局部掩码)会阻止 Polars 进入最优执行路径。
数据选择:用表达式代替 .loc / .iloc
因为 Polars 没有索引,所以没有 .loc / .iloc,相应地也没有 pandas 的 SettingWithCopyWarning。
pandas 取列:
df["a"]
df.loc[:, "a"]
Polars 取列用 .select:
df.select("a")
按值筛选行,pandas 用布尔掩码或 query,Polars 用 .filter:
df.filter(pl.col("a") < 10)
由于 select/filter 接受表达式并整体交给优化器,多组选择条件可以被并行执行并联合优化。
变得"懒惰":用 scan_csv + collect 替代 read_csv
惰性模式应该成为 Polars 的默认工作方式,因为只有 lazy 才能触发查询优化。进入 lazy 有两种途径:使用隐式惰性的读取函数(如 scan_csv),或对已有 DataFrame 调用 .lazy()。
考虑官方文档的例子:磁盘上有一个列很多的 CSV,我们只想按 id1 分组并对 v1 求和。
pandas 写法:
df = pd.read_csv(csv_file, usecols=["id1", "v1"])
grouped_df = df.loc[:, ["id1", "v1"]].groupby("id1").sum()
Polars 惰性写法(只需把 eager 的 read_csv 换成惰性的 scan_csv):
df = pl.scan_csv(csv_file)
grouped_df = df.group_by("id1").agg(pl.col("v1").sum()).collect()
这里发生了两件重要的事:
- 投影下推(projection pushdown):优化器发现最终只需要
id1、v1两列,于是从 CSV 扫描阶段就只读取这两列,而不是像 pandas 那样先整表读入内存再裁剪(pandas 只能靠手动usecols补救); .collect()触发求值:第二行末尾调用.collect()才指示 Polars 真正执行整条查询。
若确实想用 eager 模式,把 scan_csv 换回 read_csv 即可。完整的优化手段清单(谓词下推、投影下推、切片下推、公共子计划消除、表达式简化、join 排序、类型强转、基数估计等)可查阅 Optimizations 文档。
pl.scan_* 系列不仅限于 CSV——官方文档说明它覆盖 CSV、IPC、Parquet、JSON 等常见格式。想确认某个 lazy 查询到底被优化成了什么样子,可以先用 explain 打印优化前后的计划树,相关说明见 Query Plan 文档。
表达你自己:用表达式在单个上下文内并行
pandas 脚本的本质是多步顺序执行的变换(每个中间结果都要落一次内存);Polars 则把多个变换折叠进表达式,让它们在一个上下文内并行执行。
列赋值:with_columns vs assign
假设 df 有一列 value,我们要新增 tenXValue(×10)与 hundredXValue(×100)两列。
pandas 用 assign + lambda,两步顺序执行:
df.assign(
tenXValue=lambda df_: df_.value * 10,
hundredXValue=lambda df_: df_.value * 100,
)
Polars 用 .with_columns 一次性挂多个表达式,且可以并行执行:
df.with_columns(
tenXValue=pl.col("value") * 10,
hundredXValue=pl.col("value") * 100,
)
基于条件的列赋值:when → then → otherwise
假设 df 有 a、b、c 三列,当 c == 2 时用 b 覆盖 a。
pandas 用 mask:
df.assign(a=lambda df_: df_["a"].mask(df_["c"] == 2, df_["b"]))
Polars 用条件表达式:
df.with_columns(
pl.when(pl.col("c") == 2)
.then(pl.col("b"))
.otherwise(pl.col("a"))
.alias("a")
)
注意 Polars 可以并行计算 if → then → otherwise 的每个分支——当分支本身很昂贵时(比如每个分支内是复杂聚合),这种并行性价值尤为明显。
过滤与过滤融合
pandas 过滤房产数据:
df.query("m2_living > 2500 and price < 300000")
# 或等价掩码
df[(df["m2_living"] > 2500) & (df["price"] < 300000)]
Polars:
df.filter(
(pl.col("m2_living") > 2500) & (pl.col("price") < 300000)
)
更进一步:即使你把过滤写成多个分散的 .filter 调用,查询优化器也会检测到并把它们合并为单个 filter(属于谓词下推/表达式简化范畴,可对照 Optimizations 文档 中的说明),尽量避免多次遍历数据。
pandas transform → Polars 窗口表达式 .over()
pandas 文档中经典的 groupby(...).transform(...) 用法,在 Polars 中对应的是窗口函数。给定如下 DataFrame,我们希望新增一列 size,表示每个 c 分组内的行数:
df = pd.DataFrame({
"c": [1, 1, 1, 2, 2, 2, 2],
"type": ["m", "n", "o", "m", "m", "n", "n"],
})
df["size"] = df.groupby("c")["type"].transform(len)
pandas 的思路是:按 c 分组 → 取 type → 算组长度 → 再把结果 join 回原表:
c type size
0 1 m 3
1 1 n 3
2 1 o 3
3 2 m 4
4 2 m 4
5 2 n 4
6 2 n 4
Polars 用窗口表达式一步到位(不需要手动 join):
df.with_columns(
pl.col("type").count().over("c").alias("size")
)
shape: (7, 3)
┌─────┬──────┬──────┐
│ c ┆ type ┆ size │
│ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ u32 │
╞═════╪══════╪══════╡
│ 1 ┆ m ┆ 3 │
│ 1 ┆ n ┆ 3 │
│ 1 ┆ o ┆ 3 │
│ 2 ┆ m ┆ 4 │
│ 2 ┆ m ┆ 4 │
│ 2 ┆ n ┆ 4 │
│ 2 ┆ n ┆ 4 │
└─────┴──────┴──────┘
为什么窗口比"transform + join"更强? 因为整组逻辑被压缩进一个表达式,你可以在同一个上下文中组合多个窗口函数,甚至可以基于不同的分组键同时计算。而且 Polars 会缓存作用于同一分组的窗口表达式——把多次 .over("c") 放进同一个 .with_columns 既方便又是最优选择:
df.with_columns(
pl.col("c").count().over("c").alias("size"),
pl.col("c").sum().over("type").alias("sum"),
pl.col("type").reverse().over("c").alias("reverse_type"),
)
shape: (7, 5)
┌─────┬──────┬──────┬─────┬──────────────┐
│ c ┆ type ┆ size ┆ sum ┆ reverse_type │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ u32 ┆ i64 ┆ str │
╞═════╪══════╪══════╪═════╪══════════════╡
│ 1 ┆ m ┆ 3 ┆ 5 ┆ o │
│ 1 ┆ n ┆ 3 ┆ 5 ┆ n │
│ 1 ┆ o ┆ 3 ┆ 1 ┆ m │
│ 2 ┆ m ┆ 4 ┆ 5 ┆ n │
│ 2 ┆ m ┆ 4 ┆ 5 ┆ n │
│ 2 ┆ n ┆ 4 ┆ 5 ┆ m │
│ 2 ┆ n ┆ 4 ┆ 5 ┆ m │
└─────┴──────┴──────┴─────┴──────────────┘
在这个例子里,size 和 reverse_type 都按 c 分组(缓存复用),而 sum 按 type 分组——三种窗口统计同时计算、互不干扰。
.over() 的能力不止于此:它支持多列分组(如 .over("Type 1", "Type 2")),也能配合 rank、sort_by、head 等实现"组内 Top-N",官方配套示例见 window.py;.over() 的完整行为在 表达式/窗口相关文档 中有进一步展开。
缺失值:null 与 NaN 的严格区分
pandas 的混乱 vs Polars 的整齐
pandas 依据列 dtype 混用 NaN/None 表示缺失,且行为还会因是否启用可选的可空数组而不同。Polars 的规则非常干净:
- 所有数据类型的缺失值统一用
null表示; - 浮点列允许出现
NaN,但NaN是"特殊的浮点值",不算缺失数据; - 整型列出现缺失时,pandas(除非用可空 dtype)会把整型列悄悄转成带
NaN的 float 列;Polars 中整型列的缺失值就是null,列保持整型。
处理缺失值:fill_null 的四种填充方式
关于缺失值的完整讲解见 Missing data 文档,其可执行示例位于 missing-data.py。fill_null 支持四类填充来源:
- 字面量:
.fill_null(0)用常量替换所有null; - 表达式:
.fill_null(pl.col("b") * 2),用另一列的派生值逐行填充; - 邻值策略:
.fill_null(strategy="forward" | "backward"),用前/后第一个非null值填充; - 插值:
.fill_null(pl.col("x").interpolate())——注意用interpolate方法而非fill_null,且序列首尾的null保持为null。
NaN 的特殊语义
Polars 把 NaN 视为浮点数值而非缺失,因此:
NaN不计入null_count;fill_null不填充NaN,需要用专门的fill_nan;- 与
null不同,Polars 并不为NaN维护元数据,is_nan需要真实计算; - 数值聚合(
mean、sum等)会跳过null,但会把NaN计入并让它传播到结果。若希望聚合忽略NaN,可先用fill_nan(None)(等价写法fill_nan(None)将NaN转成null)再聚合。
顺带一提:把 NaN 写进整型列,pandas 会静默转型成 float;Polars 不会转型而是直接抛异常——这正是前文"严格类型系统"的体现。
别到处 .pipe():用"返回表达式的函数"替代
pandas 生态里很流行用 .pipe 把一个函数依次作用在 DataFrame 上:
def add_foo(df: pd.DataFrame) -> pd.DataFrame:
df["foo"] = ...
return df
def add_bar(df: pd.DataFrame) -> pd.DataFrame:
df["bar"] = ...
return df
def add_ham(df: pd.DataFrame) -> pd.DataFrame:
df["ham"] = ...
return df
(df
.pipe(add_foo)
.pipe(add_bar)
.pipe(add_ham)
)
如果把这套习惯原样搬进 Polars,你会得到 3 个独立的 with_columns 上下文,迫使 Polars 串行执行 3 段变换,并行度为零,并产生次优的查询计划。
正确做法是:把每个变换写成"创建表达式"的函数。下面的写法在同一个 with_columns 上下文中注入 3 个表达式,从而被允许并行执行:
def get_foo(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("foo")
def get_bar(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("bar")
def get_ham(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("ham")
# 单个上下文,3 个表达式并行运行
df.with_columns(
get_ham("col_a"),
get_bar("col_b"),
get_foo("col_c"),
)
如果这些生成表达式的函数确实需要读取 schema 才能决定分支,才使用(而且只用一次)pipe——在管道的最外层把 LazyFrame 传给一个闭包,闭包内读取 lf.schema 后再构造 with_columns:
from collections import OrderedDict
def get_foo(input_column: str, schema: OrderedDict) -> pl.Expr:
if "some_col" in schema:
# branch_a
...
else:
# branch b
...
def get_bar(input_column: str, schema: OrderedDict) -> pl.Expr:
if "some_col" in schema:
# branch_a
...
else:
# branch b
...
def get_ham(input_column: str) -> pl.Expr:
return pl.col(input_column).some_computation().alias("ham")
# 仅在需要获取 LazyFrame 的 schema 时使用一次 pipe
lf.pipe(lambda lf: lf.with_columns(
get_ham("col_a"),
get_bar("col_b", lf.schema),
get_foo("col_c", lf.schema),
))
"返回表达式的函数"还有额外的架构收益:表达式可链式调用、可偏应用、可组合,从而让自定义逻辑具备远超 pandas pipe 链的复用性与灵活性——这也是从 pandas 迁移到 Polars 时最值得刻意练习的思维转变。
迁移要点速查
- 忘掉索引:用整数位置理解行,用
select/filter/with_columns这些"动词"操作数据; - 默认走 lazy:文件入口用
scan_csv/scan_parquet等,内存 DataFrame 调.lazy(),末尾.collect();让投影下推与谓词下推帮你少读数据; - 拒绝 lambda:凡是一个 pandas lambda 能做的事,先想想 Polars 是否已有原生表达式(
when/then/otherwise、算术、字符串、聚合、窗口…); - 把多步
assign/pipe收敛为单个上下文内的多个表达式,换取并行执行与更优查询计划; - 数据在列内嵌套没关系,但表永远是二维的;需要"行级标签"语义时,显式创建一列即可;
- 缺失值只认
null(浮点列另有不算缺失的NaN),整型列不再因为缺失而悄悄变 float。
完整的惰性 API 讲解可继续阅读 Lazy API 使用指南(配套示例 using.py)与 执行 Lazy 查询;若想对照 Spark 的迁移思路,仓库还提供了一份平行的 Spark 迁移指南。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00