How-to
Automating Reporting WorkflowsDeep dive

Diff Excel Files in Git with a textconv Driver

Make git diff show what changed inside a workbook instead of "Binary files differ": a small Python converter that prints every cell as sheet, address, value and formula, a .gitattributes entry and a diff driver that use it, readable diffs of templates and golden files in pull requests, and the limits of the approach.

Report templates, golden output files for tests and small reference workbooks often live in the same repository as the code that uses them. Their history is then useless in review: change one cell in a template and git diff says only Binary files a/template.xlsx and b/template.xlsx differ. Git has a mechanism for exactly this — a textconv diff driver converts a binary file to text before diffing it. With a thirty-line Python script that prints each cell as a line of text, git diff, git log -p and pull request reviews show which cells changed on which sheet, including formulas. This page is part of Testing and Packaging Excel Automation Scripts.

Prerequisites

Bash
pip install openpyxl
# git 2.x

pricing_template.xlsx is a small template with an Inputs sheet holding a VAT rate and an exchange rate, and a Calc sheet whose gross column is a formula that reads the VAT rate. It will be committed, changed in two ways — an input value and a formula — and diffed.

How git diffs a workbook with textconv For each side of the diff git runs the textconv command on the workbook, the Python script prints one line per cell with sheet, address, value and formula, and git diffs the two text versions line by line. git diff template.xlsx changed .gitattributes *.xlsx diff=excel textconv script python xlsx2text.py Text per cell Sheet!A1 = value Line diff changed cells only

Step 1 — The converter

The converter prints one line per non-empty cell: sheet and address, then the formula if there is one, otherwise the value. Loading without data_only keeps formulas as text, which is what a reviewer needs to see:

Python
import sys
from openpyxl import load_workbook

def dump(path, out=sys.stdout):
    wb = load_workbook(path, data_only=False, read_only=False)
    for ws in wb.worksheets:
        out.write(f"=== {ws.title} ({ws.sheet_state}) ===\n")
        for row in ws.iter_rows():
            for cell in row:
                if cell.value is None:
                    continue
                value = cell.value
                if isinstance(value, float):
                    value = repr(value)
                out.write(f"{ws.title}!{cell.coordinate}\t{value}\n")
    for name, dn in wb.defined_names.items():
        out.write(f"name {name} = {dn.attr_text}\n")

if __name__ == "__main__":
    dump(sys.argv[1])
Bash
python xlsx2text.py pricing_template.xlsx

Every line carries its own sheet and address, so a change shows up as exactly one changed line — and a line diff can never pair a cell on one sheet with a cell on another. Floats are printed with repr so 0.2 and 0.20000000000000001 are distinguishable. The sheet header includes the sheet's state, so hiding a sheet shows up in the diff too; defined names are listed at the end because a changed named range changes what formulas compute.

Step 2 — Tell git to use it

Two pieces of configuration: .gitattributes, which is committed, says which files use the excel diff driver; the driver's command is set in git configuration, which each clone sets for itself because git does not run commands from a repository's committed files:

Bash
git init -q repo && cp pricing_template.xlsx xlsx2text.py repo/ && cd repo
printf '*.xlsx diff=excel\n*.xlsm diff=excel\n' > .gitattributes
git config diff.excel.textconv "python xlsx2text.py"
git config diff.excel.cachetextconv true
git -c user.name=demo -c user.email=demo@example.com add . && git -c user.name=demo -c user.email=demo@example.com commit -qm "add pricing template"
git log --oneline | wc -l | xargs echo "commits:"
What is shared and what each clone sets Two layers. The .gitattributes file with the diff=excel entry is committed and shared by everyone who clones the repository. The textconv command and cachetextconv setting live in each clone's git configuration, because git never runs commands defined by a repository's committed files. .gitattributes — committed *.xlsx diff=excel · *.xlsm diff=excel .git/config — per clone diff.excel.textconv = python xlsx2text.py · cachetextconv = true

cachetextconv stores each converted version in git's notes so that git log -p over a long history does not reconvert the same workbook every time. The textconv command runs in the repository root, so a relative path to the script works; for a converter used across repositories, install it as a command and put that on PATH instead. Adding the git config lines to the project's setup instructions — or to a make setup script — keeps every developer's diffs readable.

Step 3 — Change the workbook and diff it

Edit the template the way a colleague would: a new VAT rate and a rounded gross formula. Then look at the diff:

Python
from openpyxl import load_workbook

wb = load_workbook("repo/pricing_template.xlsx")
wb["Inputs"]["B2"] = 0.21
for r in (2, 3):
    wb["Calc"][f"C{r}"] = f"=ROUND(B{r}*(1+Inputs!B2),0)"
wb.save("repo/pricing_template.xlsx")
print("edited")
Bash
cd repo && git diff --stat && git diff | grep -E '^[-+][^-+]'

