How-to
Automating Reporting WorkflowsDeep dive

Store Excel Files in Git with Git LFS

Keep report templates, reference workbooks and golden test files in a git repository without bloating it: track .xlsx and .xlsm with Git LFS so git stores a small pointer and the workbook lives on the LFS server, see what is actually committed, keep readable diffs with a textconv driver, lock binary files so two people cannot edit the same template, and move existing workbooks into LFS.

An Excel automation project usually has a few workbooks that genuinely belong in the repository: the formatted template the report fills in, a reference table of cost centres, the golden output file a test compares against. Git handles them, but not well. An .xlsx file is a zip archive, so every saved change produces a completely different binary blob; git cannot store just the difference, and every version of every workbook stays in the history that each clone downloads. A template saved fifty times is fifty copies. Git LFS (Large File Storage) changes where those bytes live: the repository stores a tiny text pointer for each version, and the workbook itself goes to an LFS server — which GitHub, GitLab, Bitbucket and Azure DevOps all provide — and is downloaded only for the versions that are checked out. This page sets up LFS for Excel files, shows what is actually committed, keeps diffs readable, and uses LFS file locking for templates. It belongs to Testing and Packaging Excel Automation Scripts.

Prerequisites

Bash
git lfs version        # install from git-lfs.com or your package manager if missing

Git LFS is a git extension; install it once per machine and run git lfs install. The hosting service needs LFS enabled for the repository, which is the default on the major platforms, though storage and bandwidth quotas apply.

Step 1 — Track Excel files

Bash
set -e
rm -rf reports-repo && mkdir reports-repo && cd reports-repo
git init -q && git config user.email dev@example.com && git config user.name Dev
git lfs install --local > /dev/null
git lfs track "*.xlsx" "*.xlsm"
cat .gitattributes

git lfs track writes the patterns to .gitattributes, which is committed with the project so every clone treats the files the same way. The attributes say three things: filter=lfs replaces the file's content with a pointer when it is committed and restores it on checkout; diff=lfs and merge=lfs tell git not to try text diffs and merges on it; and -text stops line-ending conversion from corrupting the binary. Commit .gitattributes before adding any workbooks — files committed before the pattern exists go into normal git storage, and only a history rewrite moves them out again.

Step 2 — Commit a workbook and see what git stores

Bash
set -e
cd reports-repo
python - <<'EOF'
from openpyxl import Workbook
from openpyxl.styles import Font
wb = Workbook(); ws = wb.active; ws.title = "Report"
ws["A1"] = "Monthly sales report"; ws["A1"].font = Font(size=16, bold=True)
ws.append([]); ws.append(["Region", "Revenue", "Target"])
wb.save("template.xlsx")
EOF
git add .gitattributes template.xlsx && git commit -q -m "Add report template"
echo "--- what the commit contains for template.xlsx:"
git show HEAD:template.xlsx
echo "--- files in LFS:"
git lfs ls-files --size

The workbook in the working folder is an ordinary Excel file that opens normally. The commit, however, contains only the three-line pointer: the LFS spec version, the SHA-256 hash of the real file and its size. The real bytes are kept in .git/lfs/objects locally and uploaded to the LFS server on git push. Anyone who clones the repository gets the pointers for the whole history but downloads only the workbooks for the commit they check out, so fifty versions of a template cost fifty small pointers, not fifty workbooks.

The pointer file git commits The committed content of template.xlsx is three lines of text — the LFS specification URL, the SHA-256 object id of the real workbook and its size in bytes, here about 4.9 kilobytes. A B C 1 Line Example Meaning 2 version https://git-lfs.github.com/spec/v1 pointer format 3 oid sha256:95c0636d… hash of the real workbook 4 size 4895 bytes to download on checkout Where the workbook bytes live In plain git every version of the workbook is a full blob in the repository history that every clone downloads. With Git LFS the history holds a small pointer per version, the workbooks live on the LFS server, and a clone downloads only the versions it checks out. Plain git full blob for every saved version every clone downloads all of them repository grows with each save no useful delta compression Git LFS three-line pointer per version workbooks on the LFS server clone fetches only what it checks out history stays small

Step 3 — Keep diffs readable

Bash
set -e
cd reports-repo
cat > xlsx2txt.py <<'EOF'
import sys
from openpyxl import load_workbook
wb = load_workbook(sys.argv[1], read_only=True, data_only=False)
for ws in wb.worksheets:
    for row in ws.iter_rows():
        for c in row:
            if c.value is not None:
                print(f"{ws.title}!{c.coordinate}\t{c.value}")
