Polars 不是 SQL:三个语义坑,两个活到了 2.x¶
本文所有报错原文均来自可复现脚本:
experiments/polars_duckdb_semantics.py→evidence/polars_duckdb_semantics.json。 实验环境:WSL2 / Python 3.12 / polars 2.1.0 / duckdb 2.0.0。 说明:原始事故发生在 polars 0.20.x 时代;本文用 2.1.0 复现,目的是证明这些是语义而非某个版本的 bug。
一、现象¶
一次 Polars 版本升级,让我的集成测试集中爆出一批错误。修完后我在 commit 里留下了三类修复:
with_columns同一批次里引用本批新建的列,报错;str.starts_with传入元组做"多前缀匹配",报错;pct_change的关键字参数名变了,报错。
当时我以为是升级踩到 API 变更,改完就翻篇。直到后来做了一次系统性的复现实验才发现:前两个根本不是 API 变更,而是求值语义——它们在新版 2.1.0 上一个字不改地原样复现。 我当初"改掉就完事"的理解,一半是错的。
二、我一开始的错误判断¶
当时的排错顺序,就是那次升级收尾提交(1f538c0)的 diff 顺序:集成测试报什么修什么——同批 with_columns 拆开、starts_with 换显式连接、pct_change 改参数名、DuckDB 视图注册补上——一个下午全部“修完”,commit 信息写的也是中性的 "Fix integration test issues"。问题恰恰出在修得太顺:每个报错都被当成孤立的 API 变更处理掉,没有一个被追问“你到底是哪一类”。直到为写这篇文章做复现实验,才发现其中两个在新版上一字不改地原样复现——“修好了”和“理解了”之间隔着的,就是这次补做的实验。
典型误判三连,对照看看你中了几条:
- 第一反应是"库的 bug"。同一个
with_columns里第二行引用第一行刚建的列,SQL 里这么写没问题(SELECT 同层别名互引是合法的),pandas 的链式写法也是顺序执行的——为什么 Polars 不行?一定是 bug。 - 第二反应是背 API。把报错一个个搜掉、改掉,至于"为什么不能这么写",没有问。
- 把 DuckDB 的视图当成一张真表。注册了就以为"存进去了",直到 schema 漂移把整条查询链炸穿才意识到视图只是"一个定义"。
三、排查与关键证据¶
三个语义点,各自的"错误写法 → 实测报错原文 → 正确写法 → 结果":
坑 1:同批 with_columns 引用本批新建列
df.with_columns([
(pl.col("close") * 2).alias("double"),
(pl.col("close") / pl.col("double")).alias("ratio"), # ← 引用本批的 double
])
实测报错(polars 2.1.0):ColumnNotFoundError: unable to find column "double"; valid columns: ["close", "code"]
注意报错里"valid columns"不含 double——在这一批表达式里,新建列对同批的其他表达式不存在。
正确写法:拆成两次 with_columns。实测通过。
坑 2:starts_with 传元组
pl.col("code").str.starts_with(("600", "000"))
实测报错(polars 2.1.0):InvalidOperationError: cannot cast List type (inner: 'String', to: 'String')
正确写法:多个前缀用 | 显式连接。实测通过。
坑 3:pct_change 的关键字名
pl.col("close").pct_change(periods=10) # pandas 的习惯
实测报错:TypeError: Expr.pct_change() got an unexpected keyword argument 'periods'
正确写法:pct_change(n=10)。实测通过。
附加发现:DuckDB 的坑被官方"悄悄填了"。 原始事故里还有一个 DuckDB 视图问题:视图是对 parquet 目录的 glob 定义,当目录里的文件列结构不一致(schema 漂移)时查询会炸。但在 duckdb 2.0.0 上实测:同样的 glob、同样的列结构不一致,查询不再报错——新版自动按列名做了并集。这意味着"这个坑"是版本相关行为:老版本炸、新版本静默换语义。依赖它报错的代码,升级后反而要小心。
四、根因¶
with_columns是声明式、批内无序求值。一批表达式之间没有"先后",所以"先建的列对后建的可见"这个前提不成立。这不是缺陷,是求值模型——SQL 的 SELECT 同层别名互引之所以可行,是因为数据库做了逻辑计划的特殊处理,而 Polars 选择把表达式当成相互独立的树。- 类型契约比直觉严格。"多前缀匹配"在 pandas 里是元组/列表,在 Polars 的表达式系统里前缀就是一个字符串;传集合不是"宽松点也能跑",而是类型层面直接拒绝。
- API 改名是表象,语义不变是本质。
periods→n这种纯属迁移工单;坑 1、坑 2 才是需要更新思维模型的部分。 - 视图 = 惰性定义,不是数据快照。glob 视图在每次查询时重新扫描目录,schema 漂移的处理方式随版本演化(老版报错 → 新版按名并集)。
五、约束与取舍¶
- 放弃了写一个"兼容层"把新旧 API 封装起来。代价:调试时看到的不再是真实报错,坑被藏到更深处。
- 接受了"拆批次"带来的啰嗦。换来的是数据流每一步的可见性与可预测性。
- 放弃了 pandas/Polars 双栈长期并行的打算。代价:所有调用点一次性迁移;收益:不会出现"同一个语义两个库两套行为"的心智负担。
六、可复用检查清单¶
- 升级前先跑全量测试拿到"报错清单",按类归因(语义 vs API 名),而不是逐个救火;
- 检索代码里所有"同批引用新建列"的写法,一律拆批——不要试图绕过;
- 凡是"目录即表"的视图,CI 里放一个 schema 漂移探测:往目录里扔一份列结构不同的文件,期望"明确报警"——如果没报,说明你的版本行为已经变了,把这一点写进升级记录;
- 查报错先问"求值模型是什么"(声明式 or 过程式),再查 API 名字;
- 迁移完成的标志不是"能跑",而是新旧输出在固定样本上对拍一致。
复现材料¶
- 脚本:
experiments/polars_duckdb_semantics.py - 原始结果:
evidence/polars_duckdb_semantics.json/.md(含全部报错原文与修复对照) - 实验版本:polars 2.1.0、duckdb 2.0.0、Python 3.12