Git Internals: Understanding Objects, Trees, and Blobs
Most developers interact with Git through a handful of porcelain commands—git add, git commit, git push, git pull—and never need to peer beneath the surface. But when you understand what Git is actually doing under the hood, everything from merge conflicts to rebase operations suddenly makes sense. This tutorial takes you deep into Git's object model, where blobs, trees, and commits form the backbone of every repository you've ever created.
The Git Object Model: A Content-Addressable Filesystem
At its core, Git is not a version control system in the traditional sense—it is a content-addressable filesystem with version control conventions layered on top. Every piece of content Git stores is identified by a SHA-1 hash that is derived directly from the content itself. This means identical content always produces the same hash, giving Git built-in deduplication and integrity verification for free.
The entire Git object database lives inside the .git/objects directory. When you commit, merge, or stash, Git writes new objects into this database. These objects are immutable—once written, they are never modified. Instead, Git creates new objects to represent new states, and references (branches, tags, HEAD) are simply pointers that move to point at different objects over time.
The Four Fundamental Object Types
Git's object database consists of exactly four types of objects. Each serves a distinct purpose in building the graph of your repository's history:
- Blob — Stores the raw content of a single file. No metadata, no file name, just the bytes.
- Tree — Represents a directory. It contains entries that map file names (or subdirectory names) to blob hashes and tree hashes, along with mode information (executable, symlink, etc.).
- Commit — Points to a single tree that represents the root directory of your project at a moment in time. It also contains parent commit hashes, an author, a committer, and a message.
- Tag — A named reference that typically points to a commit, with optional annotation (message, signature, tagger information). Used primarily for marking releases.
Together, these four object types form a directed acyclic graph that encodes your entire project history. Let's explore each one in detail.
Blobs: The Raw Content Storage
A blob (binary large object) is Git's unit of file content storage. Crucially, a blob does not store the file name, permissions, or any metadata—it stores only the raw bytes of the file. This design choice is brilliant: if you have the same file content in ten different directories across your project, Git stores only a single blob, and ten separate tree entries point to that same blob hash.
Under the hood, Git computes the blob's hash by prepending a header to the content before hashing. The header format is:
<object_type> <byte_length>\0<raw_content>
For a blob, the object type is literally the string "blob". Let's see this in action by manually creating a blob with plumbing commands:
# Create a fresh repository to experiment in
mkdir git-internals-lab
cd git-internals-lab
git init
# Create a simple file
echo "Hello, Git Internals!" > hello.txt
# Inspect the raw byte count (22 bytes: 21 chars + newline)
wc -c hello.txt
# Output: 22 hello.txt
# Manually compute the SHA-1 hash that Git would produce
# The header is: "blob 22\0" followed by the file content
printf "blob 22\0Hello, Git Internals!\n" | sha1sum
# Example output: a5d8e5c8e5f9c0b3d4a7f1e2c3b4d5e6f7a8b9c0d -
# Now let Git do the same thing with its plumbing command
# The -w flag writes the object to .git/objects
# The --stdin flag reads content from standard input
blob_hash=$(git hash-object -w --stdin < hello.txt)
echo "Blob hash from Git: $blob_hash"
# Verify the hash matches our manual computation
# (They will match if you used the correct byte count and content)
Notice that we computed the hash without Git and got the same result. This is the essence of content-addressable storage—the hash is entirely determined by the content, making it impossible to tamper with objects undetected.
Now let's inspect the blob we just created:
# Determine the object type
git cat-file -t $blob_hash
# Output: blob
# View the stored content
git cat-file -p $blob_hash
# Output: Hello, Git Internals!
# See the raw object with header (includes the "blob 22\0" prefix)
git cat-file $blob_hash | hexdump -C | head
# This shows the raw compressed object before Git decompresses it
The git cat-file command is your window into Git's object database. The -t flag shows the type, -p prints the content (automatically decompressing and stripping the header), and using no flag dumps the raw bytes.
Trees: How Git Represents Directories
While blobs store file content, tree objects store directory structure. A tree is essentially a sorted list of entries, where each entry contains:
- A mode (e.g.,
100644for a regular file,100755for executable,040000for a subdirectory,120000for a symlink) - A type indicator (
blobortree) - A 40-character SHA-1 hash pointing to the blob or subtree
- A file name (or directory name for subtrees)
Trees are what allow Git to reconstruct the exact directory structure of your project at any commit. A tree for the root directory points to blobs for files and to other trees for subdirectories, forming a recursive structure that mirrors your filesystem.
Let's build a tree manually using Git's plumbing to see exactly how this works:
# First, let's create a more realistic project structure
mkdir src
echo 'console.log("Hello from JS");' > src/app.js
echo "# My Project" > README.md
# Store both files as blobs
blob_readme=$(git hash-object -w README.md)
blob_app=$(git hash-object -w src/app.js)
echo "README blob: $blob_readme"
echo "app.js blob: $blob_app"
# Now we need to build the tree for the 'src' subdirectory
# We use git update-index to stage entries into the index (staging area)
# Then git write-tree reads the index and creates a tree object
# Clear the index first (it currently has nothing staged)
rm -f .git/index
# Stage src/app.js into the index at path "src/app.js"
# The syntax: git update-index --add --cacheinfo <mode> <hash> <path>
git update-index --add --cacheinfo 100644 $blob_app src/app.js
# Write a tree from the current index state
# This creates a tree representing the current index
tree_src=$(git write-tree)
echo "src/ tree hash: $tree_src"
# View the contents of this tree
git ls-tree $tree_src
# Output will show: 100644 blob <hash> app.js
# The tree stores the filename, mode, and blob reference
# Now let's build the root tree that contains both README.md and the src/ subtree
# We need to reset the index and stage everything properly
rm -f .git/index
# Stage README.md as a regular file
git update-index --add --cacheinfo 100644 $blob_readme README.md
# Stage the src/ subtree
# For subdirectories, we use the special mode 040000
git update-index --add --cacheinfo 040000 $tree_src src
# Write the root tree
tree_root=$(git write-tree)
echo "Root tree hash: $tree_root"
# Inspect the root tree
git ls-tree $tree_root
# Output:
# 100644 blob <hash> README.md
# 040000 tree <hash> src
The git ls-tree command is the primary tool for examining tree objects. Without flags, it shows a simple listing. Add -r to recursively list all blobs in subtrees, or -l to include the blob sizes.
# Recursively list everything in the root tree
git ls-tree -r $tree_root
# Shows every blob reachable from this tree, with full paths
# Show with blob sizes
git ls-tree -r -l $tree_root
An important insight: the tree object stores file names and modes, while the blob stores only content. This separation means that renaming a file doesn't create a new blob—it only creates a new tree with the updated name pointing to the same blob. Similarly, changing permissions (chmod +x) only changes the tree entry, not the blob.
Commits: Snapshots That Point to Trees
A commit object ties everything together. It contains:
- A tree hash — the root directory snapshot for this commit
- Parent commit hashes — zero parents for the initial commit, one for normal commits, two or more for merge commits
- Author information — name, email, and timestamp
- Committer information — same format, captures who actually created the commit (may differ from author in rebase/cherry-pick scenarios)
- A commit message — the actual text you typed
Let's create a commit object from our root tree using the plumbing:
# Create a commit object pointing to our root tree
# git commit-tree creates a commit object from a tree hash
# It reads the commit message from stdin
commit_hash=$(echo "Initial commit with README and src/" | git commit-tree $tree_root)
echo "Commit hash: $commit_hash"
# Inspect the commit object
git cat-file -p $commit_hash
# Output will look like:
# tree 4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c
# author Your Name <you@example.com> 1699123456 +0000
# committer Your Name <you@example.com> 1699123456 +0000
#
# Initial commit with README and src/
# Verify the object type
git cat-file -t $commit_hash
# Output: commit
# Now let's update HEAD to point to this commit
# Without this, the commit exists but is unreachable
git update-ref HEAD $commit_hash
# Verify HEAD now points to our commit
git log --oneline
# Should show our commit
Let's create a second commit to see how parent relationships form:
# Modify a file and create a new blob
echo 'console.log("Updated JS file");' > src/app.js
new_blob=$(git hash-object -w src/app.js)
# Clear index and stage the new structure
rm -f .git/index
git update-index --add --cacheinfo 100644 $new_blob src/app.js
git update-index --add --cacheinfo 100644 $blob_readme README.md
# Reuse the src tree? No—we changed app.js, so src/ needs a new tree
# But wait: the src/ tree only contains app.js. Since app.js changed,
# we need a new tree for src/. Let's build it:
# First, stage just src/app.js to create a new src tree
rm -f .git/index
git update-index --add --cacheinfo 100644 $new_blob src/app.js
new_tree_src=$(git write-tree)
# Now build the new root tree
rm -f .git/index
git update-index --add --cacheinfo 100644 $blob_readme README.md
git update-index --add --cacheinfo 040000 $new_tree_src src
new_tree_root=$(git write-tree)
# Create a second commit with the first commit as parent
# The -p flag specifies parent commit(s)
commit_hash_2=$(echo "Update app.js" | git commit-tree $new_tree_root -p $commit_hash)
echo "Second commit: $commit_hash_2"
# Update HEAD to point to the new commit
git update-ref HEAD $commit_hash_2
# Inspect the second commit
git cat-file -p $commit_hash_2
# Shows parent: <first_commit_hash>
Notice the elegance: each commit points to a complete tree snapshot of the entire project. Git doesn't store diffs between commits—it stores full snapshots. The magic of Git's efficiency comes from the fact that unchanged files reuse the same blobs, and Git later compresses objects into packfiles for even greater storage efficiency.
Tag Objects: Named References with Metadata
Tags come in two flavors: lightweight and annotated. A lightweight tag is simply a reference file in .git/refs/tags/ that points directly to a commit hash. An annotated tag creates an actual tag object in the database, which stores:
- The tagged object hash (usually a commit)
- Tagger information
- A message
- Optionally a GPG signature
# Create an annotated tag using plumbing
tag_hash=$(git mktag < tag_message.txt)
# Or simply use: git tag -a v1.0 -m "Release v1.0" $commit_hash_2
# Inspect the tag object
git cat-file -p v1.0
# Shows: object <commit_hash>, type commit, tag v1.0, tagger info, message
Annotated tags are recommended for releases because they carry provenance information that lightweight tags lack.
Hands-On Exploration: The Full Object Graph with Porcelain Commands
Now let's step back and use the familiar porcelain commands, then inspect the resulting object graph. This is how you'd normally work, but with X-ray vision turned on:
# Create a new repository
mkdir porcelain-lab
cd porcelain-lab
git init
# Create files and commit normally
echo "Version 1" > file.txt
mkdir subdir
echo "Subdir content" > subdir/nested.txt
git add file.txt subdir/nested.txt
git commit -m "First commit"
# Now let's dissect what just happened
# Find the commit hash that HEAD points to
head_commit=$(git rev-parse HEAD)
echo "HEAD commit: $head_commit"
# Inspect the commit object
echo "=== Commit Object ==="
git cat-file -p HEAD
# Extract the tree hash from the commit
head_tree=$(git rev-parse HEAD^{tree})
echo "Root tree: $head_tree"
# List the root tree contents
echo "=== Root Tree ==="
git ls-tree $head_tree
# Recursively list all blobs in the tree
echo "=== All Blobs Reachable from HEAD ==="
git ls-tree -r HEAD
# For each blob, show its content
echo "=== Blob Contents ==="
for blob in $(git ls-tree -r HEAD | awk '{print $3}'); do
echo "Blob: $blob"
echo "Content: $(git cat-file -p $blob)"
echo "---"
done
This recursive listing shows you every single blob that makes up the current state of your project. Each blob is exactly one file version. If you have 500 files, your commit references (through trees) exactly 500 blobs—minus any deduplication for identical files.
How Objects Are Stored on Disk
Navigate to .git/objects and you'll see a directory structure that mirrors the SHA-1 hashes:
# Explore the object store
ls .git/objects/
# Output: info/ pack/ XX/ YY/ ZZ/ ...
# Each object lives at .git/objects/<first_two_chars>/<remaining_38_chars>
# For example, if a blob hash is a5d8e5c8e5f9c0b3d4a7f1e2c3b4d5e6f7a8b9c0d
# It lives at: .git/objects/a5/d8e5c8e5f9c0b3d4a7f1e2c3b4d5e6f7a8b9c0d
# Find the actual file for a known hash
object_path=".git/objects/${head_tree:0:2}/${head_tree:2}"
ls -la "$object_path"
# This file is the compressed (zlib-deflated) object
# Manually decompress an object to see its raw header and content
python3 -c "
import zlib, sys
with open('$object_path', 'rb') as f:
raw = zlib.decompress(f.read())
print('Raw bytes:', raw)
print('Decoded:', raw.decode('utf-8', errors='replace'))
"
# The raw bytes start with: "tree <size>\0" followed by the tree entries
Git uses zlib compression for all objects. The object is compressed after the header is prepended, then written to disk. When Git reads an object, it decompresses and verifies that the SHA-1 of the decompressed data matches the filename—giving cryptographic integrity verification on every read.
Loose Objects vs. Packfiles
Initially, Git stores every object as a separate file—these are called loose objects. Over time, Git periodically runs git gc (garbage collection) which packs multiple objects into a single packfile with delta compression:
# Check the count of loose objects
find .git/objects -type f ! -path '*/pack/*' ! -path '*/info/*' | wc -l
# Run garbage collection to pack objects
git gc
# Now examine the pack directory
ls .git/objects/pack/
# Contains: .pack file (compressed objects) and .idx file (index for fast lookup)
# After gc, loose objects are removed if they were packed
find .git/objects -type f ! -path '*/pack/*' ! -path '*/info/*' | wc -l
# Much smaller number—only objects that haven't been packed yet
# You can still inspect packed objects with the same cat-file commands
git cat-file -p HEAD
# Git transparently looks up objects in packfiles via the .idx index
Inside a packfile, Git stores objects as delta chains against other objects. For example, a new version of a file might be stored as a delta against its previous version, saving enormous space. The packfile format is one of Git's secret weapons for efficiency—repositories with thousands of commits can be remarkably small because only the changes between similar blobs are stored, not the full content of every version.
Why Understanding Git Internals Matters
You might wonder: "I've used Git for years without knowing any of this—why should I care?" Here are concrete reasons:
- Merge conflicts make sense: When you understand that a merge is comparing tree entries and blobs across three commits (base, ours, theirs), conflict markers become less mysterious. You're seeing Git's inability to automatically resolve differing blob pointers for the same file path.
- Rebase is demystified: Rebase replays commits (which point to trees) onto a new base. Each replayed commit gets a new hash because its parent pointer changes, even if the tree it points to is identical. Understanding this explains why commit hashes change during rebase.
- Large file recovery: If you accidentally delete a commit, the objects may still exist in the database.
git fsck --lost-foundcan find dangling blobs and trees, letting you recover work that seems hopelessly lost. - Scripting and automation: Plumbing commands like
git cat-file,git ls-tree, andgit write-treeare stable interfaces designed for scripting. They let you build custom workflows, migration tools, or repository analysis scripts. - Performance optimization: Knowing that Git deduplicates blobs helps you structure monorepos efficiently. Understanding packfile delta chains explains why certain repository operations are slow and when to run
git gc. - Forensic debugging:
git bisectandgit blametraverse the commit graph. Understanding the object model lets you trace exactly which blob changed at which commit, even for complex histories.
Best Practices When Working with Git Internals
- Use plumbing commands for scripts, porcelain for humans: The plumbing commands (
git cat-file,git hash-object,git write-tree,git commit-tree,git update-ref,git ls-tree,git ls-files) have stable output formats designed for programmatic consumption. Use them in automation scripts instead of parsing human-readable porcelain output. - Never manually edit objects in
.git/objects: The object database is content-addressable with integrity checks. Manually altering object files corrupts the repository and breaks SHA-1 verification. Always use Git commands to modify objects. - Run
git fsckperiodically: This command verifies the integrity of the object database, checking that all referenced objects exist and that SHA-1 hashes match. It also finds dangling objects that might be recoverable. - Use
git gcwisely: Garbage collection packs loose objects and removes unreachable ones (after a grace period). For active repositories, Git auto-runs gc periodically. For repositories with heavy commit churn (like CI build repos), consider tuninggc.autoandgc.reflogExpiresettings. - Protect against dangling object loss: Objects that are not reachable from any reference (branch, tag, reflog) may be pruned. If you're experimenting with
git reset --hardorgit rebase, know that the old commits are kept alive by the reflog for at least 90 days by default. Usegit reflogto recover seemingly lost commits. - Understand the index (staging area): The file
.git/indexis a binary representation of the tree that will be written at the next commit.git ls-files --stageshows its contents. The index is whatgit addupdates and whatgit write-treereads. Understanding this bridge between blobs and