PX06-对比两个 Excel 文件的差异:从逐单元格到生产级实现

适用版本 :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])          # 主表中"有差异"的
相关推荐
Ticnix2 小时前
MCP 上个月把自己推翻重写了:Session 没了、Sampling 废了——你学的教程还停在 2025
python·agent·全栈
“AI国潮设计-小江”2 小时前
[AIGC实战] 基于Stable Diffusion的潮汕非遗IP自动化生成工作流(附Python批量处理脚本)
开发语言·人工智能·python·prompt·aigc
LOVE️YOU2 小时前
Python 中的 range 是类还是函数?range 对象该怎么理解?
python
VidDown3 小时前
从视频画面里提取文字:OCR、文字检测与结构化输出
python·网络协议·ocr·音视频·视频编解码·视频
言乐63 小时前
Python实现ai进化
python·django·virtualenv·pygame·tornado
南笙北梦3 小时前
从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查
python
硫酸锌014 小时前
在手机上运行Python增量备份手机数据到PC电脑
android·windows·python
沐欣工作室_lvyiyi4 小时前
家庭水资源节能系统设计(论文+源码)
c++·python·云平台·单片机毕业设计·单片机仿真·电子信息工程毕业设计·机械电子工程
FelixZhang0284 小时前
量化求真10|回测通过以后,策略就能上场吗?
人工智能·python·深度学习·学习·机器学习·金融·lstm