Note the first two lines: git diff --stat still reports the file as Bin with no insertions or deletions, because statistics are counted on the raw bytes, not on the converted text. The diff itself shows exactly the three cells that changed — the VAT rate from 0.2 to 0.21, and the two gross formulas gaining ROUND(…,0) — as removed and added lines, each with its sheet and address. That is what a reviewer needs to approve a template change: not "the file changed", but "the VAT rate and the rounding changed, nothing else did". The same view appears in git log -p pricing_template.xlsx for the file's whole history, and git diff --word-diff narrows it to the changed part of each line.

Binary diff and textconv diff On the left the diff git shows without a driver, a single line saying the binary files differ. On the right the textconv diff with the VAT rate changed from 0.2 to 0.21 and the gross formulas changed to include ROUND. Binary files a/… and b/… differ - Inputs!B2 0.2 + Inputs!B2 0.21 - Calc!C2 =B2*(1+Inputs!B2) + Calc!C2 =ROUND(B2*(1+Inputs!B2),0) before after textconv

Step 4 — Use it in reviews and tests

Hosted services such as GitHub and GitLab render diffs on their servers and do not run local textconv drivers, so the pull request page still says "binary". Two ways around that. Reviewers can run git diff main...branch -- '*.xlsx' locally, where the driver applies. Or CI can post the text diff as a comment or an artefact, using the converter directly:

Python
import difflib
import io
import subprocess
import sys
sys.path.insert(0, "repo")
from xlsx2text import dump

def workbook_text(path=None, git_ref=None):
    buf = io.StringIO()
    if git_ref:
        data = subprocess.run(["git", "-C", "repo", "show", f"{git_ref}:pricing_template.xlsx"],
                              check=True, capture_output=True).stdout
        dump(io.BytesIO(data), buf)
    else:
        dump(path, buf)
    return buf.getvalue().splitlines()

diff = difflib.unified_diff(workbook_text(git_ref="HEAD"), workbook_text("repo/pricing_template.xlsx"),
                            "HEAD", "working copy", lineterm="", n=0)
print("\n".join(line for line in diff if not line.startswith("@@")))

git show HEAD:file retrieves the committed version's bytes, and difflib produces the same unified diff git would. The same comparison works in tests: Compare Excel Output to a Golden File in Tests shows it failing a test with a readable list of changed cells instead of "files differ".

What the text view does not show

The converter prints cell contents, formulas, sheet states and names — the things that change results. It does not show formatting, column widths, conditional formats, charts, data validation or comments, so a change that only restyles a template shows as an empty diff. Extend dump for the properties your templates depend on — cell.number_format is the most useful addition, because a changed number format changes what readers see — but resist dumping everything, or every save by Excel will produce noise. Workbooks saved by Excel also reorder internal XML freely, which is why diffing the unzipped XML directly is far noisier than this cell-level view.

Performance notes

The converter loads the whole workbook with openpyxl, which takes a fraction of a second for templates and small golden files and longer for workbooks with hundreds of thousands of cells. Git runs it twice per diff — once for each side — and many times for git log -p, which is why cachetextconv matters. Keep large data workbooks out of the repository altogether; this technique is for templates, reference tables and test fixtures, where a readable history is worth the conversion time.

Common pitfalls

  • Expecting the driver to come with a clone. .gitattributes is shared; the git config for the command is per clone.
  • Loading with data_only=True. You would diff cached results and miss formula changes.
  • Diffs that pair unrelated cells. Print sheet and address on every line so each cell is its own line.
  • Hosted diff views. GitHub and GitLab ignore local drivers; review locally or publish the text diff from CI.
  • Macro-enabled files. openpyxl reads .xlsm cell content, but VBA changes are invisible; see Extract VBA Macro Code from an Excel File for diffing macros.

Conclusion

Write a small converter that prints every non-empty cell as Sheet!Address and its formula or value, plus sheet states and defined names, and register it as a git diff driver: *.xlsx diff=excel in .gitattributes, and diff.excel.textconv with cachetextconv in each clone's config. git diff and git log -p then show exactly which cells changed; for hosted reviews and tests, run the same converter with difflib.

Frequently asked questions

How do I see what changed in an Excel file in git? Configure a textconv diff driver: add "*.xlsx diff=excel" to .gitattributes and set git config diff.excel.textconv to a script that prints the workbook's cells as text. git diff then compares the text versions.

Why does git say "Binary files differ" for .xlsx? An .xlsx file is a zip archive, so git treats it as binary and cannot show line changes. A textconv driver converts it to text first.

Does GitHub use my textconv driver in pull requests? No. Hosted services render diffs on their servers without running local commands. Review Excel changes locally with git diff, or have CI post the text diff produced by the same converter.

Can the diff show formatting changes? Only if the converter prints them. Adding number formats is useful; dumping all styles creates noise, because Excel changes many internal details on every save.

More on logging, testing & packaging

More on logging, testing & packaging →