Most engineers use Git every day while carrying a mental model of it that is simply wrong: that Git stores your history as a chain of diffs, replaying patches to reconstruct any version. It doesn't. At its core, Git is a content-addressable filesystem — a key-value store where the key is a hash of the value — and every commit is a full snapshot, not a delta. Diffs do exist in Git, but they live in a completely different layer than most people assume, and understanding which layer does what explains a lot of otherwise mysterious behavior: why .git sometimes balloons, what git gc actually does, and why "deleted" commits stick around for weeks.

Everything is one of four objects

Every piece of data Git tracks ends up in .git/objects, the object database. There are three object types you interact with constantly (plus tags, which we'll skip):

  • Blob — the raw content of a file. Just the bytes, no filename, no permissions.
  • Tree — a directory listing. Each entry records a mode, a type, a hash, and a name, mapping names to blobs (files) or other trees (subdirectories).
  • Commit — a pointer to one top-level tree, plus parent commit(s), author and committer info, and the message.

You can watch a blob get born with plumbing commands:

bash
$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

That 40-character key is not a hash of the file content alone. Git first builds a header of the form blob \0, concatenates it with the content, and takes the SHA-1 of the combined bytes. The result is stored zlib-compressed at a path derived from the hash itself: the first two characters name a subdirectory, the remaining 38 name the file — so the blob above lives at .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4. Trees and commits are stored exactly the same way; only the type string in the header changes.

A tree object printed with git cat-file -p looks like this:

100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

For regular files, only three modes are valid: 100644 (normal file), 100755 (executable), and 120000 (symlink). Notice what this structure buys you: filenames live in trees, not blobs. Rename a file without changing its content and the blob is untouched — only the tree changes. Two identical files anywhere in your repository, or the same file across a thousand commits, are the same blob, stored once. Content addressing gives you deduplication for free.

So where's the diff?

Here is the part that breaks the "chain of patches" mental model. When you commit a change to one line of a 5,000-line file, Git writes a brand-new blob for the entire file. The commit object points to a new top-level tree, which points to new trees along the changed path, which point to the new blob. Everything unchanged is shared with previous commits by hash. The output of git diff is computed on demand by comparing two snapshots — it is a view, not the storage format.

If that were the whole story, repositories would grow absurdly: a hundred small edits to a large file would mean a hundred nearly identical compressed copies. These individually-stored objects are called loose objects, and Git deliberately doesn't leave them that way forever.

Packfiles: where deltas actually live

Periodically — when there are too many loose objects, when you push to a remote, or when you run git gc yourself — Git packs loose objects into a packfile. This is the layer where delta compression finally appears, as a storage optimization rather than a data model.

The Pro Git book demonstrates it with a ~22K source file committed twice with a one-line change. Stored loose, each version compresses to about 7K independently. After git gc, git verify-pack -v on the resulting pack shows something like:

b042a60ef7dff760008df33cee372b945b6e884e blob   22054 5799 1463
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   9 20 7262 1 \
  b042a60ef7dff760008df33cee372b945b6e884e

One version is stored intact (22,054 bytes, compressed to 5,799), and the other has shrunk to a 9-byte delta that references it. The direction is the interesting design decision: the second, newer version is the one stored whole, and the older version is stored as a delta against it — because you're far more likely to need fast access to the most recent version of a file. A naive patch-chain design would do the opposite and make git checkout slower the longer your history gets.

On disk, a pack is two files under .git/objects/pack/: the .pack file holding all the object data, and a companion .idx index that maps object hashes to byte offsets so Git can seek straight to any object without scanning the pack.

Two consequences worth internalizing:

  1. Delta choice is not tied to history. Git can delta any object against any similar object in the pack — different files, unrelated branches, whatever compresses best. Repacking can rewrite all of these decisions at any time without changing a single object hash, because the hashes address content, not storage layout.
  2. Object identity never changes. Loose or packed, whole or delta — the same content always has the same hash. Storage format is invisible to every command above the plumbing.

What `git gc` is actually deciding

Because packing is a background concern, Git triggers it heuristically, and the defaults are concrete numbers you can read out of the git-gc documentation:

SettingDefaultMeaning
gc.auto6700Roughly this many loose objects triggers an automatic pack
gc.autoPackLimit50More packs than this get consolidated into one larger pack
gc.pruneExpire2.weeks.agoLoose objects unreachable for longer than this get deleted

Common porcelain commands that create objects check whether the repository has grown substantially since the last maintenance and run git gc --auto when the thresholds are exceeded. That last row also answers a frequent question: why can you recover a commit after a botched reset? Objects that become unreachable — orphaned commits, blobs from amended commits — are not deleted when you "lose" them. By default git gc only prunes loose unreachable objects older than two weeks, which is exactly the safety window that makes git reflog rescues possible. (Setting it to now trades that safety away and increases corruption risk if other operations are running concurrently.)

Why the model matters

Once you see the two layers — an immutable, content-addressed object graph on top, and a repackable compression layer underneath — several everyday behaviors stop being folklore:

  • Commits are cheap because unchanged subtrees are shared by hash, not copied.
  • A bloated .git after adding a huge binary stays bloated even after you delete the file, because the blob remains reachable from history; only rewriting history and letting pruning expire it reclaims the space.
  • git gc is safe to run anytime in the sense that repacking never alters object identity — it only changes how the same graph is serialized to disk.

The diff you see in code review is a presentation-layer artifact. The delta that saves your disk is a storage-layer artifact. Between them sits the actual data model — snapshots, all the way down.

References