Home DevOps Git Internals: Objects, Trees, Blobs and the DAG — How Git Actually Stores Your Data
Advanced 4 min · July 07, 2026

Git Internals: Objects, Trees, Blobs and the DAG — How Git Actually Stores Your Data

Git internals explained: objects, trees, blobs, and the DAG.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 07, 2026
last updated
214
articles · all by Naren
Before you start⏱ 30 min
  • Basic Git usage (commit, push, pull, merge)
  • Comfortable with command line
  • Understanding of hash functions (SHA-1)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

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.

✦ Definition~90s read
What is Git Internals?

Git is a content-addressable filesystem. It stores data as objects identified by SHA-1 hashes. The four object types — blob, tree, commit, and tag — form a directed acyclic graph (DAG) that represents your project history.

Think of Git as a time-traveling filing cabinet.
Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

explore-objects.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/bin/bash
# io.thecodeforge — DevOps tutorial

# Create a test repo and explore objects
mkdir /tmp/git-internals && cd /tmp/git-internals
git init

# Create a file and stage it
echo "Hello, Git Internals" > README.md
git add README.md

# Find the blob hash
BLOB_HASH=$(git hash-object README.md)
echo "Blob hash: $BLOB_HASH"

# Show the object type and content
git cat-file -t $BLOB_HASH
git cat-file -p $BLOB_HASH

# Now commit to see tree and commit objects
git commit -m "Initial commit"

# Get the commit hash
COMMIT_HASH=$(git rev-parse HEAD)
echo "Commit hash: $COMMIT_HASH"

# Show the commit object
git cat-file -p $COMMIT_HASH

# Show the tree object
git ls-tree -r HEAD
Output
Blob hash: 8b6f3b9c3c6b8f3b9c3c6b8f3b9c3c6b8f3b9c3
blob
Hello, Git Internals
Commit hash: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
tree a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
parent (none)
author John Doe <john@example.com> 1234567890 -0500
committer John Doe <john@example.com> 1234567890 -0500
Initial commit
100644 blob 8b6f3b9c3c6b8f3b9c3c6b8f3b9c3c6b8f3b9c3 README.md
Senior Shortcut:
Use 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.

blob-manipulation.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Create a blob manually
echo "Manual blob content" | git hash-object -w --stdin
# Output: <hash>

# Verify it exists
git cat-file -p <hash>

# Two files with same content share blob
echo "Same content" > file1.txt
echo "Same content" > file2.txt
git add file1.txt file2.txt
BLOB1=$(git hash-object file1.txt)
BLOB2=$(git hash-object file2.txt)
echo "Blob1: $BLOB1, Blob2: $BLOB2"
# Both hashes are identical
Output
Manual blob content
Blob1: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Blob2: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Production Trap:
Large blobs (e.g., binaries, datasets) bloat your repo. They never get deduplicated because content changes. Use 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.

tree-inspection.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Create a nested structure
mkdir -p src/lib
echo "main code" > src/main.go
echo "library code" > src/lib/helper.go
git add .
git commit -m "Add source files"

# Show the tree for HEAD
git ls-tree -r HEAD

# Show the tree object itself (non-recursive)
TREE_HASH=$(git rev-parse HEAD^{tree})
git cat-file -p $TREE_HASH

# Show subdirectory tree
git ls-tree HEAD:src
Output
100644 blob <hash1> src/lib/helper.go
100644 blob <hash2> src/main.go
040000 tree <treehash1> src
100644 blob <hash2> src/main.go
040000 tree <treehash2> src/lib
100644 blob <hash1> src/lib/helper.go
How Git Tracks Renames:
Git doesn't store rename metadata. It detects renames by comparing tree contents. Two commits with similar trees but different filenames trigger rename detection. That's why 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.

commit-dag.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Create a branch and merge to see DAG
git checkout -b feature
echo "feature work" > feature.txt
git add . && git commit -m "Feature commit"
git checkout main
echo "main work" > main.txt
git add . && git commit -m "Main commit"
git merge feature --no-edit

# View the DAG
git log --graph --oneline --all

