推荐使用 dictdiffer 进行结构化 diff,它专为嵌套字典设计,输出可遍历的差异操作列表(add/change/remove),保留路径层级且精度高;需注意空值处理、不可哈希类型预过滤及忽略逻辑应置于存在性检查前。

直接用 dictdiffer 做结构化 diff 最省心
它专为嵌套字典设计,输出是可遍历的差异操作列表(add、change、remove),不递归展开值,保留路径层级。比手写递归或用 json.dumps 字符串比对更准——后者会忽略键序、类型隐式转换、浮点精度等细节。
安装后直接调用:
from dictdiffer import diff返回形如
result = list(diff(dict_a, dict_b))
[('change', ['user', 'profile', 'age'], (25, 26)), ('add', ['user', 'tags'], [('new', 'active')])] 的元组列表,路径用列表表示,改动类型和新旧值一目了然。
注意点:
-
dictdiffer默认把None和缺失键视为不同,若想忽略空值差异,需预处理:把两边都转成统一缺省值(如{}或None)再比 - 对含自定义对象、函数、不可哈希类型的 dict 会抛
TypeError,得先用copy.deepcopy+ 类型过滤剥离
手动递归对比时必须处理路径追踪和类型分支
自己写递归不是不行,但容易漏掉三类关键逻辑:路径拼接错误、同键不同类型未区分、集合/列表内容顺序敏感性误判。核心是每层递归传入当前路径(如 ['config', 'database']),并在进入子结构前做类型校验。
示例关键判断逻辑:
def compare_dicts(d1, d2, path=None):
if path is None:
path = []
for k in set(d1.keys()) | set(d2.keys()):
cur_path = path + [k]
if k not in d1:
print(f"MISSING: {cur_path} in d1")
elif k not in d2:
print(f"MISSING: {cur_path} in d2")
else:
v1, v2 = d1[k], d2[k]
if type(v1) != type(v2):
print(f"TYPE_MISMATCH: {cur_path} — {type(v1).__name__} vs {type(v2).__name__}")
elif isinstance(v1, dict) and isinstance(v2, dict):
compare_dicts(v1, v2, cur_path)
elif v1 != v2:
print(f"VALUE_CHANGE: {cur_path} — {v1} → {v2}")
常见坑:
- 用
str(path)拼接路径导致嵌套列表变字符串,后续无法定位;必须保持list类型 - 没判断
isinstance(v1, (list, tuple))就直接==,会把[1,2]和(1,2)判为相同 - 对浮点数用
==而非math.isclose,小精度误差直接标为变更
用 deepdiff 以外的轻量替代:dictdiffer vs jsonpatch
jsonpatch 本质是生成符合 RFC 6902 的补丁操作,适合需要序列化、传输或回滚的场景;dictdiffer 更侧重人眼可读的结构差异。两者都不依赖 deepdiff,且体积小(jsonpatch 仅一个文件)。
选哪个?
- 要生成可执行 patch 并应用到另一份数据上 → 用
jsonpatch.make_patch(dict_a, dict_b),输出是标准 JSON Patch 数组 - 只需快速定位哪几层键变了、增删了哪些字段 →
dictdiffer返回原生 Python 结构,不用解析 JSON - 对比含 datetime、Decimal 等非 JSON 原生类型的 dict → 两者都会报错,必须先用
default参数做序列化预处理
忽略某些键或路径时别硬编码,用白名单/黑名单函数
实际业务中常要忽略 updated_at、id、version 这类元字段。在 dictdiffer 或手写递归里,最稳的方式是传入一个 ignore_func,而不是在循环里写一堆 if k in ['id', 'version']。
例如:
def should_ignore(path):然后在 diff 主逻辑中加判断:
return any(
p in ['id', 'updated_at', 'version'] for p in path
) or len(path) > 5 # 限制深度防止爆炸
if should_ignore(cur_path): continue。
容易被忽略的点:
- 路径是逐级累积的,
['user', 'profile', 'id']中'id'在末尾,但['id']本身也可能是一级键,所以要用any(...)而非path[-1] == 'id' - 忽略逻辑必须放在「键存在性检查之前」,否则
k not in d1这类缺失判断也会被跳过,导致漏报真正缺失的字段










