How Git Actually Stores Your Code (It's Just Snapshots)

Contents
I spent years assuming Git stored diffs, because every other version control system I’d used before it did, and because “commit” sounds like the kind of word that means “here’s what changed.” I only found out I was wrong the day a colleague asked why a one-line change to a ten-thousand-line file didn’t shrink the repository the way a diff-based system would predict. It should have added a few bytes for the diff. Instead the repository grew by roughly the size of the whole file again. That sent me down into .git/objects for an afternoon, and the answer turned out to be the single most useful thing I’ve ever learned about Git: it doesn’t store changes at all. It stores complete snapshots of your entire project at every commit, and the reason it doesn’t balloon in size the way that sounds like it should is content-addressed deduplication, not delta compression between commits.
Understanding that one design decision explains an enormous amount of Git’s otherwise-confusing behaviour: why branches are so cheap, why git commit --amend can rewrite history without touching unrelated files, why two files with identical contents anywhere in your project’s history share exactly one copy on disk, and why detached-HEAD states are so easy to get into and out of without losing data. None of that is magic. It’s the direct consequence of storing snapshots addressed by hash instead of storing diffs addressed by position in a timeline.
What’s actually inside .git/objects
Git’s storage is built from four object types, and every one of them is identified purely by the SHA-1 (or SHA-256, on repositories configured for it) hash of its own content. That’s the “content-addressed” part: the name of an object is derived from what’s inside it, so two objects with identical content are, by definition, the same object, stored once regardless of how many commits or files reference it.
| |
A blob is the content of a single file, with no filename, no permissions, nothing but the raw bytes. A tree is a directory listing: a set of entries, each pointing at either a blob (a file) or another tree (a subdirectory), each with a filename and a mode attached. A commit points at exactly one tree — the root of the entire project at that moment — plus the hash of its parent commit (or commits, for a merge), an author, a committer, and a message. There’s no commit object anywhere in Git that stores “here’s what changed since the parent.” The commit says “here is the complete state of every file in the project, as of this moment,” full stop.
| |
Follow that tree hash with git cat-file -p and you get a full listing of the project root as it existed at that commit — every file, pointing at a blob. Follow one of those blob hashes and you get the exact byte contents of that file, at that commit, with no reference to any earlier or later version.
Why it doesn’t cost what it sounds like it should
The obvious objection is that storing a complete snapshot at every single commit should make repositories enormous, growing by roughly the size of the whole project on every commit rather than the size of just the change. This is where content addressing does the actual work: if you commit a one-line change to a ten-thousand-line file, only that one file’s blob actually changes — every other file in the project is byte-for-byte identical to the previous commit, so its blob hash is identical too, and Git simply reuses the existing object instead of storing a duplicate. The new tree object for the project root points at 9,999 blobs it already had on disk and exactly one new blob for the file that changed. The snapshot is complete, but almost all of it is references to objects that already existed.
This is also why identical files anywhere in your repository’s history — a config file copied into three different directories, a vendored dependency that hasn’t changed in two years — cost exactly one blob’s worth of storage no matter how many trees point at it. Deduplication in Git isn’t a special feature bolted on for efficiency. It falls straight out of the fact that object identity is content, not location or history.
Git does still compress after the fact, through packfiles — periodically it bundles loose objects together and does compute delta compression between similar objects within a pack, which is where the space savings you’d expect from a “diff-based” system actually happen, just after the fact rather than as the fundamental storage model. git gc triggers this packing manually; Git also runs it automatically once enough loose objects accumulate. The distinction matters because it means the logical model — every commit is a full snapshot — and the physical model on disk — compressed, deduplicated packfiles — are two different layers, and confusing them is exactly what led me to expect the repository to grow linearly with file size on that one-line change. You can watch the two layers diverge directly: git cat-file -p always shows you the logical, uncompressed content of an object regardless of whether it’s sitting loose or packed, while du -sh .git shows you the physical, compressed reality after packing. A repository can have thousands of logical snapshot commits and still occupy a few megabytes on disk, because the physical layer is doing the same delta-compression work a diff-based system would do, just decoupled from the logical guarantee that every commit is independently a complete, addressable project state.
What this explains about everyday Git behaviour
Branches being “cheap” makes sense once you see that a branch is nothing but a 41-byte text file containing a commit hash — .git/refs/heads/main literally just holds a SHA. Creating a branch never copies any project files; it creates one small pointer file referencing a commit that already exists, which is why git branch experiment completes instantly regardless of repository size, unlike checking out a fresh working copy in some older centralised systems.
git commit --amend makes sense too: it doesn’t edit history in place, it creates an entirely new commit object with a new tree and a new hash, and moves the current branch pointer to reference it. The old commit object still exists in .git/objects until garbage collection eventually removes it, which is exactly why git reflog can recover an amended-away commit for weeks after the amend — the object was never deleted, just orphaned from any reachable branch.
And git diff between two commits, despite everything above, works by comparing two complete snapshots and computing the difference on the fly, rather than reading a stored diff — which is also why git diff between two commits that are very far apart in history is no slower, structurally, than diffing two adjacent commits. There’s no chain of stored diffs to replay; Git just walks two independent tree structures and reports where the blob hashes disagree. This is the same idea underpinning why blockchain really is just Git with extra steps — both systems use a hash-chain of immutable, content-addressed objects, and once you understand one you’ve mostly understood the other’s storage model, even though what they’re solving is different.
The staging area is a fourth object waiting to happen
There’s one part of this model that trips people up specifically because it looks like an exception: the staging area, or index. When you run git add, Git doesn’t wait for git commit to compute a tree — it writes new blob objects for whatever you staged immediately, and records them in .git/index, a binary file that’s effectively a proposed tree object that hasn’t been promoted to a real one yet. Running git status and seeing files listed as staged is Git comparing three things at once: the working directory on disk, the proposed tree sitting in the index, and the tree belonging to HEAD’s commit. Every diff you’ve ever seen — staged versus unstaged, working tree versus last commit — is just Git comparing pairs of these tree-shaped structures and reporting where the blob hashes disagree, exactly the same operation as diffing two arbitrary commits, just with one side not yet promoted to a permanent, committed object.
This is why git add on a large file can be surprisingly slow while git commit afterwards feels instant: the expensive work (hashing and compressing the file into a blob object) already happened at add time. Commit only has to build a tree from blobs that already exist and write one small commit object referencing it, which is also why what actually happens when you type git commit is a genuinely short list of operations once you already know what a blob, tree and commit object are — the interesting work was front-loaded into the staging step, not the commit step most people assume is where the real effort happens.
Troubleshooting with the object model in mind
A repository that suddenly balloons in size is almost always a large binary file committed and then modified repeatedly — a database dump, a build artefact, an image re-exported many times — because each version is a genuinely different blob with essentially no redundancy for Git to deduplicate against, unlike source code where most lines survive unchanged between commits. git count-objects -v and git rev-list --objects --all | git cat-file --batch-check (piped through a size sort) will show you exactly which blobs are eating the disk, and the fix is almost always removing the binary from history entirely with something like git filter-repo, then switching to Git LFS or simply not committing that class of file going forward.
A detached HEAD, which trips up almost everyone the first time it happens, is just what you get when you check out a commit directly rather than a branch — HEAD now points straight at a commit hash instead of at a ref file that itself points at a commit. Nothing is broken and nothing is lost; any commits you make from there are perfectly valid objects, they’re just not referenced by any branch, so they become eligible for garbage collection once you check out something else, unless you create a branch pointing at them first with git switch -c before leaving.
And “my file changes vanished after a rebase” is nearly always a case of the reflog being the only thing still pointing at the orphaned commits — git reflog, followed by git checkout on the commit hash you find just before the rebase, recovers the pre-rebase snapshot directly, because rebase doesn’t delete objects, it just moves the branch pointer and leaves the old commits dangling until garbage collection eventually sweeps them, usually not for another 30 days. Understanding that the objects were never touched, only the branch pointer, is what turns a panicked “I’ve lost a week of work” into a calm two-command recovery.
Is it worth understanding the internals
Yes, more than almost any other piece of trivia in software engineering, because Git’s object model isn’t an implementation detail you can safely ignore behind the porcelain commands. Every genuinely confusing Git situation — a rebase that “lost” commits, a merge conflict that resolves strangely, a repository that’s grown to gigabytes for no visible reason, a git branch command that runs in milliseconds against a codebase with fifteen years of history — is explained directly by the fact that Git stores snapshots addressed by content hash, not diffs addressed by position in time. Once that clicks, the porcelain commands (checkout, rebase, merge, reset) stop being a list of incantations to memorise and start being obviously-named operations on a data structure you can actually picture, which is also why Git branches genuinely are just sticky notes on commits rather than a metaphor stretched past its breaking point.