# Inspect merge commit
MERGE_HASH=$(git rev-parse HEAD)
git cat-file -p $MERGE_HASH
Output
* a1b2c3d (HEAD -> main) Merge branch 'feature'
|\
| * b2c3d4e (feature) Feature commit
* | c3d4e5f Main commit
|/
o d4e5f6g Initial commit
tree <treehash>
parent c3d4e5f...
parent b2c3d4e...
author ...
committer ...
Merge branch 'feature'
Never Do This:
Don't 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.

dag-visualization.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Show parent relationships
git rev-list --parents HEAD

# Find dangling commits (unreachable)
git fsck --lost-found

# Show all refs and their commit hashes
git show-ref

# List all objects reachable from HEAD
git rev-list --objects HEAD | head -5
Output
a1b2c3d c3d4e5f b2c3d4e
c3d4e5f d4e5f6g
b2c3d4e d4e5f6g
d4e5f6g
dangling commit <hash>
dangling blob <hash>
a1b2c3d refs/heads/main
b2c3d4e refs/heads/feature
a1b2c3d <treehash>
<blobhash> README.md
...
Interview Gold:
Question: 'How does Git handle a merge conflict at the object level?' Answer: Git doesn't store conflicts in objects. During merge, Git reads the three trees (base, ours, theirs) and tries to auto-merge. If conflict, it writes conflict markers into the working tree. The merge commit still has two parents; the conflict is resolved before commit.

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.

packfile-inspection.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Count loose objects
find .git/objects -type f | wc -l

# Run garbage collection
git gc

# Count objects after GC (should be fewer)
find .git/objects -type f | wc -l

# List packfiles
ls -la .git/objects/pack/

