Git Internals: Objects, Trees, Blobs and the DAG — How Git Actually Stores Your Data
Git internals explained: objects, trees, blobs, and the DAG.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓Basic Git usage (commit, push, pull, merge)
- ✓Comfortable with command line
- ✓Understanding of hash functions (SHA-1)
Git stores your files as blobs, directories as trees, and snapshots as commits. Each object is hashed and stored in .git/objects. The commit graph is a DAG where each commit points to its parent(s) and a tree that represents the root directory.
Think of Git as a time-traveling filing cabinet. Each drawer (commit) contains a snapshot of every file (blobs) and folder structure (trees). The drawer labels are unique IDs (hashes) derived from the contents. The cabinet's chain of drawers forms a DAG — you can go forward or backward in time, but never in circles.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You've used Git for years. You know the incantations: commit, push, merge, rebase. But when git fsck screams about a missing object at 3 AM, or a corrupted packfile eats your history, your textbook knowledge won't save you. I've seen a junior engineer panic-delete .git/objects because 'it was taking too much space' — that was a fun Monday.
The problem Git solves is deceptively hard: versioning a directory tree efficiently across time, with branching and merging, all while keeping history immutable and verifiable. Before Git, version control systems like SVN stored deltas — changes between versions. That made branching expensive and history fragile. Git flipped the model: store complete snapshots as objects, and let the DAG handle the rest.
By the end of this, you'll be able to recover a corrupted repository by hand, understand why git gc sometimes doubles your repo size, and explain to your teammates why rebasing is just rewriting the DAG. You'll never blindly trust git push --force again.
The Object Store: Content-Addressable Storage
Git's core is a key-value store. The key is a SHA-1 hash of the content; the value is the object itself. This means identical content always produces the same hash — deduplication for free. Objects are stored in .git/objects/ as loose files (one per object) or packed into packfiles.
When you run git add, Git creates a blob object for each file. The blob stores the file content, not the filename. Filenames are stored in tree objects. This separation is critical: renaming a file doesn't change its blob, only the tree that references it.
To inspect an object, use git cat-file -p <hash>. To see its type, use git cat-file -t <hash>. These commands are your window into the object store.
git rev-list --objects --all to list every object in the repo. Pipe to git cat-file --batch-check to see types and sizes. Great for finding large blobs.Blobs: The Atomic Unit of Content
A blob is a binary large object — it stores the raw content of a file. Blobs are immutable: once created, they never change. If you modify a file and re-stage it, Git creates a new blob. The old blob remains in the object store until garbage-collected.
Blobs don't store metadata like filename, permissions, or timestamps. That's intentional: it allows Git to deduplicate identical content across files and commits. Two files with the same content share the same blob, even if they have different names.
To create a blob manually without staging, use git hash-object -w <file>. The -w flag writes the object to the store. Without it, you just get the hash. This is useful for scripting and recovery.
git lfs or git annex for large files. A 100MB binary committed once stays forever — even if deleted in later commits, it lives in history.Trees: Directory Snapshots
A tree object represents a directory. It contains a list of entries, each with a mode (file type and permissions), a name, and a reference to a blob (for files) or another tree (for subdirectories). The tree is essentially a sorted list of (mode, name, hash) tuples.
Trees are also immutable. When you change a file, Git creates a new tree for the directory containing that file, and recursively for all parent directories up to the root. This is how Git efficiently represents changes: only the affected trees are new; unchanged subtrees are reused.
You can view a tree with git ls-tree <tree-ish>. The tree-ish can be a commit hash, branch name, or tree hash. Use -r for recursive listing.
git log --follow is expensive — it has to walk the DAG and compare trees.Commits: The DAG Nodes
A commit object ties everything together. It contains: a tree hash (root directory snapshot), zero or more parent commit hashes (for merge commits), author/committer info, timestamp, and a message. The parent pointers form the DAG.
A linear history is a chain of commits with one parent each. A merge commit has two or more parents. A root commit (first commit) has no parents. The DAG is directed (parent pointers go backward in time) and acyclic (you can't have a cycle because parent hashes are computed before child).
To see the DAG, use git log --graph --oneline. For a raw view, git cat-file -p HEAD shows the commit object.
git commit --allow-empty to create a commit without changes. It still creates a commit object with the same tree as parent. It's useless and pollutes history. Use git tag instead for markers.The DAG: Directed Acyclic Graph in Practice
The DAG is what makes Git powerful. Branching is just creating a pointer to a commit. Merging creates a commit with two parents. Rebasing rewrites history by creating new commits with new parent pointers — the old commits become unreachable and eventually garbage-collected.
Reachability is key: a commit is 'alive' if it's reachable from a ref (branch, tag, HEAD). Unreachable commits are 'dangling' and removed by git gc. This is how Git manages history: you can delete a branch, and its commits vanish from the DAG.
To visualize the DAG, use git log --graph --oneline --decorate --all. For a more detailed view, git rev-list --parents HEAD shows parent hashes.
Packfiles: Efficient Storage for the DAG
Loose objects are fine for small repos, but they waste space and inodes. Git packs objects into packfiles — a single file containing multiple objects, compressed with delta encoding. A packfile has an index (.idx) for fast lookup.
Delta encoding stores a base object and a series of deltas (differences). Git finds similar objects (e.g., consecutive versions of a file) and stores only the differences. This is why git gc can drastically reduce repo size.
To inspect packfiles, use git verify-pack -v .git/objects/pack/*.idx. It shows each object, its type, size, and delta chain. A deep delta chain (e.g., >100) can slow down access. git repack -a -d --depth=250 --window=250 rebuilds the pack with controlled depth.
git gc on a bare repo with many concurrent fetches can cause corruption. Git 2.0+ has gc.autoDetach to run in background, but on shared filesystems (NFS), it's risky. Always run git gc during maintenance windows.Refs and Reflog: Navigating the DAG
Refs are pointers to commits. Branches, tags, and HEAD are refs stored in .git/refs/. A branch is a movable pointer; a tag is usually immutable. HEAD is a symbolic ref that points to the current branch (or directly to a commit in detached HEAD state).
The reflog records every movement of HEAD and branch refs. It's a local history of where your pointers have been. git reflog shows the last N actions. This is your safety net: even if you lose a commit from the DAG (e.g., after a reset), the reflog keeps it alive for 90 days by default.
To recover a lost commit, find its hash in reflog and create a branch: git branch recover <hash>.
gc.reflogExpire=never for critical repos? No — that's dangerous. Instead, set gc.reflogExpire=90.days and gc.reflogExpireUnreachable=30.days. That gives you 90 days to recover any commit, even if unreachable.Recovering from Corruption: When the DAG Breaks
Corruption happens: disk errors, interrupted git push, buggy Git versions. Symptoms include fatal: bad object, error: packfile .idx mismatch, or fatal: loose object <hash> is corrupt.
First, run git fsck --full to identify all corrupt or missing objects. If a loose object is corrupt, you might recover it from a packfile: git unpack-objects < .git/objects/pack/pack-*.pack extracts all objects, then git fsck again. If a packfile is corrupt, you may need to delete it and re-fetch from a remote.
For a bare repo, git fsck --full is safe. For a non-bare, be careful: git fsck checks the object store, not the working tree. Always have a backup.
git prune without git fsck first. git prune removes unreachable objects — if you have a corrupt ref, you might prune reachable objects. Always run git fsck --full and verify no errors before pruning.When Not to Use Git Internals Knowledge
You don't need to dive into .git/objects every day. For 99% of operations, git add, git commit, git push are enough. Understanding internals is for debugging, recovery, and advanced workflows (e.g., custom hooks, CI optimizations).
If you're building a tool that manipulates Git objects directly (e.g., a custom merge driver), you need this knowledge. But for everyday development, it's overkill. Don't manually create objects unless you have to.
Also, don't rely on internal formats for backup. Git's object format is stable, but packfile format can change. Always use git bundle or git clone --mirror for backups.
git gc behavior. Otherwise, trust the porcelain.The 4GB Container That Kept Dying
git clone on a monorepo would OOM-kill after 10 minutes. The repo was 2GB compressed, 8GB unpacked.git fetch without git gc. Git tried to load all packfile indexes into memory during clone, exhausting the 4GB limit. The core.deltaBaseCacheLimit default (96MB) was also too low for the delta chain depth.git config core.deltaBaseCacheLimit 256m and run git gc --aggressive on the remote. Then clone with --depth=1 for CI. Also set fetch.unpackLimit=1 to avoid creating new packfiles on every fetch.- Never let a monorepo accumulate packfiles without periodic GC.
- And always shallow-clone in CI.
fatal: bad object refs/heads/maingit fsck --full to list missing objects. 2. If missing from pack, try git unpack-objects < .git/objects/pack/*.pack. 3. If still missing, restore from backup or re-clone.error: packfile .git/objects/pack/pack-*.idx contains unknown objectgit fsck --full to identify missing objects. 3. Fetch from remote: git fetch origin --refetch (Git 2.34+) or re-clone.git gcgit verify-pack -v .git/objects/pack/*.idx | sort -k3 -n to find large objects. 2. If many duplicates, run git repack -a -d --depth=250 --window=250. 3. Check for large blobs with git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' | awk '/^blob/ {print $2, $3}' | sort -n | tail -10.ls .git/objects/$(echo <hash> | cut -c1-2)/$(echo <hash> | cut -c3-)git verify-pack -v .git/objects/pack/*.idx | grep <hash>git unpack-objects < .git/objects/pack/*.pack. If packed and corrupt, delete pack and re-fetch.| File | Command / Code | Purpose |
|---|---|---|
| explore-objects.sh | mkdir /tmp/git-internals && cd /tmp/git-internals | The Object Store |
| blob-manipulation.sh | cd /tmp/git-internals | Blobs |
| tree-inspection.sh | cd /tmp/git-internals | Trees |
| commit-dag.sh | cd /tmp/git-internals | Commits |
| dag-visualization.sh | cd /tmp/git-internals | The DAG |
| packfile-inspection.sh | cd /tmp/git-internals | Packfiles |
| reflog-recovery.sh | cd /tmp/git-internals | Refs and Reflog |
| corruption-recovery.sh | cd /tmp/git-internals | Recovering from Corruption |
Key takeaways
git verify-pack.Interview Questions on This Topic
How does Git handle a merge conflict internally? Does it create special objects?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Git. Mark it forged?
4 min read · try the examples if you haven't