Git LFS for Large Files
Git was designed to track small, text-based source files efficiently, but it struggles with large binary files like videos, design files, datasets, or compiled assets. Git LFS (Large File Storage) is an official Git extension that solves this problem by storing large file contents outside your normal Git history and replacing them with small “pointer” files inside it. This keeps your repository fast to clone and cheap to store, even when it contains gigabytes of binary assets.
Overview: How Git LFS Works
To understand why Git LFS exists, you need to remember how Git normally stores content. Every version of every file you commit becomes a blob object in .git/objects, addressed by the SHA-1 (or SHA-256, on newer repos) hash of its content. A tree object records a snapshot of a directory, pointing to blobs and other trees. A commit object points to one tree plus its parent commit(s). A branch is nothing more than a movable pointer to a commit, and HEAD normally points to a branch.
This model is brilliant for source code because Git can compress and delta-encode similar text blobs very efficiently. It is terrible for large binaries: a 200 MB video file changed ten times means Git may end up storing something close to ten full 200 MB blobs (binary diffs compress poorly), and every one of those blobs gets downloaded by everyone who clones the repository, forever, because Git’s history is immutable and distributed in full to every clone.
Git LFS fixes this by changing what actually gets committed. When a file pattern is tracked with LFS, Git’s clean filter intercepts the file on git add: instead of storing the real file content as a blob, Git stores a tiny text pointer file (a few hundred bytes) containing the file’s content hash and size. The real file content is uploaded separately to LFS storage (hosted by GitHub for GitHub repositories). On checkout, the smudge filter reverses the process: it reads the pointer, fetches the real content from LFS storage if it isn’t already cached locally, and writes it into your working tree. Your working tree always looks normal — only what lives inside .git‘s object database and what gets pushed changes.
Because the pointer file is what Git actually versions, cloning or fetching the repository’s history is fast and small. The large binary content is only downloaded on demand for the commits you actually check out, and Git LFS itself deduplicates identical blobs by content hash, just like Git does internally.
Syntax
git lfs install
git lfs track "<pattern>"
git lfs untrack "<pattern>"
git lfs ls-files
git lfs status
git lfs pull
git lfs fetch
git lfs prune
git lfs migrate import --include="<patterns>"
| Command | What it does |
|---|---|
git lfs install |
Registers the LFS clean/smudge filters in your global Git config. Run once per machine. |
git lfs track "*.psd" |
Adds a rule to .gitattributes so matching files are stored via LFS. Must be committed. |
git lfs untrack "*.psd" |
Removes a tracking rule from .gitattributes (existing committed files stay in LFS until migrated out). |
git lfs ls-files |
Lists which files in the current checkout are tracked by LFS. |
git lfs status |
Shows LFS objects staged or pending, similar to git status. |
git lfs pull |
Downloads the actual LFS content for files already checked out (useful after cloning with smudge skipped). |
git lfs fetch |
Downloads LFS objects for recent history without touching the working tree. |
git lfs prune |
Deletes old local LFS objects that are no longer referenced by recent commits, to reclaim disk space. |
git lfs migrate import |
Rewrites existing commit history so files matching a pattern become LFS pointers retroactively. |
Examples
Example 1: Tracking a new file type
git lfs install
git lfs track "*.psd"
git add .gitattributes
Output:
Updated git hooks.
Git LFS initialized.
Tracking "*.psd"
git lfs install only needs to run once per machine (it wires up Git’s filter mechanism globally), but git lfs track must be run inside each repository, and it writes its rule into a .gitattributes file that must itself be committed so every collaborator gets the same tracking rules.
Example 2: Adding, committing, and pushing a tracked file
git add design/homepage-mockup.psd
git commit -m "feat: add homepage mockup asset"
git push origin feature/homepage-design
Output:
Uploading LFS objects: 100% (1/1), 42 MB | 3.1 MB/s, done.
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 1.2 KiB | 1.2 MiB/s, done.
To github.com:acme/website.git
3f2a1c9..8b7e4d2 feature/homepage-design -> feature/homepage-design
Notice the extra “Uploading LFS objects” line — that upload goes to GitHub’s LFS storage, separate from the regular Git push of your small commit and tree objects. The commit itself only ever contains the pointer file, which is why git commit and the tree/blob upload are so fast even though the real asset is 42 MB.
Example 3: Checking what’s tracked
git lfs status
git lfs ls-files
Output:
On branch feature/homepage-design
Git LFS objects to be committed:
design/homepage-mockup.psd (LFS: 9f86d081)
Git LFS objects not staged for commit:
8b7e4d2 * design/homepage-mockup.psd
The * in git lfs ls-files output means the actual LFS content is present locally, not just the pointer. If you clone a repository without Git LFS installed, files still appear in your working tree, but they contain the raw pointer text instead of real content — a common source of confusion covered below.
How It Works Step by Step
When you run git add on a file matched by a .gitattributes LFS rule:
- Git invokes the registered clean filter instead of reading the file as-is.
- The filter hashes the file’s real content and copies it into your local LFS cache (
.git/lfs/objects), keyed by that hash. - A small pointer file — containing a version line, an
oid sha256:...hash, and asizein bytes — is what actually gets staged into the index and becomes the blob object Git commits.
On git push, Git LFS intercepts the push, uploads any new LFS objects to the LFS storage endpoint (GitHub’s own LFS servers for github.com repos), and only then lets the normal Git push of commits/trees/pointer-blobs proceed.
On git checkout, git switch, or git clone, the smudge filter runs in reverse: it reads each pointer file, checks whether the matching content already exists in the local LFS cache, downloads it from LFS storage if not, and writes the real bytes into your working tree in place of the pointer text. A raw pointer file looks like this:
version https://git-lfs.github.com/spec/v1
oid sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
size 44040192
Common Mistakes
Mistake 1: Committing the large file before tracking it.
git add training-data/model-weights.bin
git commit -m "feat: add trained model weights"
git lfs track "*.bin"
Running git lfs track after the file is already committed does nothing to that existing commit — the full binary is already a regular Git blob in your history. The fix, if the commit hasn’t been pushed yet, is to track the pattern, re-stage the file so the clean filter converts it, and amend:
git lfs track "*.bin"
git add .gitattributes
git rm --cached training-data/model-weights.bin
git add training-data/model-weights.bin
git commit --amend --no-edit
If the large blob is already buried deep in history and already pushed, you need git lfs migrate import instead (see below), which rewrites every affected commit — and rewriting pushed history requires force-pushing and coordinating with anyone who has already pulled.
Mistake 2: Forgetting that .gitattributes must be committed. If you run git lfs track locally but never commit the resulting .gitattributes changes, your collaborators’ clones won’t know to treat those files as LFS pointers, and they’ll accidentally commit full binaries as regular blobs.
Mistake 3: Cloning without Git LFS installed. If a machine doesn’t have the git-lfs package installed, cloning a repository that uses LFS silently leaves pointer text files in the working tree instead of real content — no error, just files that look wrong. The fix is to install git-lfs, run git lfs install, then git lfs pull to hydrate the pointers into real files.
Mistake 4: Rewriting history that’s already been pulled by others. git lfs migrate import rewrites commit SHAs, exactly like git rebase or git filter-repo. Never run it on a shared branch without warning your team — everyone will need to re-clone or hard-reset their local branch, and any unpushed local work based on the old history will need to be reapplied.
Migrating Existing History Into LFS
If large files were already committed as normal blobs and you want to retroactively move them into LFS, use git lfs migrate import. This is a history-rewriting operation, so treat it with the same caution as git filter-repo: back up the repository first, and coordinate a force-push window with your team.
git lfs migrate import --include="*.mp4,*.mov" --everything
git push --force-with-lease origin main
Output:
migrate: Sorting commits: ..., done
migrate: Rewriting commits: 100% (24/24), done
main abc1234 -> def5678
migrate: Updating refs: ..., done
Every commit that ever touched a matching file gets a new SHA, which is why --force-with-lease (not bare --force) matters: it refuses to overwrite the remote branch if someone else has pushed commits you haven’t seen, protecting against silently discarding a teammate’s work.
Best Practices
- Run
git lfs trackand commit.gitattributesbefore adding any large files, not after — retrofitting is a history rewrite. - Track specific extensions (
*.psd,*.mp4) rather than whole directories, so you don’t accidentally sweep in small text files that Git already handles fine. - Document the Git LFS requirement in your project’s
README.mdso new contributors install it before their first clone. - Watch your storage and bandwidth quota — GitHub’s free LFS tier has limits, and pushes will fail once you exceed them.
- Run
git lfs pruneperiodically on long-lived local clones to remove cached LFS objects for commits you no longer need. - For extremely large or rarely-versioned assets (multi-gigabyte datasets, build artifacts), consider whether an external artifact store or release asset is a better fit than LFS at all.
- Prefer
git push --force-with-leaseover bare--forcewhenever a migration or rebase requires overwriting a pushed branch.
Practice Exercises
- Exercise 1: Create a new repository, run
git lfs install, track*.png, add a PNG image, and commit it. Usegit lfs ls-filesto confirm it’s tracked, then open the file inside.git‘s object store (viagit cat-file -pon its blob hash) to see the raw pointer text. - Exercise 2: Imagine a teammate already committed a 300 MB
.zipfile as a normal blob three commits ago, and it has not been pushed yet. Work out the sequence of commands to move just that file into LFS without rewriting the other commits, usinggit lfs trackplusgit rm --cachedand re-adding. - Exercise 3: Simulate cloning a repository on a machine without
git-lfsinstalled by unsetting the filters (git config --unset filter.lfs.smudge) and re-checking out a tracked file. Observe the pointer text in your working tree, then figure out the two commands needed to fix it oncegit-lfsis properly installed.
Summary
- Git LFS replaces large file content with small pointer files inside Git’s normal object history, keeping clones fast and small.
- The real content lives in separate LFS storage and is transferred via clean (on add) and smudge (on checkout) filters.
git lfs trackwrites rules to.gitattributes, which must itself be committed for teammates to benefit.- Tracking a pattern does not retroactively fix files already committed as plain blobs — that requires re-adding the file or running
git lfs migrate import. git lfs migrate importrewrites history like a rebase orfilter-reporun: never do it on a shared branch without warning collaborators, and force-push with--force-with-leaseafterward.- Cloning without
git-lfsinstalled leaves pointer text instead of real files — install it and rungit lfs pullto fix.
