Back to Insights
Developer Tools & DX•Git Isn't a Diff Tracker: How Blobs, Trees, DAG Commits, and the Index Actually Work Under the Hood•deep dive•October 12, 2026•18 min read

Demystifying Git Internals: How Blobs, Trees, and the Commit DAG Work Under the Hood

Discover Git's object model. Learn how blobs, trees, and commits form a directed acyclic graph, and how the index enables fast staging and diffing operations.

T
Tamiz UddinFull-Stack Engineer

The "File Tracker" Myth

Most developers view Git as a file versioning system—a digital folder that remembers what your code looked like in the past. While this is the interface you use daily (via git add, git commit, and git checkout), it is fundamentally a misunderstanding of the underlying architecture. Git is not a diff tracker; it is a content-addressable filesystem that happens to model the concept of "files".

At its core, Git does not store differences (deltas) between versions, nor does it primarily operate on individual files. It operates on a massive, global Directed Acyclic Graph (DAG) of immutable content objects. Understanding this shift in mental model—from "files" to "objects"—is essential for optimizing workflows, troubleshooting complex repository states, and writing efficient scripts that interact with the git command-line tool directly.

In this deep dive, we will unpack the four primary object types in the Git object model—Blobs, Trees, Commits, and the Index—and explore how they interact to form the version control system you know and use. We will also examine the internal mechanics that make Git so fast, such as hash-based integrity checks and the clever use of the working directory and index to compute diffs without scanning the entire history.

Table of Contents

The Git Object Model: A Bird's-Eye View

When you run git init, Git creates a .git directory containing the entire infrastructure for your repository. Inside .git/objects, you will find pack and pack subdirectories containing the actual object files. In Git's architecture, every piece of data is an Object. There are four types of objects:

  1. Blobs: The actual content of files.
  2. Trees: The structure of directories, referencing blobs and sub-trees.
  3. Commits: The pointers that link trees to previous commits, forming the DAG.
  4. Tags: Named references pointing to specific objects (usually commits).

The relationship between these objects is hierarchical but immutable. A commit points to a tree, a tree points to blobs or sub-trees, and blobs point to nothing (they are leaves). This structure allows Git to model an entire version of your project as a single, verifiable snapshot.

The DAG: Commits as Nodes

The "Directed Acyclic Graph" (DAG) is formed by the commit objects. Each commit contains a pointer to its parent commit (or multiple parents in the case of merges). This creates a linked list of states. Because the pointers only move forward in time and never loop, it is a DAG. This is why git log is a traversal of this graph, and why git merge is essentially the creation of a new commit that has two parents.

Level 1: The Blob — Storing Content

A Blob is the simplest object. It stores the raw content of a file. Crucially, it does not store the filename, the file permissions, or the modification time. It is just bytes.

Blob Anatomy

You can inspect a blob directly using the git hash-object command or by examining the contents of .git/objects.

bash
# Create a blob from a file
$ echo "Hello, World" | git hash-object -w -stdin
a3ee6c673e6dd086e664f618f8fa9859f4355b45

# Check the type of that object
$ git cat-file -t a3ee6c673e
blob

If you look at the internal format of a blob in the .git/objects directory (uncompressed), it looks like this:

text
blob <size>\0<raw content>
  • blob: The type tag.
  • \0: NUL byte separator.
  • raw content: The exact bytes of the file.

Because a Blob contains no metadata, the hash of a specific file content is deterministic. If you change a single character in a file, the hash changes completely. If you move a file from src/a.js to lib/a.js without changing its content, the Blob hash remains the same. Git detects the move not by renaming the file, but by noticing that the same Blob hash now appears in a new path.

Performance Note: Deduplication

Since Blobs are stored in a global content-addressable store, Git automatically deduplicates data. If you have 1,000 files with the same copyright header, the exact byte sequence of that header is stored only once as a Blob. This is a major contributor to Git's efficiency in repositories with many similar files (like templates or boilerplate).

Level 2: The Tree — Structuring Directories

While Blobs store content, Trees store structure. A Tree object represents a directory. It does not store directory metadata like mtime, but it does store the file modes (e.g., 100644 for executable or 100755 for non-executable) and the names of the children.

