前段时间,Polars 迎来了 2.0 大版本升级。平时用它处理 CSV、做 join、跑 group_by 的朋友,最关心的恐怕不是版本号往前跳了多少,而是旧代码还能不能原样运行、计算结果会不会悄悄发生变化。

这次升级里有几个地方挺值得留意。比如,同样的 join,连接出来的记录内容可以完全一致,但返回的行顺序却可能改变;Int64 和 UInt64 混合运算时,结果类型可能从 Float64 变为 Int128;还有 LazyFrame.collect() 默认改用流式引擎,也容易让人觉得所有查询都能直接处理超出内存的数据,实际情况并没有这么简单。
下面我们就结合 Polars 1.43.2 与 2.0.0 两个版本,聊聊这些变化具体意味着什么。特别是已经在用 Polars 做数据清洗、统计报表的朋友,升级前不妨先留意下行序、数值精度和结果类型这几个容易被忽略的细节。(截稿时刚好升级到 2.1.0,感兴趣的朋友可以试用看看。)
01 从两张 CSV 算出分类销售额
整个主流程基于 LazyFrame:先扫描两份 CSV,按商品编号做左连接,算出净额,再按类别和地区分组。数量、折扣和单价在脚本里显式声明为整数类型,避免把类型交给推断。示例需要在 polars-demo 的上一级目录运行,并分别使用前文提到的两个锁定版本环境:
import polars as pl
from pathlib import Path
root = Path("polars-demo")
orders = pl.scan_csv(
root / "orders.csv",
schema_overrides={"quantity": pl.Int64, "discount_cents": pl.Int64},
)
products = pl.scan_csv(
root / "products.csv",
schema_overrides={"unit_price_cents": pl.Int64},
)
joined = orders.join(products, on="product_id", how="left")
report = (
joined.with_columns(
(pl.col("unit_price_cents") * pl.col("quantity")
- pl.col("discount_cents")).alias("net_cents")
)
.group_by("category", "region")
.agg(
pl.len().alias("order_lines"),
pl.col("quantity").sum().alias("units"),
pl.col("net_cents").sum().alias("net_cents"),
)
.sort(["category", "region"])
)
result = report.collect()
Polars 2.0 的迁移指南提到,LazyFrame.collect() 和 collect_async() 在 engine="auto" 下默认切换为流式引擎。但这不等于所有 Polars API 都换成了流式,eager DataFrame 也不受这个默认值影响。指南同时提醒,join、group_by、unpivot 这类操作本身不保证行序,之前观察到的顺序可能不被保留。所以上面的查询里才显式写了排序:要得到可复现的报表顺序,就应该自己指定排序键,而不是依赖某次运行碰巧返回的排列。
两份保存记录都连接出了 5,000 行。把完整 join 的业务列排序后规范化为 JSON,两版的 SHA-256 均为 59bdaff3c892c22011795e0dec4acedf23c44b1697b3d0b1973bda14baf2e8fd。这能说明这组输入下两版的内容一致,但不能用来证明默认行序稳定。
同一份输入按 category, region 显式排序后,两版的 20 组汇总结果完全一致。再把四个地区加总,电子类净额为 73,294.86 元,家居 38,031.88 元,食品 33,909.92 元,户外 19,702.26 元,配件 16,534.62 元。下图上半部是这份虚构数据的分类汇总,展示了 scan_csv → join → 金额表达式 → group_by 实际算出的结果;这些数值并不代表现实销售规模。

图下半部则用同一批数据检查行序变化:先对未排序的 joined.collect() 调用 with_row_index("join_position") 记下返回位置,再固定抽出 O-00001 到 O-00012。在保存的 1.43.2 运行结果中,这 12 笔位于第 0—11 行;在 2.0.0 的运行结果里位置则分散得多,例如 O-00001 出现在第 431 行。具体位置由当次执行记录决定,重复运行也可能继续变化。如果报表或导出文件依赖行序,就要对业务键显式 sort(),不能把图中的任何位置当成 API 保证。
02 再看类型:普通订单金额之外的精度探针
几千分、几万分的订单金额,通常不会触碰到浮点数在大整数精度上的边界。为了专门观察类型提升,演示脚本另外设计了四组 Int64 + UInt64 整数探针;这些大整数只是边界样本,与订单金额无关,也没有混进上面的销售图表。
其中最直观的一组是 Int64(1) + UInt64(9007199254740993),精确整数和为 9007199254740994。9007199254740993 已经超过 Float64 能逐个精确表示整数的边界。两版的保存结果如下:1.43.2 将混合结果解析为 Float64,本组值为 9007199254740992.0,与精确和不一致;2.0.0 的结果类型为 Int128,值为 9007199254740994,精确匹配。官方迁移指南描述的正是有符号整数与 UInt64 混算时,supertype 从可能有损的 Float64 改为 Int128。这并不表示所有整数表达式都会变化,也不表示更精确的结果本身是退步;迁移时还是得看下游能否接受新的 schema。
完整四组探针中,1.43.2 的结果 schema 为 Float64,有三组与精确整数期望不一致;2.0.0 的结果 schema 为 Int128,四组全部精确匹配。下图逐项列出了实际值和整数期望;它属于刻意构造的边界测试,既不能代表普通订单金额,也不能外推到所有表达式。

因此,类型检查不能只看查询是否成功。探针的关键几行可以这样写,expected 用 Python 整数计算,再把 Polars 的结果与它逐条比较。以下片段沿用上方代码块的库导入;单独复制时需先完成相同的导入。两个代码片段只展示查询逻辑,跨版本对照来自分别运行并保存的完整脚本:
probe = pl.LazyFrame({
"signed_int64": pl.Series([1], dtype=pl.Int64),
"unsigned_uint64": pl.Series([2**53 + 1], dtype=pl.UInt64),
}).with_columns(
(pl.col("signed_int64") + pl.col("unsigned_uint64")).alias("mixed_sum")
)
expected = 2**53 + 2
print(probe.collect_schema()["mixed_sum"])
print(probe.collect()["mixed_sum"][0] == expected)
如果下游的序列化、数据库列或接口固定要求某种类型,那么除了数值变得更准确之外,还要单独核对 schema 的相容性。
03 默认流式,不代表任意查询都能超出内存
Polars 2.0 的发布说明列出了初步支持溢写磁盘的操作,比如排序、窗口函数和不少表达式;但发布时 join 与 group_by 还没被列入 out-of-core 的能力保证。本文只验证了这组小型合成数据的输出,没有测试运行时间、峰值内存或磁盘占用,也没有做溢写实验。处理高内存任务时,还是得在自己的数据规模和运行环境里单独测量,不能光凭默认引擎的切换就推断出速度或资源结论。

04 写在最后
升级时,建议先固定两个版本和输入数据,比较排序后的完整记录。接着排查下游有没有依赖行序的地方,有就显式排序;对于关键的混合类型表达式,要同时核对计算结果与 collect_schema()。高内存任务还需要另外测量时间、峰值内存和磁盘需求。这份订单汇总与整数探针只针对各自的输入,不能替代生产规模的回归测试。