Git stores every version of every file forever, and that is the property that makes it trustworthy and the property that makes binaries ruinous.
A 50 MB design file edited weekly for two years is 5 GB of history that every clone downloads, permanently, whether or not anybody needs the old versions. Git LFS moves that content out of the repository and leaves a reference behind.
Why Git struggles with binaries
Section titled “Why Git struggles with binaries”Not a defect — a consequence of the design.
Git deltas text well and binaries badly. Packfiles store similar objects as deltas against each other, which works beautifully for source code where a commit changes a few lines. A recompiled binary or a re-saved image differs throughout, so the delta is roughly the size of the file, and each version costs full size.
Every version is retained. Deleting a file removes it from the working tree and not from history. The object is still reachable from an old commit, still in the packfile, still in every clone.
Everybody downloads everything. A clone fetches the complete object database by default. An engineer who never opens the design files still downloads every version of them.
The result compounds. A repository whose source is 200 MB and whose binary history is 40 GB clones as a 40 GB repository, and it grows every week whether or not the code changes.
What LFS actually does
Section titled “What LFS actually does”The mechanism, stated precisely, because the mental model matters for everything else.
In the repository, Git stores a pointer file — a small text file, a few lines long:
version https://git-lfs.github.com/spec/v1oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393size 52428800That is what is committed. It deltas well, it is tiny, and history contains one of these per version rather than 50 MB.
The real content lives in an LFS server, keyed by that object ID. For GitHub-hosted repositories, GitHub provides it.
A Git LFS client filter replaces the pointer with the real content on checkout and does the reverse on commit. To the engineer, the file looks normal.
The consequence people miss: the object is no longer part of the Git object database. git clone fetches pointers; a separate LFS fetch retrieves content. That separation is the whole benefit and the source of most operational surprises.
Tracking
Section titled “Tracking”.gitattributes decides what is stored in LFS:
*.psd filter=lfs diff=lfs merge=lfs -text*.zip filter=lfs diff=lfs merge=lfs -text*.mp4 filter=lfs diff=lfs merge=lfs -text*.bin filter=lfs diff=lfs merge=lfs -textassets/models/** filter=lfs diff=lfs merge=lfs -textUsually written by git lfs track "*.psd", which appends the rule.
Commit .gitattributes. It is the shared configuration, and it is the only thing that makes tracking consistent. A rule present on one machine and absent on another produces a repository where the same file type is stored in LFS by some contributors and as an ordinary Git object by others — which passes review, breaks nothing immediately, and leaves a repository whose history is half-migrated in a way that is unpleasant to unwind.
Track by path where possible. assets/models/** is more precise than *.bin, which catches build artefacts that should not be in the repository at all — and once they are tracked in LFS, they are permanently in LFS storage rather than being removed from the repository entirely, which is the outcome you actually wanted.
Be specific. An overly broad rule pushes text files into LFS, where they stop diffing usefully.
GitHub’s limits
Section titled “GitHub’s limits”At the time of writing, GitHub documents a maximum LFS file size of 2 GB on Free and Pro, 4 GB on Team and 5 GB on Enterprise Cloud, and states that a file exceeding the per-file limit is rejected by Git LFS with an error message.
Storage and bandwidth are billed separately from the repository, and the quota model changes. This site deliberately does not quote figures that will be wrong by the time you read them — check your account’s billing page.
The bandwidth dimension surprises people. LFS bandwidth is consumed by every fetch, including every CI run. A repository with large LFS objects and a busy pipeline can consume a great deal without any developer noticing, and the first signal is frequently a quota email.
Note the contrast with ordinary Git objects, where GitHub enforces a 100 MB limit on individual files and blocks the push outright. LFS is what lets you exceed that — 100 MB is the Git ceiling; the LFS ceiling is much higher and separately billed.
Cloning and fetching
Section titled “Cloning and fetching”The operational behaviour, and where surprises live.
A normal clone downloads LFS content for the checked-out commit. Pointers come with the Git objects; the client then fetches the objects for the files in your working tree.
It does not download every historical version — which is the saving.
Skipping LFS content entirely:
GIT_LFS_SKIP_SMUDGE=1 git clone https://github.com/example-org/assets.gitYou get pointer files where the content would be. Useful when you want the repository structure and not gigabytes of assets, and it is the right default in CI jobs that never open the binaries.
Fetching selectively:
git lfs pull --include="assets/models/**" --exclude="assets/video/**"Configuring it persistently:
git config lfs.fetchinclude "assets/models/**"git config lfs.fetchexclude "assets/archive/**"This is the LFS analogue of sparse checkout and it is underused. An engineer who needs the models and not the video should not download the video.
Migrating existing history
Section titled “Migrating existing history”The operation that actually shrinks a repository, and the one requiring the most care.
git lfs migrate rewrites history to replace matching objects with pointers.
# See what would be affected — always firstgit lfs migrate info --everything --above=10MB
# Rewrite. This changes every affected commit's SHA.git lfs migrate import --everything --include="*.psd,*.zip"-
Mirror the repository and keep the backup somewhere nobody can delete.
git clone --mirrorand store it. -
Run
git lfs migrate infoand share the output. It tells you what would move and how much you would save. If the saving is small, stop here. -
Test on the mirror, not on the real repository. Verify the result: does it build, is the history sane, did the size actually drop?
-
Agree a freeze window with every team using the repository. Merge or close open pull requests — they will not survive.
-
Announce it with a date and instructions. Everybody will need to re-clone; tell them in advance rather than after their next
git pullfails confusingly. -
Perform the rewrite and force-push during the freeze.
-
Verify — clone fresh, check size, check history, check CI passes.
-
Have everybody re-clone. Not
git pull. A fresh clone. -
Keep the mirror for months. Somebody will need a pre-rewrite SHA.
The cost is real and the benefit is permanent. A repository that shrinks from 40 GB to 2 GB saves every future clone. Weigh it against a day of organisational disruption and the loss of SHA continuity.
Consider whether you need history at all. For some repositories the honest answer is that pre-migration history is never consulted, and archiving the old repository read-only while starting fresh is cheaper than a rewrite.
Locking
Section titled “Locking”For binaries, merging is not possible, which makes coordination a real problem.
git lfs lock assets/character.psdgit lfs locksgit lfs unlock assets/character.psdFile locking prevents two people editing the same binary simultaneously, because there is no merge to fall back on.
Mark files as lockable in .gitattributes:
*.psd filter=lfs diff=lfs merge=lfs -text lockableLockable files are checked out read-only, so an engineer must take a lock deliberately rather than discovering the conflict at push time.
This is a genuine capability for design and game teams and irrelevant for most software repositories.
The operational cost: locks that are never released. Somebody locks a file, goes on holiday, and the file is blocked. A force-unlock capability and a person who owns it are part of adopting this.
LFS in CI
Section titled “LFS in CI”Where the bandwidth bill lives.
Most CI jobs do not need the binaries. A test suite that never opens the design assets should not download them:
- uses: actions/checkout@v7 with: lfs: falseWhere they are needed, cache them. LFS objects are content-addressed and immutable, which makes them ideal cache entries — key the cache on the LFS object IDs rather than on a commit.
Fetch selectively with lfs.fetchinclude for jobs that need some assets and not all.
Watch the aggregate. A thousand runs a day each pulling 2 GB of LFS content is a meaningful bandwidth figure, and it is invisible until somebody looks. This is the same lesson as CI clone optimization, applied to the LFS half.
Troubleshooting
Section titled “Troubleshooting”The failures, and what each means.
Pointer files in the working tree instead of content. The LFS client is not installed, or the smudge filter did not run. git lfs install then git lfs pull.
A pointer file committed to a repository without LFS configured. Somebody with LFS pushed to a repository where the server side is not enabled, or .gitattributes was missing. The file is a pointer to content nobody can fetch.
“This repository is over its data quota.” Bandwidth or storage exhausted. This blocks fetching, which blocks clones and CI simultaneously — so the symptom is that everything stops at once, for everybody, which looks like an outage rather than a billing issue.
A large file rejected at push. Either it exceeds the LFS per-file limit for your plan, or it was not tracked and hit Git’s 100 MB object limit.
A fork with no LFS access. Fork LFS behaviour differs from what people assume, and a contributor forking a repository with LFS content may find they cannot fetch it or cannot push it back. This is a recurring source of confusion in open-source repositories and it is worth testing before inviting external contributions to an LFS-heavy project.
Broken pointers after a migration. Usually a .gitattributes mismatch — files migrated but the tracking rule was not committed, so subsequent commits store them as ordinary Git objects again.
When LFS is the wrong answer
Section titled “When LFS is the wrong answer”Important, because LFS is frequently adopted to avoid a harder conversation.
Build outputs do not belong in version control in any form. A compiled binary that a build produces from source is not source. Put it in a package registry or release assets — binary file strategy covers the decision properly.
Very large datasets are usually better in object storage with a versioning scheme, referenced by URL and checksum.
Machine-learning model weights are the same case, and there are purpose-built tools for them.
Anything an artifact registry already handles. Container images, packages, release archives.
The question to ask: does this file need to be versioned with the source, such that checking out an old commit should give you the matching file? If yes, LFS is reasonable. If the file is an output, or is consumed by reference, it belongs somewhere else.
Rolling LFS out to an organisation
Section titled “Rolling LFS out to an organisation”Adoption is where the operational problems appear, not in the mechanism.
Every developer needs the client installed. git lfs install configures the filters in their Git config. Without it they get pointer files and a confusing experience, and the confusion is worse than an error because everything appears to work until they open the file.
Put it in onboarding. The setup script, the developer environment image, the documented prerequisites. A team that adopts LFS without updating onboarding will support the same question indefinitely.
Watch for tooling that does not understand LFS. Some build systems, some IDE integrations and some third-party Git clients handle pointer files poorly. Test the actual toolchain before committing to it.
CI images need the client too, and it is not always present by default in every runner image.
Decide who owns the quota. LFS storage and bandwidth are billed, somebody receives the warning email, and somebody has to decide what to do when a repository approaches its allocation. In practice this is a platform team responsibility and it is frequently unassigned until the first quota incident.
Establish the tracking policy centrally. A .gitattributes convention shared across repositories — which extensions are always LFS, which paths — prevents each team inventing its own and prevents the case where the same file type is LFS in one repository and not another.
Audit periodically. git lfs ls-files shows what is actually tracked. Repositories drift into tracking things nobody intended, and the drift is invisible.
Alternatives to enabling LFS
Section titled “Alternatives to enabling LFS”Before adopting it, three questions worth asking, because LFS is a permanent operational commitment.
Does the file need to be in the repository at all? The largest saving is not storing it. Build outputs, generated documentation, vendored dependencies that a package manager could fetch — each of these is frequently in a repository because somebody committed it once and nobody removed it.
Could it be fetched at build time? A large reference dataset pulled from object storage by a setup script is versioned by its URL and checksum, costs the repository nothing, and is downloaded only by jobs that need it.
Is a package registry the right home? Container images, language packages and release archives all have purpose-built stores with better semantics than LFS — immutability, versioning, retention policy and access control that Git does not provide.
Where LFS genuinely fits is content that must be versioned in lockstep with the source: design assets a build consumes, test fixtures that must match the code that reads them, binary resources shipped with the application. In those cases checking out an old commit should give you the matching file, and only LFS provides that.
The rule of thumb: if the file is an input that changes with the code, LFS is reasonable. If it is an output, or a large reference consumed by identity rather than by version, it belongs elsewhere.
Common mistakes
Section titled “Common mistakes”Expecting LFS to shrink existing history. Tracking is not retroactive.
Not committing .gitattributes. Inconsistent behaviour between machines.
Overly broad tracking rules. Text files in LFS, no useful diffs.
Migrating without a mirror backup. The rewrite is irreversible without one.
Rewriting history without a freeze and an announcement. Broken clones, lost pull requests, dead SHAs in tickets.
Downloading LFS content in every CI job. The bandwidth bill nobody looks at.
Using LFS for build outputs. They should not be versioned at all.
Ignoring locks left open. A blocked file and a colleague on holiday.
Assuming the 100 MB Git limit and the LFS limit are the same thing. They are different limits with different remedies.
LFS and migrations
Section titled “LFS and migrations”A specific interaction worth knowing before you plan a platform move.
GitHub Enterprise Importer does not migrate Git LFS objects. The documentation is explicit that LFS objects and large binaries are not migrated, though repositories using Git LFS are still supported — meaning the repository moves and the LFS content does not follow automatically.
What that means practically: a migration involving LFS is two migrations. The Git repository moves through the importer; the LFS objects must be pushed to the destination separately, and the pointers in the migrated history are dangling until they are.
The mechanism is to clone with LFS content from the source and push the LFS objects to the destination:
git lfs fetch --allgit lfs push --all <destination-remote>Do this deliberately and verify it. A repository migrated without its LFS content looks fine — the code is there, the history is there — and every binary file is a pointer to nothing. The failure surfaces when somebody checks out a branch and their build cannot find an asset.
Include it in the validation checklist, covered in migration planning. “Do LFS objects resolve on the destination” is a specific post-migration check, and it is one of the more commonly missed.
Size limits differ too. The importer documents its own file size limit for migration, separate from the LFS per-file limit, so a repository that is legal on both platforms can still exceed what the migration tooling accepts.
Measuring whether it helped
Section titled “Measuring whether it helped”LFS is adopted to solve a problem, and teams rarely check whether it did.
Repository size over time. git count-objects -vH on a fresh clone, monthly. If the number is still climbing at the same rate, either the tracking rules are missing something or the growth was never binaries.
Clone duration for a new developer. The metric that actually matters to people. Time a fresh clone before and after.
LFS storage and bandwidth consumption. Available in your account’s billing view. Rising bandwidth with flat storage means CI is pulling content it does not need.
The proportion of LFS fetches that come from CI. If it is most of them, configuring CI not to fetch is a larger saving than anything else on this list.
What tracked files actually are. git lfs ls-files | wc -l and a look at the extensions. Repositories drift into tracking things nobody intended.
The honest possibility to hold open: measurement sometimes shows LFS did not help, because the repository’s size was history depth or object count rather than binary content. That is the diagnosis-first point, and finding out afterwards is expensive because LFS is difficult to unwind once adopted.
Mental model
Section titled “Mental model”Git LFS stores lightweight pointer files in Git while large content is stored separately.
Everything follows: history stays small because it contains pointers; clones stay fast because content is fetched on demand for what you check out; and the content is a separate system with its own storage, its own bandwidth and its own failure modes.
The corollary that matters most: LFS changes where content lives from the moment you enable it. It does not travel backwards, and a repository that is already large stays large until somebody rewrites its history.
What you learned
Section titled “What you learned”- Git deltas binaries badly and retains every version, which is why binary history compounds
- LFS commits a pointer file and stores content in a separate LFS server
.gitattributesdefines tracking, must be committed, and is not retroactive- GitHub’s LFS per-file limit is plan-dependent — 2 GB Free/Pro, 4 GB Team, 5 GB Enterprise Cloud at the time of writing
- Git’s own 100 MB object limit is a separate ceiling with a different remedy
GIT_LFS_SKIP_SMUDGE,lfs.fetchincludeandlfs: falsein CI control what is downloadedgit lfs migrateshrinks existing history and rewrites every affected commit SHA- File locking exists for unmergeable binaries and needs somebody to own stuck locks
- Build outputs and datasets usually belong somewhere other than Git in any form
Exercise
Section titled “Exercise”Use a disposable repository. Synthetic files only.
-
Create a repository. Generate a 20 MB file of random bytes, commit it, then modify and commit it three more times.
-
Run
git count-objects -vH. Note the size. Predict: roughly how much did four versions cost? -
Run
git lfs track "*.bin", commit.gitattributes, then modify and commit the file again. -
Check the size again. Predict: did the repository shrink?
-
Inspect the file’s content in the last commit with
git show HEAD:file.bin | head -5. Predict: what do you see? -
Run
git lfs migrate info --everything --above=1MB. Predict: what does it report about the earlier commits? -
Clone the repository with
GIT_LFS_SKIP_SMUDGE=1and inspect the working tree. -
Delete the repository.