文章目录
- 一、先看一个真实的翻车
- 二、事故一:一对多,行数守恒破灭
- 三、对账三件套:validate、indicator、键体检
- 四、安全 merge 清单
- 五、30 秒带走版
一、先看一个真实的翻车
两份表对账:左边 3 行销售记录,右边 4 行单价表(其中 001 号有 3 条记录)。一个 left join,看起来人畜无害:
importpandasaspd sales=pd.DataFrame({"口岸编号":["001","002","003"],"金额":[100,200,300]})price=pd.DataFrame({"口岸编号":["001","001","001","002"],"单价":[10,99,20,30]})bad=sales.merge(price,on="口岸编号",how="left")print(len(bad))# 5——left 只有 3 行print(bad["金额"].sum())# 800——原始合计是 6003 行变 5 行,金额合计从 600 变 800,凭空多出三分之一。没有任何报错,没有任何警告,DataFrame 长得干干净净。这类事故在对账脚本里是最贵的一种:报表数字变了,但没人知道是哪一行开始错的。本篇把 merge 的五类坑和一整套对账方法全部实测一遍(pandas 3.0.3)。
二、事故一:一对多,行数守恒破灭
行数放大的机制很直白:left 的每一行,会与 right 中所有同键行配对。right 的 001 有 3 条,left 的 001 就被复制成 3 行(图一左)。金额列原样跟着复制,sum()直接把重复部分累加进去——join 是行级操作,sum 是列级操作,中间的放大没人替你看着。
更扎心的是同一份数据的另一种写法。price 表里没有 003 的单价,把 how 换成默认的 inner:4 行,sum 500——003 连行带金额被静默丢掉;而 left 保留 003(单价列明明白白是 NaN), sum 800。
三个数字放在一起:真值 600,inner 给 500,left 给 800。丢行和放大,两个方向都错,而中间那个正确的 600,两种默认姿势一个都给不出。很多人不知道 merge 的默认 how 是 inner——不写参数,就等于让 pandas 替你决定丢不丢数据;而对账场景里,显式写 how 只是及格线,后面两节的三件套才是真正的保险。
三、对账三件套:validate、indicator、键体检
行数放大和静默丢行,都有标准的拦截手段,加起来三件(图二):
第一件,merge 前 validate。一个参数声明你期望的基数,pandas 帮你验:
importpandasaspd sales=pd.DataFrame({"口岸编号":["001","002","003"],"金额":[100,200,300]})price=pd.DataFrame({"口岸编号":["001","001","001","002"],"单价":[10,99,20,30]})sales.merge(price,on="口岸编号",how="left",validate="one_to_many")# 放行:right 可多sales.merge(price,on="口岸编号",how="left",validate="one_to_one")# 抛 MergeError实测声明 one_to_one 直接抛MergeError,文案点名哪个键重复——一行参数换一次事前拦截。基数声明本身就是文档:看到 one_to_many,读代码的人立刻知道 right 故意有重复。它还有个隐藏好处:把「预期」写进代码。多数对账 bug 不是工具错,是上游数据悄悄变了——昨天还唯一的键,今天渠道表重修了一遍。validate 让这种变化在 merge 那一刻就炸,而不是等到月度报表差出三万块。
顺带把 3.0 的新玩具用对地方:反连接left_anti取「只在左边」的行,以前得 left join 完再过滤_merge列,现在一步到位,语义还更直白(SQL 的 NOT EXISTS)。找孤儿记录、找缺失覆盖,都比以前干净。
第二件,merge 后 indicator。indicator=True加一列_merge,每行标来源:
| _merge | 含义 |
|---|---|
| both | 两边都有 |
| left_only | 只在左边 |
| right_only | 只在右边 |
实测样本 both 2 / left_only 1 / right_only 1。它还自带一个对账恒等式:both + left_only = left 行数,both + right_only = right 行数——两边一验,平不平一目了然。对账不平先看这列,不用猜。
这列在真实工作流里是这么用的:月度对账跑完,先把 _merge 列的三个计数打出来;left_only 突增,说明上游开始漏 Key;right_only 从零变正,说明有新渠道没进主表;both 占比掉一个点,说明匹配质量在退化。把对账从「数字对不上再回头查」变成「三类行数先说话」,这就是 indicator 的真正价值——它不修 bug,它让 bug 在汇总之前现形。
第三件,键体检。三类问题按顺序查:
- 键类型:一边 int64 一边 str。pandas 3.0 实测直接抛
ValueError(老版本会静默产出匹配不上的结果)——这是 3.0 少有的「把静默故障改成显式报错」的改进,升级后老代码反而更安全; - 键唯一性:
duplicated().sum()或直接交给 validate; - NaN 语义:这条最反直觉——两个 NaN 键会互相匹配。和 SQL 反着来(SQL 里 NULL 不等于 NULL),pandas 官方文档明确警告过这个行为。实测 left 的 NaN 键行成功匹配到了 right 的 NaN 键行。后果:主键缺失的行会悄悄跨表认亲,
dropna()或填哨兵值,看你的业务。
四、安全 merge 清单
把上面的东西收成一张按顺序过的清单(图三):
- 键类型对齐:两侧键 dtype 一致;ID 列在
read_csv时就用dtype=str读进来(上一篇 read_csv 的坑在这里兑现——前导零丢了的键,join 必然对不上); - 键唯一性检查:
duplicated().sum()心里有数,或validate=声明基数; - how 显式写:对账不用默认 inner,left/outer 保行,丢失可见;
- indicator 收尾:
_merge三分类 + 恒等式验平,再进汇总; - NaN 键心里有数:会互相匹配,必要时先
dropna或填哨兵值。
版本注脚:pandas 3.0 起 int64×str 键直接报错、新增left_anti/right_anti反连接(SQL 的 NOT EXISTS,实测反连接写法干净取出只在左边的行)、copy参数已弃用——同一份 merge 代码,2.x 与 3.x 的成败表现并不一样。
五、30 秒带走版
- 一对多 merge 复制行:3 行变 5 行、sum 600 变 700,零报错——join 是行级,sum 是列级,中间没人看着;
- 默认的 inner 姿势会静默丢行:对账一律显式写 how,丢失必须可见;
validate=事前声明基数,重复键当场 MergeError;indicator=True事后三分类,both+left_only=left 行数恒等式验平;- 键类型不一致:3.0 直接 ValueError(老版本静默)——升级后老代码反而更安全;
- 两个 NaN 键会互相匹配,与 SQL 的 NULL 语义相反,官方文档明示;
- ID 键在
read_csv时dtype=str,前导零丢了 join 必错; - 3.0 新玩具:
left_anti反连接,取「只在左边」不用再 left join + 过滤。
本文全部实验基于 pandas 3.0.3 本地实测,样本为构造的小表;2.x 与 3.x 行为差异处已注明版本,读者请以自己环境的实测为准。