A Tree is an ordered list of entries. Each entry contains:

  1. Mode: The file permissions (octal).
  2. Name: The filename or subdirectory name.
  3. Hash: The SHA-1 hash of the child object (either a Blob or another Tree).

Tree Hierarchy

When you commit a project with nested directories, Git creates a Tree for each directory. If you have src/utils/index.js, the commit root Tree will not point directly to the Blob of index.js. Instead:

  1. Root Tree points to src (a sub-Tree).
  2. src Tree points to utils (a sub-Tree).
  3. utils Tree points to index.js (a Blob).
bash
# Visualize the tree structure of the current commit
$ git cat-file -p HEAD
commit 1674788087e549db4625a430b3690f5436a6a91b
tree 1f83d149839dd5d2e4f6f6e3847325996e028635
parent 0d5f3f4285483e4087fca2846573f007c43e5c2d
author John Doe <jdoe@example.com> 1659300000 +0000

# Now look at the root tree it points to
$ git cat-file -p 1f83d149839dd5d2e4f6f6e3847325996e028635
tree 100644 blob a3ee6c673e6dd086e664f618f8fa9859f4355b45\tREADME.md
tree 100644 tree 0b34c9f4d3e035e034a5e5e5e5e5e5e5e5e5e5e5\tsrc

(Note: The hashes above are illustrative; in a real repo, they would be full SHA-1 hex strings).

Why This Matters for Performance

Git can compute the hash of a directory without reading the files inside it. To hash the src directory, it only needs the hashes of its children. This makes git ls-tree and git diff extremely fast because they operate on metadata, not disk I/O-heavy file contents, unless the specific path is requested.

Level 3: The Commit — Snapshotting the State

The Commit object is the node in the DAG. It is a small text file (when uncompressed) containing:

  1. Tree Hash: A pointer to the root Tree of this version.
  2. Parent Hashes: Pointers to one or more previous commits.
  3. Author/Committer Info: Name, email, and timestamp.
  4. Message: The commit message.

The "Snapshot" Mental Model

Many beginners think of commits as "changes" (deltas). Internally, a commit is a snapshot of the entire tree. When you run git commit, Git:

  1. Takes the current state of the Index (the staging area).
  2. Writes all changed files as Blobs.
  3. Updates the directory Trees to point to these new Blobs.
  4. Creates a new root Tree pointing to the updated sub-Trees.
  5. Creates a new Commit object pointing to this new root Tree and the previous commit.

Because each commit stores a full reference to the tree, you can always check out any commit in history without relying on previous commits to

exist. This property is known as content-addressable storage and it makes Git’s integrity verification robust: if any byte in a file changes, its hash changes, which invalidates the tree pointing to it, which in turn invalidates the commit pointing to that tree.

How to Inspect These Objects Manually

Before moving to more complex scenarios like merging or rebasing, let’s see how to inspect these low-level structures directly. Git provides the git cat-file command to explore the object database.

Suppose you have a simple repository with a single commit. Let’s create one for reference:

bash
echo "Hello, Git!" > hello.txt
git add hello.txt
git commit -m "Initial commit"

Now, let’s inspect the objects. First, get the SHA-1 of the commit:

bash
COMMIT_SHA=$(git rev-parse HEAD)
echo $COMMIT_SHA
# Example: a1b2c3d4e5f6...

Next, look at the commit object itself:

bash
git cat-file commit $COMMIT_SHA

Output:

sql
tree 789c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f67890
author John Doe <john@example.com> 1700000000 +0000
committer John Doe <john@example.com> 1700000000 +0000

Initial commit

Notice the tree line. That’s a reference to the root tree. Let’s inspect that tree:

bash
git cat-file tree 789c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f67890

Output:

sql
100644 blob 8f14e45f4b198b8f4a19e3b4a1c2d3e4f5a6b7c9    hello.txt

Finally, inspect the blob:

bash
git cat-file blob 8f14e45f4b198b8f4a19e3b4a1c2d3e4f5a6b7c9

Output:

code
Hello, Git!

This chain demonstrates the DAG structure: Commit → Tree → Blob.

Merging: Three-Way Merge Internals

