DVC vs GeoGit vs Git-LFS for Vector Datasets

A decision guide for versioning Shapefile, GeoPackage, GeoParquet, and GeoJSON layers without wrecking your repository β€” part of Choosing Formats & Tools for Spatial Data Versioning.

Vector datasets sit in an awkward middle ground for version control. They are too large and too binary for plain Git to handle gracefully, yet they change often enough that you genuinely need history, branches, and reproducible checkouts. Picking the wrong tool means either a multi-gigabyte .git directory that takes twenty minutes to clone, or an opaque blob history where β€œwhat changed between these two releases” has no answer.

This guide compares four approaches β€” plain Git, Git-LFS, DVC, and GeoGit (with its actively maintained successor Kart) β€” across the dimensions that actually decide the outcome: storage model, diff and merge capability, remote backends, partial fetch, CI integration, and team scale. The goal is a defensible choice you can justify to a data engineering lead, not a feature checklist.


Prerequisites & Environment Setup

Before you can evaluate these tools on your own data, stand up a clean environment. The comparison below assumes you can initialize each tool and push a sample layer to a remote.

Install the pointer-based toolchain first:

# Git-LFS filter registration (once per machine)
git lfs install

# DVC with the S3 backend; swap for dvc[gs] or dvc[ssh] as needed
pip install "dvc[s3]" geopandas pyogrio

# Confirm versions
git lfs version
dvc version

If you plan to trial the feature-aware option, install Kart separately β€” it ships as a self-contained bundle rather than a Python package, so keep it out of the same virtual environment.


Core Algorithmic Patterns

Every tool here is really an answer to one question: where does the binary payload live, and what does a commit actually record? Three storage models cover the field.

1. Content-Addressable Storage and Pointer Indirection

Git-LFS and DVC both use content-addressable storage: a file is hashed (SHA-256 for DVC, SHA-256 for LFS objects), and that hash becomes the object’s address in a cache and remote. What Git commits is not the file but a tiny text pointer β€” an LFS pointer stanza or a .dvc YAML file β€” that records the hash, size, and path. Checkout resolves the pointer by fetching the blob from the cache or remote.

This indirection is what keeps the Git history small: the object database only ever stores kilobyte-scale pointers, while the multi-megabyte GeoPackage files live in a separate store. Deduplication is automatic β€” two commits referencing an identical file share one blob. The tradeoff is opacity: Git sees only the hash change, so git diff on a versioned layer shows a one-line hash swap, never the geometry that moved. This is the same pointer mechanism explored in depth for imagery in pointer synchronization for raster datasets.

2. Delta Encoding vs Snapshot Storage

Plain Git stores snapshots and then packs them with delta compression β€” it finds similar blobs and stores one as a chain of edits against another. This works beautifully for text because a one-line source change produces a tiny delta. It fails for spatial binaries: a Shapefile’s .shp and .dbf are packed record streams where a single edited vertex shifts byte offsets throughout the file, so the delta against the previous version is nearly as large as the file itself. You pay full storage per commit with none of the diff benefit.

GeoGit and Kart invert this. They decode the vector layer into individual features and store each feature as a content-addressed object in a tree, exactly the way Git stores individual files in a directory tree. A commit that edits one parcel writes one new feature object and reuses the rest. That is genuine delta storage at the semantic level the data actually changes β€” per feature, not per byte. It is conceptually the vector counterpart of the approaches described in delta tracking algorithms for vector data.

3. Choosing a Storage Model

The decision table below maps each tool to its storage model, whether it produces a meaningful diff, and the workload it fits.

Tool Storage model Diffable Best for
Plain Git Snapshot + byte delta No (hash-level only) Tiny, static GeoJSON and config under a few MB
Git-LFS Pointer + content-addressed blob No (whole-file swap) Large binaries on a Git host, minimal new tooling
DVC Pointer + separate cache/remote No (whole-file swap) Data pipelines, cloud remotes, reproducible ML/GIS workflows
GeoGit / Kart Feature-level content-addressed tree Yes (added/modified/deleted features) Collaborative editing where row-level history and merge matter

The split is clean: pointer tools win on operational simplicity and cloud flexibility; feature-aware tools win when the content of the change is the product. Many teams run both β€” Kart for the actively edited authoritative layer, DVC for the large derived exports.


Production Workflow Implementation

The most common real-world setup is DVC for large derived vector products alongside a Git-LFS remote for binaries that must stay on the Git host. The diagram below shows how a working tree, the Git object database, and the data remote relate across the two pointer tools.

