← Back to DevBytes

Git Internals: Objects, Trees, Blobs

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:

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:

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:

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:

# 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:

Best Practices When Working with Git Internals

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles