Managing Large Spatial Fixtures with Git LFS
A spatial fixture outgrows a repository sooner than a textual one, and the transition is unpleasant because git handles it silently until it does not. This guide sits beneath fixture versioning and provenance and covers the practical mechanics of large-file storage for spatial data: which files genuinely belong there, why a committed GeoPackage costs more than its size suggests, how to migrate history without rewriting every developer’s clone, and how to make CI fetch only what a job needs.
The decision to reach for large-file storage should come after the decision to generate rather than store. Everything here applies to the residue — the hand-crafted pathology, the incident regression, the production-shaped extract — that genuinely cannot be produced from a seed.
Root cause: why a binary fixture is expensive in git
Git stores every version of every file forever, and it stores them by content. For a text file that is cheap: successive versions share most of their content and pack efficiently. For a spatial binary it is not, and three properties make it worse than the file size implies.
Small edits produce entirely new objects. Rewriting one attribute in a GeoPackage changes internal page layout, so the new version shares almost nothing with the old. Ten revisions of a 40 MB fixture is 400 MB in the repository, permanently, for every clone.
Compression does not help. Most spatial formats are already compressed internally, so git’s packing achieves little. A 40 MB GeoPackage packs to roughly 40 MB.
History cannot be pruned selectively. Once the object is in history, removing it requires rewriting history — which invalidates every existing clone and every reference to a commit hash. The cost of a mistake here is not the disk space; it is the migration.
Decision reference
| Fixture | Store as | Because |
|---|---|---|
| Seeded generator output | Nothing — generate it | Identity is provable from inputs |
| Hand-crafted pathology, < 100 kB | Committed directly | Must be reviewable as an artefact |
| Incident regression, < 1 MB | Committed directly | Its value is that it is exactly this data |
| Production-shaped extract | Large-file storage | Too big to commit, too specific to generate |
| Multi-gigabyte reference layer | Content-addressed store | Shared across suites; dedupes |
| Anything derived from real data | Neither, without review | Carries the source’s obligations |
Step-by-step implementation
The commands target git-lfs 3.x.
Step 1 — Track by directory, not by extension
Tracking *.gpkg globally captures every GeoPackage in the repository including the small reviewable ones, which is usually not what is wanted. Track the directory that holds large fixtures instead.
git lfs install
git lfs track "tests/fixtures/large/**"
git add .gitattributes
The attributes file must be committed before the fixtures, or the first fixture lands in ordinary git history and the migration problem starts immediately.
Step 2 — Verify the pointer, not the file
A tracked file is replaced in git by a small pointer. Confirming that is what proves the tracking pattern matched.
git add tests/fixtures/large/parcels.gpkg
git cat-file -p :tests/fixtures/large/parcels.gpkg | head -3
# version https://git-lfs.github.com/spec/v1
# oid sha256:9f2a...
# size 41943040
If the output is binary rather than three lines of text, the pattern did not match and the file is going into ordinary history.
Step 3 — Fetch selectively in CI
The default clone fetches every tracked object, which for a repository with many fixtures is most of the cost of the job. Fetch only what the job reads.
- uses: actions/checkout@v4
with:
lfs: false # do not pull everything
- run: |
git lfs pull --include="tests/fixtures/large/parcels.gpkg"
Step 4 — Assert the fixture is real before using it
A missing large-file object materialises as a pointer file, and a spatial reader given a pointer produces a confusing parse error rather than a clear one.
from pathlib import Path
import pytest
def assert_materialised(path: Path):
head = path.read_bytes()[:64]
if head.startswith(b"version https://git-lfs"):
pytest.exit(
f"{path} is an unfetched LFS pointer, not data — run 'git lfs pull'",
returncode=3,
)
Verify the fix
Confirm the repository is genuinely smaller and the fixtures still load:
git count-objects -vH | grep size-pack
pytest -q tests/ -k fixture
A pack size that has not changed after tracking means the fixtures were committed before .gitattributes, and the objects are already in history. That is the case that requires a migration rather than a configuration change.
Migrating history that already contains fixtures
When large objects are already in history, tracking them going forward does not shrink anything — the old objects remain. Two paths exist and they differ in disruption rather than in outcome.
Rewrite history. Every commit containing a large object is rewritten so the object becomes a pointer. The repository shrinks immediately and completely, and every existing clone becomes invalid: every developer must re-clone, every open branch must be rebased, and every recorded commit hash — in tickets, in deployment records, in release notes — no longer resolves. This is correct when the repository is small enough that a coordinated re-clone is practical.
Leave history alone and track from now. New revisions become pointers, old objects stay. Nothing breaks, nobody re-clones, and the repository keeps its existing size without growing further. This is usually the right choice for an established repository, because the disruption of the alternative is larger than the disk it recovers.
The decision rests on one question: is the repository still growing at a rate that matters? If yes, tracking from now solves the future and the past is a fixed cost. If the past is itself the problem — a repository nobody can clone in reasonable time — a rewrite is the only remedy, and it should be scheduled like a migration rather than done on a Friday.
Keeping the tracked set small
The most effective thing that can be done about large fixtures is to have fewer of them, and the review question that achieves it is short: could this be generated? Applied to each candidate before it is added, it removes most of what would otherwise accumulate.
Three categories genuinely cannot be generated and should be tracked without argument. A hand-crafted pathology whose exact vertex arrangement is the point — a geometry that provoked a specific engine bug — cannot be reproduced from a description. An incident regression is valuable precisely because it is the data that broke production, and synthesising something similar loses the property that made it worth keeping. A production-shaped extract used for performance work has statistical properties that a simple generator will not reproduce faithfully.
Everything else is usually generatable with a day’s work on a generator that then serves the whole suite. The comparison worth making is not “generator versus one fixture” but “generator versus every fixture of that shape the team will ever need”, and on that basis the generator almost always wins.
A periodic review helps too. Listing the tracked objects by size once a quarter, and asking of the largest few whether they are still referenced by any test, typically finds one or two that outlived their purpose — and while removing them does not shrink history, it does stop them being fetched.
Failure modes and edge cases
- Tracking configured after the first commit. The fixture is already in ordinary history and tracking it now changes nothing. Commit
.gitattributesfirst, always, and verify withgit cat-filethat the staged content is a pointer. - A clone without the extension installed. Developers who have not installed the tooling receive pointer files and see parse errors from every spatial reader. The materialisation check turns that into a message naming the command to run.
- CI pulling everything. The default checkout fetches all objects, so a repository with fifty fixtures pays for fifty in every job. Disable the automatic pull and fetch by pattern.
- A fixture edited in place. Every save creates a new object in the store, so an iteratively-developed fixture accumulates revisions nobody needs. Develop it outside the repository and commit once.
- Storage quota exhausted mid-migration. A rewrite pushes every historical object at once, which can exceed a quota and leave the repository in a partially-migrated state. Check the projected size before starting.
- Assuming tracked means versioned. Large-file storage versions the pointer, and the object store may have its own retention. A fixture whose object has been pruned is unrecoverable even though its pointer is still in history.
Conclusion
Large-file storage is the right tool for the residue of fixtures that genuinely cannot be generated, and the wrong tool for everything else. Tracking by directory before the first commit, verifying that pointers rather than binaries reach history, fetching selectively in CI, and checking materialisation before a reader touches the file keeps repository workflow intact — while the underlying decision, made in fixture versioning and provenance, remains to generate wherever generation is possible.
Related
- Fixture Versioning and Provenance — the storage decision this guide implements one branch of.
- Hashing Spatial Fixtures for Content-Addressed Storage — the alternative that dedupes and verifies locally.
- Recording Fixture Provenance Metadata in CI — recording which fixture a run actually fetched.
- Test Data Generation & Mocking Strategies — the generation-first default that keeps this guide’s scope small.