I was helping a client debug a weird merge conflict that kept re-appearing even after they resolved it manually. The usual suspects, line endings, whitespace, were clean. What finally gave me a clue was the git ls-tree output showing the same blob hash attached to two different paths. That made me revisit the core of Git: how it actually stores data.
The object database, why Git isn’t a diff tracker
Most developers think of Git as a clever diff tool that records changes between file versions. In reality Git is a content-addressable object store. Every piece of data, a file, a directory snapshot, a commit, becomes an object identified by a SHA-1 (or SHA-256 in newer versions) hash of its contents. Because the hash is derived from the content, identical files across the whole history share the same object.
Blobs, raw file contents
A blob (binary large object) is the simplest Git object. It stores the exact byte sequence of a file, nothing else. When you add a file with git add, Git hashes the file content, creates a blob object, and writes it to .git/objects/xx/xxxx…. The first two hex characters become a directory name, the rest a filename.
# create a blob for a file
printf 'Hello world\n' > hello.txt
git hash-object -w hello.txt
printfwrites the file.git hash-object -whashes the file and writes the blob to the object database, returning the hash.
If you run the command again you’ll get the same hash, Git reuses the existing blob instead of creating a duplicate.
Trees, snapshots of directories
A tree object represents a directory listing. It contains entries of the form <mode> <type> <hash>\t<filename>. The type is either blob for files or tree for sub-directories. A tree does not store file contents; it merely points to blobs (or other trees) that hold the data.
# stage a file and view the tree entry
git add hello.txt
git write-tree
git addstages the file, which writes the blob if it isn’t already present.git write-treecreates a tree object from the current index and prints its hash.
If you add another file in the same directory and run git write-tree again, you’ll get a different tree hash because the list of entries changed, even though the original blob hash stays the same.
Commits, immutable nodes in a DAG
A commit ties a tree to metadata: author, committer, timestamp, message, and parent commit hashes. Commits form a directed acyclic graph (DAG). Each commit points to exactly one tree (the snapshot of the repository at that point) and zero or more parent commits (usually one, two for merges).
# create a commit from the current index
git commit -m 'Add hello file'
git commitwrites a new commit object referencing the current index tree.- The resulting commit hash is unique; if you change the message or parent, the hash changes even if the tree is identical.
Because commits are immutable, Git can safely share objects between branches. Two branches that diverge but later merge may still reference the same blob objects for unchanged files.
The index, a staging area that bridges working tree and object DB
The index (or staging area) lives in .git/index. It mirrors the working directory but stores the SHA-1 of each file as it will appear in the next commit. When you run git add, Git writes the blob (if needed) and records the blob hash in the index. git status compares the index against the working tree and the HEAD commit to tell you what’s staged, modified, or untracked.
A common mistake is assuming the index is just a convenience layer; in fact many commands (e.g., git commit --amend) operate directly on the index without touching the working tree. Understanding that the index holds hashes, not file paths, explains why a stray whitespace change can cause a whole new blob even if the logical content is unchanged.
Why the object model matters in real projects
1. Reducing repository size
Because identical blobs are deduplicated, large binary assets that never change stay as a single object. If you ever need to prune old data, git gc rewrites packfiles to compress and remove unreachable objects.
2. Faster branching and merging
Creating a new branch is just a new pointer to an existing commit. No files are copied. Merges are cheap because Git only needs to compare tree objects, not diff every file line-by-line.
3. Recovering lost files
If a file disappears from the working tree but its blob still exists, you can restore it with git cat-file -p <blob-hash> > restored.txt. The hash can be found via git rev-list --all -- <path>.
Common pitfalls and how to avoid them
- Assuming
git diffshows the whole story,git diffworks on the index vs. HEAD or working tree vs. index. It never looks at the underlying object graph. If you need to see what changed between two commits, usegit diff <hash1> <hash2>which compares the trees directly. - Modifying the
.git/objectsdirectory, The objects are immutable. Deleting a file there will corrupt the repo unless you immediately rungit pruneandgit gcto rebuild packfiles. Never edit blobs by hand. - Relying on file timestamps, Git ignores timestamps when computing hashes. A file with the same content but a different modification time will still map to the same blob.
- Large files and LFS, Git stores the whole file in a blob, which can bloat the repo. For assets >100 KB, consider Git LFS; it stores a pointer blob instead of the full file.
Inspecting the object graph with plumbing commands
Git provides low-level "plumbing" commands that let you explore the database directly.
# list all objects reachable from HEAD
git rev-list --objects HEAD
# show a blob's raw content
git cat-file -p <blob-hash>
# display a tree in a readable format
git ls-tree <tree-hash>
git rev-listwalks the commit DAG and prints object hashes.git cat-file -pprints the decompressed content of any object.git ls-treeshows mode, type, hash and filename for a tree.
These commands are invaluable when debugging strange merge conflicts or when you need to verify that two branches truly share the same file version.
Practical workflow tip: using the index as a sandbox
When experimenting with a refactor, you can stage changes without committing and then reset the index to a previous state:
# stage changes
git add .
# inspect the staged tree
git write-tree
# if you hate the result, drop the index
git reset
git add .stages everything.git write-treeshows the new tree hash, you can compare it to the previous one.git resetclears the index, leaving the working tree untouched.
This pattern lets you play with the object model without polluting your commit history.
When the model breaks: large monorepos
In monorepos with millions of files, the index can become a performance bottleneck because it must hold a hash for every file. Tools like git sparse-checkout let you limit the index to a subset of the tree, reducing memory usage. The underlying object model still works the same; only the view presented to the user changes.
FAQ
How can I find which commit introduced a particular line?
Use git blame <file>; it walks the commit DAG backward, looking at the tree objects until it finds the first commit that introduced the line.
Why does git push sometimes say "everything up-to-date" even though I added a file?
If the file’s content already exists as a blob in the remote (perhaps from another branch), Git may only need to send the new tree and commit objects. The remote already has the blob, so the push appears smaller.
Can I manually create a tree or commit?
Yes, with git mktree and git commit-tree. This is useful for scripts that need to build commits without a working directory, such as CI pipelines that generate artifacts on the fly.
Wrapping up
If you think of Git as just a diff tool you’ll keep chasing ghosts in merge conflicts. The reality is a content-addressable store where blobs, trees, and commits form a permanent DAG, and the index is the mutable bridge to your working directory. Knowing this lets you reason about storage, diagnose weird behavior, and even script custom workflows that bypass the usual high-level commands. The next time a conflict pops up, peek at the underlying objects, you’ll often see the answer before you even open an editor.