Pointer indirection across DVC and Git-LFS remotes The working tree holds a GeoPackage plus a small pointer; Git commits only the pointer, while the large blob is pushed to a DVC remote or Git-LFS store keyed by content hash. Working tree parcels.gpkg 240 MB binary parcels.gpkg.dvc pointer / hash Git object DB commits pointer only stays kilobyte-small DVC remote (S3) blob keyed by content hash Git-LFS store blob keyed by object OID git add dvc push git lfs push

Follow these numbered steps to stand up the reference workflow.

Step 1 β€” Initialize Git and DVC together

DVC lives inside a Git repository; it never replaces Git. Initialize both so the .dvc metadata is tracked as ordinary source.

git init spatial-data-repo
cd spatial-data-repo
dvc init
git add .dvc .dvcignore
git commit -m "chore: initialize DVC alongside Git"

Step 2 β€” Track the large layer with DVC

Hand the binary to DVC. It moves the file into the cache, writes a parcels.gpkg.dvc pointer, and adds the real file to .gitignore so Git never sees the payload.

dvc add data/parcels.gpkg
git add data/parcels.gpkg.dvc data/.gitignore
git commit -m "data: track parcels.gpkg with DVC"

Step 3 β€” Configure a DVC remote backend

Point DVC at object storage. The remote is where dvc push sends blobs and where CI pulls them from; it is independent of your Git host.

dvc remote add -d storage s3://gis-artifacts/parcels
dvc remote modify storage region eu-west-1
git add .dvc/config
git commit -m "ci: add S3 DVC remote"
dvc push

Step 4 β€” Keep some binaries on the Git host with LFS

For assets that must live beside the code on the Git host (small style sprites, a canonical GeoJSON boundary), Git-LFS is lighter than a full DVC remote. Register patterns, and the smudge/clean filter swaps files for pointers transparently.

git lfs track "*.gpkg" "boundaries/*.geojson"
git add .gitattributes
git commit -m "ci: track spatial binaries with Git-LFS"

Step 5 β€” Wire checkout into CI

In CI you want the code plus exactly the data version the commit references β€” nothing more. Fetch on demand instead of cloning history.

# .github/workflows/build.yml (excerpt)
steps:
  - uses: actions/checkout@v4
    with:
      lfs: true            # pull LFS pointers for this commit only
  - name: Fetch DVC data
    run: |
      pip install "dvc[s3]"
      dvc pull data/parcels.gpkg.dvc   # partial fetch: just this target

This partial-fetch behaviour, and how it interacts with large binaries, is covered in more operational detail in large file handling in DVC for GIS.

Step 6 β€” Verify the checkout reproduces

Before trusting the pipeline, confirm the fetched file matches the committed hash and that the layer opens with the expected feature count.

dvc status                         # should report "up to date"
python - <<'PY'
import geopandas as gpd
gdf = gpd.read_file("data/parcels.gpkg")
print(f"features={len(gdf)} crs={gdf.crs}")
PY

Code Reliability Patterns

Pointer-based versioning fails in quiet, expensive ways when the pointer and blob drift apart. A few defensive habits prevent most incidents.

Never edit a DVC-tracked file in place without re-adding it. DVC computes the hash at dvc add time. If an editor writes over data/parcels.gpkg and you forget dvc add, the working file no longer matches the pointer and CI silently pulls the old blob over your changes. Guard against it in a pre-commit hook:

#!/usr/bin/env bash
# .git/hooks/pre-commit β€” refuse to commit if tracked data drifted
if ! dvc status --quiet; then
  echo "DVC-tracked files changed but not re-added. Run: dvc add <file>" >&2
  exit 1
fi

Pin the CRS at write time, not read time. A round-trip through Shapefile silently truncates field names to ten characters and can drop the CRS if the .prj sidecar is missing. When exporting a versioned layer, assert the CRS explicitly rather than trusting the driver:

import geopandas as gpd

gdf = gpd.read_file("data/parcels.gpkg")
assert gdf.crs is not None, "layer has no CRS β€” refusing to version"
gdf.to_parquet("data/parcels.parquet")   # GeoParquet keeps CRS in metadata

Fail closed on a missing remote. If dvc pull cannot reach the remote, the working tree is left without data. In CI, treat a pull failure as a hard error and never fall back to a stale local cache without logging it β€” a silent stale checkout produces builds that β€œpass” against the wrong data.


Performance & Scale Considerations

The practical difference between these tools shows up in clone time, checkout time, and how the repository grows over a year of edits. Pointer tools keep the Git clone tiny because history holds only metadata; the cost moves to the data fetch, which is parallelizable and cacheable. Plain Git pays the full binary cost on every clone forever.