When you run git merge, Git doesn’t just compare two trees. It performs a three-way merge. Why three? Because you need a base to compare against.

Consider this scenario:

  • A is the common ancestor.
  • B and C are two divergent branches.

Git compares:

  1. The tree of A
  2. The tree of B
  3. The tree of C

For each file, Git checks:

  • If the file is identical in B and C, no change.
  • If the file in B differs from A but C matches A, take B’s version.
  • If the file in C differs from A but B matches A, take C’s version.
  • If both B and C differ from A and differ from each other, it’s a conflict.

This is not a text-based diff algorithm. It’s a tree-based comparison at the object level. Git can merge entire directories atomically. If a directory exists in B but not C, and had no conflicts in its sub-files, it can be moved as a unit.

Runnable Example: Simulating a Merge Conflict

Let’s create a scenario that triggers a conflict:

bash
# Create a new repo
mkdir merge-demo && cd merge-demo
git init
echo "line1" > file.txt
git add file.txt
git commit -m "Initial"

# Create a branch
git checkout -b feature
echo "line1-modified" > file.txt
git add file.txt
git commit -m "Feature change"

# Go back to main
git checkout main
echo "line1-different" > file.txt
git add file.txt
git commit -m "Main change"

# Try to merge
git merge feature
# This will fail with a conflict

When Git fails to merge, it leaves markers in file.txt:

markdown
<<<<<<< HEAD
line1-different
=======
line1-modified
>>>>>>> feature

These markers are not stored in Git’s object database. They are generated by the merge algorithm during the working tree update phase. The underlying object database still holds the clean, unmodified versions of the files.

The Reflog: Undoing Mistakes

A common misconception is that Git’s history is linear. In reality, it’s a directed acyclic graph (DAG). Branches, tags, and the HEAD reference can all point to different parts of this graph.

The reflog (reference log) records every update to the HEAD pointer. It’s not part of the main history but is essential for recovering lost commits.

bash
git reflog

Output example:

less
a1b2c3d (HEAD -> main) HEAD@{0}: commit: Main change
b2c3d4e HEAD@{1}: commit: Initial
c3d4e5f HEAD@{2}: checkout: moving from feature to main
d4e5f6a (feature) HEAD@{3}: commit: Feature change
...

If you accidentally delete a branch, you can recover it using the reflog:

bash
git branch -D feature
# Oh no, I deleted the branch. But the commit d4e5f6a still exists in the reflog.
git branch feature d4e5f6a

Performance Considerations

Git’s strength comes from its content-addressable storage, but this has implications for performance.

Packing Objects

When you make many commits, Git stores each object individually in .git/objects/. Over time, this can fragment disk space and slow down lookups. git gc (garbage collection) packs these objects into packfiles.

A packfile is a compressed stream of objects that includes:

  • Delta compression: If two files are similar, the packfile stores one full file and the difference (delta) for the other.
  • Index: A binary index file (.idx) allows binary searching for objects within the pack.

You can inspect packfiles:

bash
git count-objects -v
# count: 0
# in pack: 100
# pack uses: 1
# size: 0
# size in packs: 512

git verify-pack -v .git/objects/pack/*.idx

Shallow Clones

For large repositories, Git allows shallow clones that download only recent history:

bash
git clone --depth=100 <url>

This limits the number of commits fetched, reducing download size. However, it complicates certain operations like git rebase on older commits because the base commits are missing.

Conclusion: Why This Matters

Understanding Git’s internals transforms how you approach version control:

  1. Atomicity: Commits are atomic units. If a commit is broken, the entire tree is invalidated.
  2. Immutability: Objects are content-addressable. You can’t modify a commit; you create a new one. This makes Git safe for distributed workflows.
  3. Efficiency: Delta compression and packing make Git fast even for large projects.
  4. Recovery: The reflog and object database allow you to recover from most mistakes.

When you next run git commit, remember: you’re not just saving a snapshot. You’re writing a cryptographic hash that binds together a tree, a parent commit, an author, and a timestamp. If any part of that chain is corrupted, Git will detect it.

This design makes Git not just a version control tool, but a distributed, append-only database of snapshots. And that’s why it powers projects from small scripts to the Linux kernel.