EOF
printf '*.xlsx filter=lfs diff=xlsx merge=lfs -text\n*.xlsm filter=lfs diff=xlsx merge=lfs -text\n' > .gitattributes
git config diff.xlsx.textconv "python $PWD/xlsx2txt.py"
python - <<'EOF'
from openpyxl import load_workbook
wb = load_workbook("template.xlsx"); wb["Report"]["D3"] = "Variance"; wb.save("template.xlsx")
EOF
git diff template.xlsx | tail -n +5

With diff=lfs, git diff on a workbook says only that the pointer changed — a new hash and a new size — which is no help in a code review. Replacing it with diff=xlsx and a textconv driver keeps LFS storage while showing a readable diff: git restores both versions of the workbook, converts each to text with the small script, and diffs the text. Here adding a Variance heading to the template shows up as one added line naming the cell. The textconv setting lives in each developer's git config, not in the repository; a more complete converter that also shows formats and formulas is described in Diff Excel Files in Git with a textconv Driver.

Step 4 — Lock templates so edits do not collide

Bash
git lfs track "*.xlsx" --lockable          # adds 'lockable' to the attribute line
git lfs lock templates/monthly_report.xlsx  # requires a push remote with LFS locking
# ... edit and commit ...
git push
git lfs unlock templates/monthly_report.xlsx
git lfs locks                               # who holds which lock

Two people editing the same text file can merge their changes; two people editing the same workbook cannot — git has no way to combine two binary zips, and one person's work is lost. LFS locking prevents that. Marking a pattern lockable makes matching files read-only in every working copy until the user takes the lock with git lfs lock, which is recorded on the server; others see who holds it and cannot push changes to the file until it is released. The read-only behaviour surprises people the first time — a script that tries to save over a lockable template fails with Permission denied until the file is locked or made writable — so use lockable for files people edit by hand, such as templates, and not for files a test or job writes.

Editing a lockable template A lockable workbook is read-only after checkout. The editor takes the lock, which the server records, edits and commits the file, pushes, and then releases the lock so the next person can take it. Checkout file is read-only git lfs lock server records owner Edit and commit in Excel Push others blocked meanwhile git lfs unlock next person can edit

Moving existing workbooks into LFS, and CI

If workbooks are already in the repository's history, tracking them now only affects future commits. git lfs migrate import --include="*.xlsx,*.xlsm" --everything rewrites every branch so that past versions become pointers too, which shrinks the repository — but it changes every commit hash, so it needs a force-push and a fresh clone by everyone, and is best done at a quiet moment with the team's agreement. A gentler option is to leave history alone and accept that the old blobs remain; the repository stops growing from that point.

In CI, make sure the checkout step fetches LFS files — on GitHub Actions, actions/checkout with lfs: true — or the tests will open three-line pointer files instead of workbooks and fail with confusing "not a zip file" errors. Cache the LFS objects between runs if the workbooks are large, because LFS bandwidth counts against the hosting quota.

What belongs in LFS

LFS is for workbooks that are part of the project: templates, reference data, golden files for tests, sample inputs used in documentation. It is not a place for report outputs, data extracts or anything containing real personal data — those should not be in the repository at all, LFS or not, and a pre-commit hook can enforce that, as in Block Large or Sensitive Excel Files with a Pre-commit Hook. Small test fixtures can often be generated in code instead, which keeps them reviewable as text.

Common pitfalls

  • Adding workbooks before .gitattributes. They go into plain git; track first.
  • Pointer files in CI. Enable LFS in the checkout step.
  • Unreadable diffs. Use diff=xlsx with a textconv driver instead of diff=lfs.
  • Lockable files written by scripts. They are read-only until locked; use lockable only for hand-edited files.
  • Outputs and extracts in LFS. Keep generated and real data out of the repository entirely.

Conclusion

To keep Excel files in git without bloating it, run git lfs track "*.xlsx" "*.xlsm" and commit .gitattributes before any workbooks. Commits then hold small pointers and the workbooks live on the LFS server. Swap diff=lfs for a textconv driver to keep diffs readable, mark hand-edited templates lockable so edits cannot collide, enable LFS in CI checkouts, and use git lfs migrate only when rewriting history is acceptable.

Frequently asked questions

Should Excel files be stored in Git LFS? Workbooks that belong to the project, such as templates and test golden files, should; LFS keeps one small pointer per version in history instead of a full copy.

What does git store for a file tracked by LFS? A three-line pointer with the LFS spec version, the file's SHA-256 hash and its size; the content is stored on the LFS server.

Why is my Excel file read-only after checkout? Its pattern is marked lockable in .gitattributes; take the lock with git lfs lock before editing.

How do I move Excel files already in history into LFS? Run git lfs migrate import --include="*.xlsx" --everything, which rewrites history and requires a force-push and fresh clones.

More on logging, testing & packaging

More on logging, testing & packaging →