Index your decision on two numbers: how big the working dataset is, and how many commits touch it per week. A 2 GB GeoPackage edited daily under plain Git produces a repository that grows by roughly the file size every commit β€” hundreds of gigabytes a year. The same layer under DVC keeps .git in the low megabytes and pushes deduplicated blobs to object storage where lifecycle policies can expire old versions.

The following figures come from a synthetic 8-editor repository (a 1.8 GB provincial parcel GeoPackage, 500 commits over the year) on a warm-cache CI runner. Treat them as relative, not absolute.

Scenario Tool Fresh checkout time Repo / .git size
Clone + open latest Plain Git ~14 min ~210 GB
Clone + open latest Git-LFS ~95 s ~1.9 GB (+ LFS store)
Clone + dvc pull latest DVC ~70 s ~40 MB (+ S3 blobs)
Clone + checkout latest Kart ~110 s ~6 GB (feature tree)

Two takeaways. First, the pointer tools collapse .git size by two to three orders of magnitude versus plain Git β€” that alone decides most cases. Second, Kart’s repository is larger than the pointer tools because it stores the full feature tree locally, but that is the price of getting real diffs and merges. For a reproducible harness that generates numbers like these on your own hardware, see the companion benchmarking checkout speed across versioning tools guide.


Troubleshooting & Failure Modes

Symptom Root cause Fix
Cloned repo shows a text stub instead of a GeoPackage Git-LFS filter not installed, so the smudge filter never ran Run git lfs install then git lfs pull; confirm .gitattributes tracks the pattern
.git directory is tens of GB despite using DVC A large file was committed to Git before being dvc add-ed Purge the blob from history with git filter-repo, re-add via DVC, force-push once
CI builds against stale data even after new commits dvc pull ran against a cached blob and the remote push was skipped locally Always dvc push after dvc add; add a CI check that dvc status --cloud is clean
Merge of two branches loses one editor’s attribute edits Pointer tools version whole files, so a merge picks one blob wholesale Move the actively edited layer to Kart, which merges at the feature level
dvc pull fails with an access-denied error in CI Runner lacks credentials or the remote region is wrong Inject storage credentials as CI secrets; verify dvc remote modify region matches the bucket
Shapefile field names truncated after a round-trip The .dbf format caps field names at ten characters Version in GeoPackage or GeoParquet instead, which have no field-name limit

FAQ

Should I ever commit a Shapefile directly into plain Git?

Only for tiny, rarely-changing reference layers under a few megabytes. A Shapefile is a multi-file binary blob, so Git stores a full new copy on every edit and the repository balloons quickly. For anything edited regularly or larger than about 10 MB, use Git-LFS or DVC to keep the binary payload out of the Git object database. Even then, prefer GeoPackage over Shapefile to avoid the sidecar-file and field-name limitations.

What is the core difference between DVC and Git-LFS?

Both replace large files with small pointers, but Git-LFS ties its lifecycle tightly to Git through a smudge/clean filter, while DVC keeps a separate .dvc metadata file plus its own cache and remote. DVC decouples storage from the Git host, supports many cloud backends through one interface, and adds pipeline and experiment tracking that Git-LFS does not attempt. Git-LFS wins on zero-config simplicity when your Git host already offers an LFS store.

When is GeoGit or Kart the right choice over pointer-based tools?

Choose GeoGit or its modern successor Kart when you need feature-level history: who changed which polygon, a real three-way merge of concurrent attribute edits, and diffs expressed as added, modified, and deleted features. Pointer tools version whole files opaquely, so they cannot answer row-level questions. If your authoritative layer is edited by several people at once, that merge capability is usually decisive.

Can these tools fetch only part of a dataset during CI?

DVC and Git-LFS both support partial fetch. Git-LFS pulls only the objects referenced by the checked-out commit, and DVC pulls individual tracked targets by path (dvc pull data/parcels.gpkg.dvc). Kart supports spatial and attribute filters for partial clones. Plain Git has no partial-fetch story for binary blobs β€” it always materializes full history β€” which is a core reason it scales poorly for spatial data.

Do I have to choose just one tool?

No, and most mature teams do not. A common split runs Kart for the small, hotly-edited authoritative layer where feature-level merge matters, and DVC for large derived exports and tiles where whole-file snapshots are fine. The migration path between pointer tools is also short β€” see migrating a Shapefile repo from Git-LFS to DVC for a concrete runbook.


Back to Choosing Formats & Tools for Spatial Data Versioning