What you'll learn
Quick Answer
Git is not a series of diffs — it's a content-addressable key-value store where every object's key is the SHA-1 hash of its exact bytes. A blob stores raw file content, a tree maps filenames to blob or tree hashes, and a commit is just a pointer to one tree plus a pointer to its parent commit. Every hash and byte of output in this post came from a real git repository we created and inspected with git hash-object, git cat-file, and git ls-tree — nothing here is recited from memory.
Content-Addressable Storage: The Core Idea
Git is often taught as a tool for tracking changes, which makes people assume it stores diffs internally. It doesn't. Underneath every command you run day to day, Git is a key-value store where the key is always the SHA-1 hash of the value's exact bytes — this is what "content-addressable" means. The same content, hashed anywhere, at any time, on any machine, produces the exact same key, because the key is a pure mathematical function of the bytes, not something Git assigns or tracks separately.
To prove this rather than just assert it, we created two separate files with identical content (hello priodemy\n) and ran git hash-object on each one independently, before either file was ever added to a repository, using git version 2.52.0.windows.1:
$ git hash-object hello.txt
16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58
$ git hash-object hello2.txt
16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58Identical hash, from two different files, computed before Git had anywhere to store either one. The hash isn't looked up from some database — it's calculated fresh from the content every time, which is the entire foundation everything else in this post builds on.
Blobs: Where the Actual File Bytes Live
A blob object stores exactly one thing: raw file content. Not the filename, not permissions, not a path — just bytes. That's why hello.txt and hello2.txt from the previous section, despite having different names, hash to the exact same blob; a blob has no concept of what it's named anywhere in a working tree.
We wrote that blob into a real repository with git hash-object -w hello.txt, then inspected it two ways:
$ git cat-file -t 16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58
blob
$ git cat-file -p 16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58
hello priodemyThat confirms what Git says the object is and what it contains — but to see what's actually sitting on disk, we went one level lower and decompressed the real file at .git/objects/16/e1f1c4aa1f209a81e941f9b7e5415ad13d6c58 directly with Python's zlib module, bypassing Git entirely:
>>> import zlib
>>> data = open('.git/objects/16/e1f1c4aa1f209a81e941f9b7e5415ad13d6c58', 'rb').read()
>>> zlib.decompress(data)
b'blob 15\x00hello priodemy\n'That's the literal, complete content of the object: the ASCII header blob 15 (the type, a space, and the decimal byte length of the content), a single NUL byte, and then the raw content appended after it — all zlib-compressed for storage. This is also the exact formula Git uses to compute the hash in the first place. We confirmed this by hand, independently of Git, using Python's hashlib:
>>> import hashlib
>>> content = b'hello priodemy\n'
>>> header = f'blob {len(content)}\0'.encode()
>>> hashlib.sha1(header + content).hexdigest()
'16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58'Same hash Git computed on its own. This isn't a coincidence pointed out after the fact — it's the actual formula: a blob's identity is SHA-1 of the string "blob " + byte-length + "\0" + content, and every object type Git has (blob, tree, commit, tag) follows this exact same pattern with a different type word in the header.
Trees: Directories Are Objects Too
A blob has no name and no place in a directory structure — that information has to live somewhere, and it lives in a tree object. A tree maps names and file-mode permissions to the hash of a blob (for a file) or another tree (for a subdirectory). We staged our hello.txt alongside a second file, second.py, and ran git write-tree, which produced a real tree object:
$ git write-tree
e437fa138d973ebdc81a75ac457f3573f4dfe2f2
$ git ls-tree e437fa138d973ebdc81a75ac457f3573f4dfe2f2
100644 blob 16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58 hello.txt
100644 blob f282905bf7d247e716d48081ec6c6dab608293d6 second.pyNotice that hello.txt's blob hash in this listing is the exact same 16e1f1c4... hash from the previous section — the tree doesn't store a copy of the file, it stores a pointer (the hash) to the already-existing blob. The 100644 is a Unix-style file mode: 100644 means a regular, non-executable file. Other real Git tree modes you'll encounter are 100755 for an executable file, 040000 for a nested tree (a subdirectory), 120000 for a symbolic link, and 160000 for a submodule reference (a gitlink). A directory with subdirectories is simply a tree object whose entries point at other tree objects, recursively — Git's directory structure is a hash-linked tree of tree objects, not a special filesystem feature bolted on separately.
Commits: Just a Pointer to a Tree, Plus Metadata
This is the part that surprises people who assume a commit stores a diff. We created a real commit with git commit-tree against the tree above and inspected its raw content:
$ git commit-tree e437fa138d973ebdc81a75ac457f3573f4dfe2f2
b947fef294b4d6780be49277e1d4e68cccc9990c
$ git cat-file -p b947fef294b4d6780be49277e1d4e68cccc9990c
tree e437fa138d973ebdc81a75ac457f3573f4dfe2f2
author Priodemy Demo <[email protected]> 1790224200 +0530
committer Priodemy Demo <[email protected]> 1790224200 +0530
Initial commit: add hello.txt and second.pyFour pieces of information, nothing else: a pointer to one tree (the complete snapshot), an author line, a committer line, and a message. No diff data exists anywhere in this object. The differences you see in git diff or git log -p are computed on the fly by comparing two tree snapshots against each other — they're a view Git generates for you, not something stored on disk.
We then edited second.py and made a second, real commit on top of the first:
$ git write-tree
4c2f081ab9452ce562a8417c5b99938114421bb4
$ git ls-tree 4c2f081ab9452ce562a8417c5b99938114421bb4
100644 blob 16e1f1c4aa1f209a81e941f9b7e5415ad13d6c58 hello.txt
100644 blob 439e02fcd5fee4fdad743aed11d4ffa1d6096ff8 second.py
$ git commit-tree 4c2f081ab9452ce562a8417c5b99938114421bb4 -p b947fef294b4d6780be49277e1d4e68cccc9990c
0720163bb7b9f8180dd334d70820e50d045f0072
$ git cat-file -p 0720163bb7b9f8180dd334d70820e50d045f0072
tree 4c2f081ab9452ce562a8417c5b99938114421bb4
parent b947fef294b4d6780be49277e1d4e68cccc9990c
author Priodemy Demo <[email protected]> 1790224500 +0530
committer Priodemy Demo <[email protected]> 1790224500 +0530
Edit second.pyLook closely at the second tree's listing: hello.txt still points at the exact same blob, 16e1f1c4..., bit-for-bit unchanged, while second.py got a brand-new blob, 439e02fc..., because its content actually changed. This is the real, observable reason Git history doesn't balloon in size as a project grows — each commit only ever creates new objects for what actually changed and reuses everything else by hash. A file untouched across a thousand commits is stored exactly once.
The Whole Graph, and What's Physically on Disk
We pointed a real branch at the second commit and confirmed the resulting history with git log:
$ git update-ref refs/heads/master 0720163bb7b9f8180dd334d70820e50d045f0072
$ git log --oneline
0720163 Edit second.py
b947fef Initial commit: add hello.txt and second.pyA branch is nothing more than a text file containing one 40-character commit hash — refs/heads/master is a real file on disk, and git update-ref quite literally overwrote its contents with a new hash. Moving a branch pointer is one small file write, not a copy of any project files.
Zooming out, here's every loose object our entire two-commit demo actually created on disk:
$ find .git/objects -type f
.git/objects/07/20163bb7b9f8180dd334d70820e50d045f0072 (commit 2)
.git/objects/16/e1f1c4aa1f209a81e941f9b7e5415ad13d6c58 (blob: hello.txt, shared by both commits)
.git/objects/43/9e02fcd5fee4fdad743aed11d4ffa1d6096ff8 (blob: second.py, v2)
.git/objects/4c/2f081ab9452ce562a8417c5b99938114421bb4 (tree 2)
.git/objects/b9/47fef294b4d6780be49277e1d4e68cccc9990c (commit 1)
.git/objects/e4/37fa138d973ebdc81a75ac457f3573f4dfe2f2 (tree 1)
.git/objects/f2/82905bf7d247e716d48081ec6c6dab608293d6 (blob: second.py, v1)Seven objects total for two commits touching two files — not eight, because the unchanged hello.txt blob was reused rather than duplicated. The two-character subdirectory names (07, 16, 43...) are just the first two characters of each object's hash; splitting objects across these subdirectories is purely a filesystem optimization to avoid dumping tens of thousands of files into one flat directory as a repository grows, and carries no meaning beyond that. In a real, mature repository, Git periodically runs git gc to pack these loose objects into a single delta-compressed .pack file for space efficiency — but that's a storage-layer optimization on top of the same logical model; every command we ran above works identically whether objects are loose or packed.
Why This Model Is the Reason Git Works the Way It Does
Because a commit's hash depends on its tree's hash, which depends on every blob's hash it (directly or recursively) contains, and a commit also depends on its parent commit's hash, the whole history forms a hash chain where changing a single byte anywhere in the past would change every hash that comes after it. That's exactly why a commit hash functions as a tamper-evident fingerprint of an entire project's history up to that point — and why two independent clones reporting the same head commit hash are provably identical down to the byte, without needing to trust a central server to confirm it.
It's also why operations that feel instant in Git actually are cheap under the hood, not just fast in practice. Creating a branch is writing one new short text file, because the underlying blobs and trees it points at already exist in the object store and can be referenced by any number of commits or branches simultaneously — nothing gets copied. This is the direct, mechanical reason branching in Git is cheap enough to do dozens of times a day without a second thought.
One forward-looking note: Git has an experimental SHA-256 object format (initialized with git init --object-format=sha256), motivated by SHA-1's known theoretical collision weaknesses. It produces 64-character hashes instead of the 40-character ones shown throughout this post, using the exact same "type size\0content" hashing formula we verified by hand above — the object model itself doesn't change, only which hash function computes the key.
