Taming Large Binaries With Git LFS: Setup, Migration, and the Bandwidth Trap

A repository does not get slow because you have a lot of code. It gets slow because someone committed a 400 MB design file, then a slightly changed copy of it, then forty more, and every clone now drags the entire history of every binary down the wire.

Git stores every version of every file forever. For text that is a gift; a diff is tiny. For binaries it is a slow-motion disaster, because Git cannot diff a PSD or an MP4 or a compiled dependency, so it keeps a full copy of each revision packed into history that every developer and every CI runner must download in full. Git LFS (Large File Storage) fixes this by keeping a small text pointer in the repository and pushing the actual bytes to a separate store. This is a how-to for the harder case: a repository that is already bloated, where you need to rewrite history to move the existing binaries out. The versions I have tested against are git 2.45 and Git LFS 3.5.

Install, then declare what you track

Install the extension and run the one-time per-user setup that installs the smudge and clean filters into your Git config:

Free · 4 minutes

Do you actually know what you are running — and what it is about to cost you?

Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.

# Debian/Ubuntu; use brew install git-lfs on macOS
sudo apt-get install git-lfs
git lfs install    # sets up the LFS filters in ~/.gitconfig

# Inside the repository, declare the patterns to track
git lfs track "*.psd"
git lfs track "*.mp4"
git lfs track "*.zip"

Each git lfs track call writes a line into .gitattributes in the repository root. That file is the contract, and it must be committed — it is what tells every other clone which paths are LFS-managed. A typical .gitattributes looks like this:

*.psd  filter=lfs diff=lfs merge=lfs -text
*.mp4  filter=lfs diff=lfs merge=lfs -text
*.zip  filter=lfs diff=lfs merge=lfs -text

One warning that catches people out: git lfs track only affects files committed after the pattern is added. It does nothing to the binaries already sitting in your history. Tracking patterns are the setup; migration is the actual job.

Migrating existing files out of history

To move binaries that already exist in past commits, you use git lfs migrate import. This is the command that rewrites history: it walks the commits you point it at, replaces the matching blobs with LFS pointers, and generates new commit SHAs for everything it touches. Because the SHAs change, this is a destructive, force-push operation — treat it exactly as you would a rebase of shared history.

Work on a fresh clone, not your working copy, and make sure the tree is clean first:

# Rewrite the entire history across all branches and tags
git lfs migrate import --everything --include="*.psd,*.mp4,*.zip"

# Or migrate by size instead of extension: everything over 10 MB
git lfs migrate import --everything --above=10MB

# Then push the rewritten history and the LFS objects
git push --force --all
git push --force --tags

Use --everything when you mean it: it processes all commits reachable from every local and remote reference, so branches and tags stay internally consistent. If you migrate only the default branch, anyone who later merges an old feature branch reintroduces the raw binaries and undoes the work. The --above flag is the pragmatic option when you do not have a clean list of extensions — it catches whatever is big, regardless of type. Preview before you commit to anything with git lfs migrate info --everything --above=10MB, which reports the byte totals per extension without changing a thing.

Rewriting shared history is a coordination event, not a quiet housekeeping task. Every collaborator must re-clone afterwards, because their old local history no longer shares ancestry with the rewritten remote. Announce it, pick a quiet window, and merge outstanding branches first. This is the same discipline that makes an enforced pre-commit baseline worth having: cheap hooks that stop the next oversized binary landing in the first place beat an expensive history rewrite later.

Verify, then lock what cannot merge

Confirm the migration actually did what you think with git lfs ls-files, which lists every file the current commit resolves to an LFS object:

git lfs ls-files
# 3f1a9c2e5b * assets/hero.psd
# a7d4e08f16 * media/intro.mp4

An asterisk in the middle column means the full object is present locally; a minus means only the pointer is. If a binary you expected is missing from the list, it was not migrated — check your include pattern.

Binaries have a second problem beyond size: you cannot merge them. Two people editing the same PSD produce a conflict Git cannot resolve, and one person’s work is silently lost. Git LFS offers file locking for exactly this. Mark the un-mergeable types as lockable, and LFS keeps them read-only on disk until someone takes an explicit lock:

git lfs track "*.psd" --lockable   # adds the 'lockable' attribute
git lfs lock assets/hero.psd       # claim it before editing
git lfs locks                      # see who holds what
git lfs unlock assets/hero.psd     # release when done

Locking only helps if the team agrees to use it, but for design and media assets it turns a silent overwrite into an honest “someone else has this open”.

The bandwidth trap

Here is where LFS quietly bills you. On GitHub, Git LFS is metered on two axes: storage and bandwidth. At the time of writing, GitHub Free and Pro accounts include 10 GiB of storage and 10 GiB of bandwidth per month, with Team and Enterprise Cloud at 250 GiB each; beyond that it is metered, usage-based billing rather than pre-paid data packs. The trap is that bandwidth counts downloads, not uploads. Every clone, every fresh CI runner, every git archive that pulls an LFS object draws down the repository owner’s monthly bandwidth allowance.

A busy CI pipeline that clones the full repository on every run is the usual culprit. Move a few gigabytes of assets into LFS, wire up a build that fetches them fifty times a day, and the bandwidth line climbs fast — the same pattern that makes clone time a hidden drag on lead time in the DORA metrics. Mitigate it: use GIT_LFS_SKIP_SMUDGE=1 or a shallow, filtered checkout so CI only pulls the LFS objects a given job actually needs, and cache them between runs. When the numbers get large, that overage becomes a real cost line worth allocating back to the team that generates it.

Which is the honest test for whether LFS is the right tool at all. LFS is versioned storage bolted onto Git — it is excellent when you genuinely need design files and media under version control alongside the code that uses them. It is the wrong tool for build outputs, compiled dependencies, or model weights that are regenerated rather than authored; those belong in a proper artifact store or object storage, addressed by hash, not versioned in your history. Move binaries into LFS because a human edits them and you need the diffable, lockable, versioned trail. Everything else, keep out of Git entirely.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.