Everyone "knows" Git stores diffs. Then you read the object store and find the opposite: every commit stores a full snapshot of every changed file. Commit a 400-line file, append one line, commit again, and .git/objects now holds two complete blobs. Here's a run on git 2.43.0:

console
$ git cat-file -s 19c2c78   # blob from commit 1
18589
$ git cat-file -s 4f601b1   # blob from commit 2 (one line added)
18605
$ du -b .git/objects/19/c2c78... .git/objects/4f/601b1...
4719    .git/objects/19/c2c78...
4739    .git/objects/4f/601b1...

Two nearly identical files, each stored whole as a zlib-compressed loose object — roughly 4.7 KB apiece on disk. Git made no attempt to exploit the similarity. Now run git gc and inspect the resulting pack:

console
$ git verify-pack -v .git/objects/pack/pack-552b533c....idx | grep -E "19c2c78|4f601b1"
4f601b1... blob   18605 3719 531
19c2c78... blob   9 20 4342 1 4f601b1...

Two things worth staring at. The older blob collapsed from 18,589 bytes to a 9-byte delta (20 bytes in the pack). And the delta points backward in time: the old version is reconstructed from the new one, not the other way around. The newest version is stored whole. Pro Git explains the heuristic: the most recent version of a file is the one most likely to be needed quickly, so it's the version that should cost zero delta applications to materialize. Your git checkout of HEAD never pays the delta tax; git show v0.1:file.c does.

So the folklore is wrong twice. Git doesn't store diffs — until it repacks, at which point it stores reverse deltas that have nothing to do with your commit history.

A delta is a tiny program, not a diff

The on-disk format is specified in gitformat-pack. A packfile starts with a 12-byte header — the 4-byte signature PACK, a 4-byte version ("Git currently accepts version number 2 or 3 but generates version 2 only"), and a 4-byte object count — followed by the object entries and a trailing checksum of everything above.

Each entry begins with a variable-length header packing a 3-bit type and the uncompressed size (7 bits per byte, MSB as a continuation flag — object sizes are not constrained to 32 bits). The type table is where deltas live:

TypeNameMeaning
1–4OBJ_COMMIT, OBJ_TREE, OBJ_BLOB, OBJ_TAGFull objects, zlib-deflated
6OBJ_OFS_DELTABase identified by a negative byte offset within the same pack
7OBJ_REF_DELTABase identified by object name

OBJ_REF_DELTA may name a base that isn't in the pack at all — that's a thin pack, which only makes sense during a transfer where the receiver already holds the base objects. On disk, the spec is blunt: "the pack should be self contained to avoid cyclic dependency," so thin packs get completed with their missing bases once they land.

The delta payload itself opens with two varint sizes (base size, reconstructed size) followed by a sequence of just two instruction types:

  • Copy (MSB set): copy up to 64 KiB from a given offset in the base. A size field of zero means 0x10000, so the densest possible instruction — the single byte 0x80 — means "copy 64 KiB from offset 0."
  • Insert (MSB clear): append up to 127 literal bytes that follow the instruction.

That's the whole language. It also explains the 9-byte delta above. The old file is a strict prefix of the new one (a line was appended), and the delta runs new→old, so no insert is needed at all: two 3-byte size headers plus a single copy instruction — "copy the first 18,589 bytes of the base" — account for all nine bytes. It's memcpy with a script, which is why applying deltas is fast — and why a delta is not a textual diff. It has no notion of lines, hunks, or files. git log -p output is computed fresh at display time by diffing two fully-reconstructed blobs; it is never read off disk.

How Git decides who deltas against whom

Finding the best base for each of a million objects is quadratic if done naively. git-repack documents the actual strategy: objects are "first internally sorted by type, size and optionally names and compared against the other objects within --window to see if using delta compression saves space."

So Git never searches globally. It sorts candidates so that similar objects land near each other — same type, similar size, similar filename — then slides a window (default 10 objects) over the list and tries each object against its window neighbors, keeping the smallest delta. This is why renaming a file doesn't bloat your repository: delta selection keys off content-shaped signals, not history. Two blobs from unrelated branches can delta against each other if they happen to be neighbors in the sorted list.

Chains are bounded by --depth, default 50, hard maximum 4095. Depth is a read-amplification knob: to materialize an object at depth N, the unpacker must reconstruct every base above it, applying delta data "that many times to get to the necessary object," as the docs put it. Bigger windows and depths shrink the pack and slow down every future read — a classic write-once/read-forever tradeoff, and the reason --window=250 --depth=250 advice from old mailing lists should be taken with suspicion.

Two more limits shape real repositories:

  • core.bigFileThreshold (default 512 MiB): files above it are stored deflated in packs without delta compression and are treated as binary for diff purposes. Large assets never participate in the delta machinery at all.
  • Already-compressed content (JPEGs, zips, encrypted blobs) technically can be deltified, but near-identical source edits produce wildly different compressed bytes, so the window search finds no savings. This — not file size per se — is why committing build artifacts makes repositories grow linearly forever.

When packing actually happens

Loose objects are the write-optimized format: creating one is an O(1) zlib-and-rename. Packs are the read- and storage-optimized format. Git moves between them when:

  1. git gc --auto fires because the repository has approximately more loose objects than gc.auto (default 6700), or more than gc.autoPackLimit (default 50) packs exist without a .keep file.
  2. You push to a remote — transfers are built on packs, assembled on the fly from the objects the other side is missing.
  3. You run git gc or git repack -a -d yourself: -a packs everything referenced into a single pack, -d deletes the now-redundant packs and loose objects. There's also git repack --geometric=, which maintains a ladder of packs where each successive pack holds at least n× as many objects as the next-largest — it repacks the smallest set of packfiles it can while leaving the big ones intact, keeping maintenance cheap on huge repositories.

The companion .idx file is what makes packs usable as a database: a 256-entry fan-out table keyed on the first byte of the object ID, sorted object names, per-object CRC32s (v2 — so object data can be copied between packs and still be verified), and 4-byte offsets with an 8-byte spill table that only comes into play for packs over 2 GiB.

In my toy repo the arithmetic came out like this: ~10 KB of loose blobs became a 4,382-byte pack holding both versions, with the second version costing 20 bytes. Multiply that by every version of every file across ten years of history, and you get the everyday miracle nobody looks at: a .git directory holding thousands of snapshots of your tree, frequently smaller than the single checkout sitting next to it.

References