# Inspect packfile contents
git verify-pack -v .git/objects/pack/*.idx | head -20
Output
15
Counting objects: 12, done.
Delta compression using up to 8 threads.
...
5
total 24
-r--r--r-- 1 user user 1024 Jan 1 00:00 pack-<hash>.idx
-r--r--r-- 1 user user 4096 Jan 1 00:00 pack-<hash>.pack
<sha1> commit 1 123 456
<sha1> tree 1 456 789
<sha1> blob 2 789 1011 (delta 0)
...
Production Trap:
Running 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>.

reflog-recovery.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Simulate a lost commit
git reset --hard HEAD~1

# Reflog shows the old HEAD
git reflog

# Recover the commit
LOST_HASH=$(git reflog | head -1 | awk '{print $1}')
git branch recovered $LOST_HASH

# Verify
git log --oneline recovered
Output
a1b2c3d HEAD@{0}: reset: moving to HEAD~1
b2c3d4e HEAD@{1}: commit: Feature commit
...
<output of git log>
Senior Shortcut:
Set 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.

corruption-recovery.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
# io.thecodeforge — DevOps tutorial

cd /tmp/git-internals

# Simulate corruption: delete a loose object
CORRUPT_HASH=$(git rev-parse HEAD:README.md)
rm -f .git/objects/${CORRUPT_HASH:0:2}/${CORRUPT_HASH:2}

# Run fsck
git fsck --full 2>&1

# Recover from packfile (if object is in a pack)
git unpack-objects < .git/objects/pack/pack-*.pack 2>/dev/null || echo "Object not in pack, need remote"

# If all else fails, re-clone
echo "Recovery failed. Re-clone from remote."
Output
Checking object directories: 100% (256/256), done.
missing blob <hash>
Object not in pack, need remote
Recovery failed. Re-clone from remote.
Production Trap:
Never run 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.

When to Reach for Internals:
Use internals when: recovering corrupted repo, building Git tooling, optimizing CI clone times, or debugging git gc behavior. Otherwise, trust the porcelain.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A CI/CD container running git clone on a monorepo would OOM-kill after 10 minutes. The repo was 2GB compressed, 8GB unpacked.
Assumption
The team assumed the container needed more memory. They bumped it to 8GB — still died.
Root cause
The repo had accumulated thousands of packfiles due to frequent 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.
Fix
Set 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.
Key lesson
  • Never let a monorepo accumulate packfiles without periodic GC.
  • And always shallow-clone in CI.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
fatal: bad object refs/heads/main
Fix
1. Run git 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.
Symptom · 02
error: packfile .git/objects/pack/pack-*.idx contains unknown object
Fix
1. Delete the corrupt packfile and its index. 2. Run git fsck --full to identify missing objects. 3. Fetch from remote: git fetch origin --refetch (Git 2.34+) or re-clone.
Symptom · 03
Repository size exploded after git gc
Fix
1. Run git 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.
★ Git Internals: Objects, Trees, Blobs and the DAG Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
`fatal: bad object <hash>`
Immediate action
Check if object is loose or packed
Commands
ls .git/objects/$(echo <hash> | cut -c1-2)/$(echo <hash> | cut -c3-)
git verify-pack -v .git/objects/pack/*.idx | grep <hash>
Fix now
If loose and missing, recover from pack: git unpack-objects < .git/objects/pack/*.pack. If packed and corrupt, delete pack and re-fetch.
`error: packfile .idx` mismatch+
Immediate action
Check packfile integrity
Commands
git verify-pack .git/objects/pack/pack-*.idx
git fsck --full
Fix now
Delete corrupt pack and idx, then git fetch origin --refetch or re-clone.
Repository size > expected+
Immediate action
Find large objects
Commands
git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' | sort -k2 -n | tail -20
git gc --aggressive
Fix now
If large blobs are unnecessary, rewrite history with git filter-branch or git filter-repo.
Lost commit after reset+
Immediate action
Check reflog
Commands
git reflog
git branch recover <hash>
Fix now
Create a branch from the reflog entry to restore the commit.
FeatureLoose ObjectPackfile Object
StorageSingle file per objectMultiple objects in one file
Compressionzlib-compressed individuallyDelta-compressed against similar objects
Access speedFast for single objectSlower due to delta chain resolution
Space efficiencyPoor (wastes inodes)Excellent
CreationOn every git add/git commitOn git gc or git repack
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
explore-objects.shmkdir /tmp/git-internals && cd /tmp/git-internalsThe Object Store
blob-manipulation.shcd /tmp/git-internalsBlobs
tree-inspection.shcd /tmp/git-internalsTrees
commit-dag.shcd /tmp/git-internalsCommits
dag-visualization.shcd /tmp/git-internalsThe DAG
packfile-inspection.shcd /tmp/git-internalsPackfiles
reflog-recovery.shcd /tmp/git-internalsRefs and Reflog
corruption-recovery.shcd /tmp/git-internalsRecovering from Corruption

Key takeaways

1
Git is a content-addressable filesystem
objects are stored by SHA-1 hash of their content, enabling deduplication and integrity verification.
2
The DAG (directed acyclic graph) of commits is the backbone of Git history. Branching, merging, and rebasing are just pointer manipulations on this graph.
3
Packfiles with delta compression are what make Git efficient for large repos. But deep delta chains can hurt performance
monitor with git verify-pack.
4
The reflog is your safety net. It keeps a local history of pointer movements for 90 days by default. Use it to recover from accidental resets or rebases.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Git handle a merge conflict internally? Does it create special ...
Q02SENIOR
When would you choose to use `git gc --aggressive` vs `git repack -a -d`...
Q03SENIOR
What happens to the DAG when you force-push after a rebase? How does the...
Q04JUNIOR
What is a blob object in Git?
Q05SENIOR
You run `git fsck` and see 'dangling commit'. What does it mean and how ...
Q06SENIOR
How would you design a system to serve Git objects at scale for a monore...
Q01 of 06SENIOR

How does Git handle a merge conflict internally? Does it create special objects?

ANSWER
No special objects. Git reads three trees (base, ours, theirs) and attempts a three-way merge. If conflict, it writes conflict markers to the working tree. The merge commit still has two parents and a tree that reflects the resolved state. The conflict is resolved before the commit is created.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is a Git blob and how is it different from a file?
02
What's the difference between a tree and a commit in Git?
03
How do I recover a lost commit after a hard reset?
04
What happens to objects when I delete a branch? Do they get deleted immediately?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 07, 2026
last updated
214
articles · all by Naren
🔥

That's Git. Mark it forged?

4 min read · try the examples if you haven't

Previous
Git Submodules: Managing Nested Repositories
26 / 47 · Git
Next
Conventional Commits: Standardized Commit Messages