适用版本 :Python 3.10+ / xlwings 0.24.3+ / pandas 1.1+(
compare)/ numpy 2.x依赖 :
xlwings、numpy、pandas平台:Windows(xlwings 需要本机 Excel;§4 的纯 pandas 方案跨平台)
做过数据相关工作的朋友,大概率都遇到过这种场景:
- 版本核对:同事发过来一份"修改版.xlsx",想知道跟原版改了哪几格
- 合同修订:甲乙双方来回发了好几个版本,要确认改动是不是只在约定的地方
- 报表复核:系统导出了两份报表,数据对不上,得找出差异在哪
这种事手动干简直是噩梦------眼睛盯着两张表逐行找,找十分钟眼都花了,还不一定找得全。
今天分享一下我做这件事的演进过程:从最朴素的逐格对比,到优化版的批量读取,再到生产级的完整实现。每一步都是踩过坑之后的升级,看完你就能直接拿去用。
1. 朴素版:逐单元格对比
最直觉的写法------两层循环,一个格子一个格子地比。思路简单,写出来也快:
python
import xlwings as xw
from pathlib import Path
DATA_DIR = Path(r"D:\MTCResearch\Python-excels\data")
with xw.App(visible=False, add_book=False) as app:
book = app.books.open(DATA_DIR / "期末考试-学生成绩表.xlsx")
book_backup = app.books.open(DATA_DIR / "期末考试-学生成绩表 - 备份.xlsx")
# expand() 默认模式 "table":从 A1 向下、向右扩展到连续数据边界
for row in book.sheets[0].range("A1").expand():
for cell in row:
# 用当前单元格的地址(如 "B3")直接去备份表定位
backup_cell = book_backup.sheets[0].range(cell.address)
if cell.value != backup_cell.value:
cell.color = (255, 0, 0) # 主表标红
backup_cell.color = (255, 0, 0) # 备份表也标红
book.save(); book.close()
book_backup.save(); book_backup.close()
能跑,但问题一大堆。
1.1 性能极差------最大的硬伤
每读一次 cell.value、每设一次 cell.color,都是一次跨进程的 COM 调用。Windows 上单程大概 0.1~1 毫秒,听起来不多,但架不住量大啊。
| 单元格数 | 预估耗时 |
|---|---|
| 100 | < 1 秒 |
| 1,000 | 数秒 |
| 10,000 | 半分钟以上 |
| 100,000 | 几分钟,还容易超时 |
解决思路:一次性把整片区域读成二维数组,在 Python 内存里比完,只对差异格下发着色指令。COM 交互从 O(N) 降到 O(1)。
1.2 没有异常保护,Excel 进程会残留
中间任何一步报错(文件被占用、格式损坏),进程就退不出去,任务管理器里堆满 EXCEL.EXE,下次打开文件还提示"被锁定为只读"。
解决思路 :用 with xw.App() as app:(xlwings ≥ 0.24.3),异常也能保证 quit()。
1.3 对比范围以主表为准
expand() 只按主表的范围遍历:
- 备份表行数/列数更多 → 多出来的部分根本不检查
- 备份表更小 →
backup_cell落在空白区,读到None,会被判为差异(如果超出工作表最大行列限制还会直接抛异常)
解决思路:对比前先取两表尺寸的并集,区分"值不同"与"仅一侧存在"。
1.4 比较规则过于粗暴
python
0 != None # True ------ 一个是数字 0,一个是空单元格
88.1 != 88.10000000001 # True ------ 浮点尾差
" 张三" != "张三" # True ------ 首尾空格
这些在业务上可能被视为"一样"的东西,代码会全部当成差异。
解决思路:先做归一化,数值用容差比较。
1.5 路径硬编码
路径写死在代码里,换台机器就跑不了。解决思路 :抽成常量或者用 pathlib + 配置文件。
2. 优化版:批量读取 + 数组对比
知道问题在哪就好办了。把 COM 交互次数从 O(单元格数) 降到 O(1),性能直接提升几个数量级:
python
import numpy as np
import xlwings as xw
from pathlib import Path
DATA_DIR = Path(r"D:\MTCResearch\Python-excels\data")
FILE_A = DATA_DIR / "期末考试-学生成绩表.xlsx"
FILE_B = DATA_DIR / "期末考试-学生成绩表 - 备份.xlsx"
with xw.App(visible=False, add_book=False) as app:
book = app.books.open(FILE_A)
book_backup = app.books.open(FILE_B)
sht = book.sheets[0]
sht_bak = book_backup.sheets[0]
# 一次性读取全部值 → 二维 list,Python 下标从 0 开始
data1 = sht.range("A1").expand().value
data2 = sht_bak.range("A1").expand().value
for row_idx, row in enumerate(data1):
for col_idx, val1 in enumerate(row):
val2 = data2[row_idx][col_idx]
# 空值统一成 "",避免 None 与 "" 的误判
v1 = val1 if val1 is not None else ""
v2 = val2 if val2 is not None else ""
if v1 != v2:
# xlwings 的 range(行, 列) 编号从 1 开始!
sht.range(row_idx + 1, col_idx + 1).color = (0, 255, 0) # 主表:绿
sht_bak.range(row_idx + 1, col_idx + 1).color = (0, 0, 255) # 备份表:蓝
book.save(); book.close()
book_backup.save(); book_backup.close()
提速效果 :COM 调用从 2N 次(读+写)降到 1 + 2D 次(1 次读 + D 个差异格着色)。1 万格的表格通常从几十秒降到 1 秒以内。
但还有几个问题没解决:
| 剩余问题 | 后果 |
|---|---|
data2[row_idx][col_idx] 未做边界检查 |
备份表更小时直接 IndexError |
未处理 .value 的维度退化 |
见下方详细说明 |
注释写"备份表同步标红",代码却是 (0,0,255) 蓝色 |
注释与代码不一致,容易误导 |
None → "" 并不能修复 0 vs 空单元格的误判 |
见下方详细说明 |
2.1 一个容易忽略的坑:.value 的维度退化
这是 xlwings 里一个特别容易踩的坑------Range.value 的返回值不是固定的二维结构:
| 区域 | 返回值 | enumerate 的结果 |
|---|---|---|
| 多行多列 | [[...], [...]] 二维 list |
正常 |
| 单行(如 A1:F1) | [v1, v2, ...] 一维 list |
row 是标量 → 内层 enumerate(row) 报错 |
| 单列(如 A1:A20) | [v1, v2, ...] 一维 list |
同上 |
| 单个单元格 | 标量(如 123) |
enumerate(123) 直接 TypeError |
表格数据多的时候你可能永远不会触发这个问题,但哪天遇到个单行单列的小表,代码就崩了,而且还挺难想到是这个原因。
稳妥写法:用 np.array(value, dtype=object, ndmin=2) 强制成二维:
python
import numpy as np
np.array([1, 2, 3], dtype=object, ndmin=2).shape # (1, 3) ← 一维升成 1 行
np.array([[1, 2, 3]], dtype=object, ndmin=2).shape # (1, 3)
np.array(5, dtype=object, ndmin=2).shape # (1, 1) ← 标量升成 1×1
2.2 None → "" 到底修了什么
优化版里的注释说这样能"避免 None != '' 误判"。实际测一下:
python
(0 if 0 is not None else "") == (None if None is not None else "")
# 0 == "" → False
结论 :这个替换只修了 None vs ""(空字符串)这一种情形,而 Excel 里空单元格读出的是 None、数字 0 读出的是 0.0, 0 != "" 依然为 True,误判并没有被消除。
真正稳妥的做法是显式归一化 + 分类比较,并根据业务决定是否把"空"与"0"视为等价。
3. 生产级实现
把上面提到的坑都填上,就有了这个可以拿去干活的版本:
python
import math
from pathlib import Path
import numpy as np
import xlwings as xw
DATA_DIR = Path(r"D:\MTCResearch\Python-excels\data")
FILE_A = DATA_DIR / "期末考试-学生成绩表.xlsx"
FILE_B = DATA_DIR / "期末考试-学生成绩表 - 备份.xlsx"
# 配色:差异类型一眼可辨
COLOR_CHANGED_A = (255, 192, 0) # 主表·值被修改 → 琥珀
COLOR_CHANGED_B = (0, 176, 240) # 备份表·值被修改 → 天蓝
COLOR_ONE_SIDE = (255, 0, 0) # 仅一侧存在 → 红
REL_TOL = 1e-9 # 浮点相对容差
ABS_TOL = 1e-9 # 浮点绝对容差
BLANK_AS_ZERO = False # 是否把"空单元格"与 0 视为等价
def normalize(v):
"""把 None 与纯空白字符串统一成 None,避免首尾空格造成误判。"""
if v is None:
return None
if isinstance(v, str) and v.strip() == "":
return None
return v
def is_number(v):
"""bool 是 int 的子类,要排除,否则 True/False 会被当数值比较。"""
return isinstance(v, (int, float)) and not isinstance(v, bool)
def values_equal(a, b):
"""统一的相等判定:归一化 → 空值处理 → 数值容差 / 直接相等。"""
a, b = normalize(a), normalize(b)
if a is None and b is None:
return True
if a is None or b is None:
if not BLANK_AS_ZERO:
return False # 一个有值一个为空 → 记为差异
a = 0 if a is None else a
b = 0 if b is None else b
if is_number(a) and is_number(b):
return math.isclose(float(a), float(b),
rel_tol=REL_TOL, abs_tol=ABS_TOL)
return a == b
def to_2d(value):
"""把 xlwings 的 .value 强制成二维 object 数组,规避维度退化。"""
return np.array(value, dtype=object, ndmin=2)
def col_letter(n):
"""列号(从 1 开始)转 Excel 列标:1→A, 27→AA。"""
s = ""
while n:
n, r = divmod(n - 1, 26)
s = chr(65 + r) + s
return s
with xw.App(visible=False, add_book=False) as app:
book = app.books.open(FILE_A)
book_backup = app.books.open(FILE_B)
try:
sht, sht_bak = book.sheets[0], book_backup.sheets[0]
# 一次性读取(各 1 次 COM 交互)
data1 = to_2d(sht.range("A1").expand().value)
data2 = to_2d(sht_bak.range("A1").expand().value)
# 取并集作为对比范围
rows = max(data1.shape[0], data2.shape[0])
cols = max(data1.shape[1], data2.shape[1])
print(f"主表 {data1.shape} / 备份 {data2.shape} / 对比范围 {rows}×{cols}")
if data1.shape != data2.shape:
print("⚠️ 两表尺寸不一致,超出部分按『仅一侧存在』处理")
diffs = []
for r in range(rows):
for c in range(cols):
# 越界保护:落在任一表范围之外的值视为 None
v1 = data1[r, c] if (r < data1.shape[0] and c < data1.shape[1]) else None
v2 = data2[r, c] if (r < data2.shape[0] and c < data2.shape[1]) else None
if values_equal(v1, v2):
continue
one_side = not (r < data1.shape[0] and c < data1.shape[1]
and r < data2.shape[0] and c < data2.shape[1])
if one_side:
sht.range(r + 1, c + 1).color = COLOR_ONE_SIDE
sht_bak.range(r + 1, c + 1).color = COLOR_ONE_SIDE
else:
sht.range(r + 1, c + 1).color = COLOR_CHANGED_A
sht_bak.range(r + 1, c + 1).color = COLOR_CHANGED_B
diffs.append((r, c, v1, v2, one_side))
print(f"\n共 {len(diffs)} 处差异:")
for r, c, v1, v2, one_side in diffs[:50]:
flag = "【仅一侧存在】" if one_side else ""
print(f" {col_letter(c + 1)}{r + 1}: {v1!r} → {v2!r} {flag}")
if len(diffs) > 50:
print(f" ...(其余 {len(diffs) - 50} 处已省略)")
book.save()
book_backup.save()
finally:
# 保证工作簿一定被关闭,不会锁死文件
book.close()
book_backup.close()
相比优化版,主要做了这几件事:
to_2d()彻底规避维度退化,单行单列单格都不会崩- 取两表尺寸并集,越界处按"仅一侧存在"处理,不再
IndexError values_equal()统一归一化 + 浮点容差,消除尾差和空格误报- 输出可读的差异清单(单元格地址 + 旧值 → 新值),而不只是涂色
性能提示 :差异格很多时,逐格
.color仍然是主要开销(每格一次 COM 调用)。这种时候建议改为生成差异报告(写一个新 sheet 或 CSV),或者改用 Excel 的"条件格式"/"修订"功能。
4. 如果只关心数据(不关心格式)
上面的方案都是基于 xlwings 的,好处是能在原文件上标色、保留格式,但缺点是依赖 Windows + Excel、速度受 COM 限制。
如果你不需要保留 Excel 原格式,只是想知道数据上有什么差异,那纯 pandas 方案更简洁、更快、还能跨平台:
python
import pandas as pd
from pathlib import Path
DATA_DIR = Path(r"D:\MTCResearch\Python-excels\data")
df1 = pd.read_excel(DATA_DIR / "期末考试-学生成绩表.xlsx")
df2 = pd.read_excel(DATA_DIR / "期末考试-学生成绩表 - 备份.xlsx")
# ---------- 方案 A:compare(),直接给出"改前 / 改后" ----------
# 要求:两表的行索引与列名完全一致
if df1.shape == df2.shape:
diff = df1.compare(df2)
print(diff)
# 成绩 姓名
# self other self other
# 1 85.0 90.0 NaN NaN
# 3 NaN NaN 李四 李肆
#
# self = 左表(df1)的值,other = 右表(df2)的值,未变化的位置是 NaN
# ---------- 方案 B:ne() + any(),逐行标记是否有差异 ----------
mask = df1.ne(df2).any(axis=1)
print(df1[mask]) # 主表中"有差异"的