Understanding the Git Object Model: Blobs, Trees, Commits
AI generated
git
HEAD
Git · Object Model · Internals · Developer Basics
Understanding the Git Object Model: Blobs, Trees, Commits
What actually lives under .git/objects

Branches, commits, diffs and merges look like independent concepts, but technically they are all just pointers into the same object graph. This article deliberately drops below the add, commit and branch abstraction and uses git cat-file and git hash-object to show directly against the object store how blobs, trees and commits are built, how they reference each other, and why Git addresses its objects by SHA-1 or SHA-256 instead of by filename.

14 min read Blobs · Trees · Commits Git 2.x · CLI · SHA-1/SHA-256

1. Why the object model is the foundation of everything in Git

Git is usually understood as a tool for managing branches and commits, but underneath that surface almost everything is a graph built from four simple object types: blobs, trees, commits and tags. A branch is nothing more than a movable pointer to a commit hash. A commit is a pointer to exactly one tree. A merge commit differs from a regular commit only in that it references two parent hashes instead of one. Even git diff and git blame are ultimately just traversals and comparisons over the very same object store.

Once this model clicks, it becomes obvious why certain Git operations are as cheap as they are: switching branches only costs bending a pointer, a commit does not create new blobs for unchanged files, and a clone ultimately just transfers a set of objects addressed by their hashes. This article therefore deliberately drops below the command-line abstraction of add, commit and branch, and uses git cat-file and git hash-object to show directly how the four object types are actually built.

2. Blobs: how Git stores the content of a file

A blob is the simplest of the four object types and stores exclusively the raw byte content of a file, compressed with zlib. No filename, no path, no permissions and no timestamp are part of a blob. Two files with identical content in completely different locations of the repository therefore produce exactly the same blob hash and are stored internally as a single object, regardless of how many times they appear in the project. This content addressing is the core idea from which almost all of Git's efficiency advantages follow.

The hash of a blob is not computed from the file content alone, but from a short header of the form blob <size-in-bytes>\0 followed by the actual content. This construction guarantees that different object types with coincidentally identical byte content still end up with different hashes. If a file is renamed or moved to another directory, its blob hash stays unchanged, because filename and path are stored exclusively in the parent tree object, never inside the blob itself.


# Create a blob object manually and inspect it directly
$ printf "Tailwind CSS v4 + Alpine.js, no jQuery.\n" > note.txt
$ git hash-object -w note.txt
fb5dea5c2a8ff1580c941793d6bc47cfe4158e70

# Ask Git only for the object type
$ git cat-file -t fb5dea5c2a8ff1580c941793d6bc47cfe4158e70
blob

# Pretty-print the decompressed content
$ git cat-file -p fb5dea5c2a8ff1580c941793d6bc47cfe4158e70
Tailwind CSS v4 + Alpine.js, no jQuery.

# Size of the uncompressed content in bytes
$ git cat-file -s fb5dea5c2a8ff1580c941793d6bc47cfe4158e70
40

3. Trees: directory structure as its own object type

A tree object represents exactly one directory level and consists of a sorted list of entries. Each entry has four parts: the file mode (for example 100644 for a regular file or 040000 for a subdirectory), the object type (blob or tree), the hash of the referenced object, and the name within that directory. Unlike a blob, a tree does carry names, but only relative to its own level, never as a full path from the project root.

Nested directories are represented through nested tree objects: a subdirectory is referenced in the parent tree as its own entry of type tree, whose hash in turn points to a separate tree object holding the entries of exactly that subdirectory. The root tree of a commit therefore points, through a chain of such tree objects, to the entire directory structure of the project. Content addressing applies here too: two identical subdirectories in different locations of the project produce the same tree hash and share the same object in storage.


# Build a tree object manually from a staged blob entry
$ HASH=$(git hash-object -w note.txt)
$ git update-index --add --cacheinfo 100644 $HASH note.txt
$ git write-tree
9d7f97f9c4862742cfcbba6b406b251f7818a83b

# Inspect the resulting tree, -l also shows the blob size
$ git ls-tree -l 9d7f97f9c4862742cfcbba6b406b251f7818a83b
100644 blob fb5dea5c2a8ff1580c941793d6bc47cfe4158e70      40    note.txt

4. Commits: snapshot pointers to a tree and parent objects

A commit object references exactly one root tree that describes the complete state of the project at that moment, plus zero, one, or multiple parent commits. An initial commit has no parent, a regular commit has exactly one, a merge commit has two or more. On top of that come metadata fields: author with name, email and timestamp, committer with the same fields, which can differ from the author during a rebase or cherry-pick, and finally the commit message itself as free text at the end of the object.

This structure explains why a commit is a full snapshot and not a diff: the referenced tree fully describes every file in the project at that point in time, not just the change relative to the previous commit. A diff between two commits is therefore not stored information at all, it is computed on the fly whenever needed by comparing the two referenced trees. This also explains why git log -p is just as fast for ancient commits as for brand new ones: there is no chain of deltas that needs to be applied sequentially, every snapshot is directly addressable.


# Build a commit object manually, referencing a tree and no parent
$ git commit-tree 9d7f97f9c4862742cfcbba6b406b251f7818a83b -m "Initial commit: add note.txt"
40198ede659b31953652f31e67947aa29f3940e2

# A second commit with the same tree, now with an explicit parent
$ git commit-tree 9d7f97f9c4862742cfcbba6b406b251f7818a83b \
    -p 40198ede659b31953652f31e67947aa29f3940e2 \
    -m "Second commit: same tree content, new parent link"
1bd19922bf345863a07e1d9d80561db2b85f6e66

# Inspect the commit object: tree, parent, author, committer, message
$ git cat-file -p 1bd19922bf345863a07e1d9d80561db2b85f6e66
tree 9d7f97f9c4862742cfcbba6b406b251f7818a83b
parent 40198ede659b31953652f31e67947aa29f3940e2
author Mira Novak <mira@mironsoft.de> 1752300000 +0200
committer Mira Novak <mira@mironsoft.de> 1752300000 +0200

Second commit: same tree content, new parent link

5. Content-addressable storage: SHA-1 versus SHA-256

Every Git object is identified by the hash of its own content, which is precisely the definition of "content-addressable storage". Historically Git uses SHA-1 with a 40-character hexadecimal representation (160 bits). Since Git 2.29, Git also supports SHA-256 with a 64-character hexadecimal representation (256 bits) as an alternative object format, which must be chosen explicitly at repository creation time via git init --object-format=sha256. A repository always uses exactly one of the two formats in full; mixing object formats inside a single repository is not supported.

The move toward SHA-256 is mainly a response to theoretical, and by now partly practically demonstrated, collision attacks against SHA-1, such as the SHAttered attack published in 2017, even though Git already hardens against the known attack variants with an additional collision detection mechanism called SHA-1DC. Which format an existing repository uses is recorded in .git/config under extensions.objectFormat; if this entry is absent entirely, SHA-1 is the implicit default. Important in practice: SHA-256 and SHA-1 repositories are currently not directly interoperable, switching an existing project requires explicit conversion tooling.


# SHA-1 repository (Git default)
$ git init sha1-repo && cd sha1-repo
$ printf "Tailwind CSS v4 + Alpine.js, no jQuery.\n" > note.txt
$ git hash-object note.txt
fb5dea5c2a8ff1580c941793d6bc47cfe4158e70
$ cd ..

# SHA-256 repository (explicit object format)
$ git init --object-format=sha256 sha256-repo && cd sha256-repo
$ printf "Tailwind CSS v4 + Alpine.js, no jQuery.\n" > note.txt
$ git hash-object note.txt
8e6f45c08caf69d04db237ee305bbc655ed3a5d78c57835341179cc9128f3234
# same content, but the digest is 64 hex chars instead of 40

# Bulk-list every object currently in an object store
$ git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype) %(objectsize)'
8e6f45c08caf69d04db237ee305bbc655ed3a5d78c57835341179cc9128f3234 blob 40

6. Inspecting objects directly with git cat-file

git cat-file is the central tool for looking directly into the object store, without going through the working directory or the index at all. The three most important flags each answer a different question: -t returns only the object type (blob, tree, commit or tag), -p pretty-prints the decompressed content, formatted appropriately for its type, and -s returns the size of the uncompressed content in bytes. All three flags accept both full 40- or 64-character hashes and unambiguous abbreviations, usually from seven characters onward.

For bulk operations across the entire object store, git cat-file --batch-all-objects combined with --batch-check is the tool of choice: it lists every object in the repository with its hash, type and size in a single pass, without requiring you to already know which hashes even exist. This is enormously useful when debugging a broken repository, when hunting for unusually large blobs that were accidentally committed, or simply to get a sense of how many objects a repository actually contains, independent of how many branches or tags point at them.

7. Creating objects manually with git hash-object

git hash-object is, in a sense, the counterpart to cat-file: it computes the object hash of a given content using exactly the same procedure Git uses internally, but by default without actually writing the object. Only the -w flag actually writes the resulting blob object, compressed, into .git/objects. Without -w, the command only returns the hash the content would have, which is ideal for checking upfront whether a particular version of a file already exists somewhere in the repository as a blob, without creating a new object in the process.

With --stdin, git hash-object reads content not from a file but directly from standard input, which combines well with other Unix tools, for example to compute the hash of generated or filtered content without having to create an intermediate file. This exact mechanism also sits behind git add internally: when staging a file, Git calls the same hashing and writing procedure that hash-object -w performs by hand, and afterward only updates the matching index entry to point at the new hash. git add is, at its core, nothing more than hash-object -w plus an update to the staging area.

8. Loose objects: one compressed file per object

Every newly created object initially lands as a so-called loose object under .git/objects/, named after its own hash. The first two characters of the hash form the directory name, and the remaining 38 (for SHA-1) or 62 (for SHA-256) characters form the filename inside it, for example .git/objects/8e/6f45c0...f234. This two-character sharding strategy prevents a single directory from holding tens of thousands of files in larger repositories, which would noticeably slow down many filesystems compared to many smaller directories.

Each of these files is individually compressed with zlib deflate and contains exactly one object along with its type header. That is simple and robust, but inefficient in the long run: thousands of small changes produce thousands of individual small files, without Git exploiting any redundancy between similar but not identical blobs. That is why git gc eventually packs loose objects together into packfiles, where similar objects reference each other through delta compression. This packfile mechanism is its own, considerably more complex topic and deliberately not part of this article; what matters here is only that packfiles do not change anything about the underlying object structure of blobs, trees and commits, they only change how these objects are physically stored on disk.


# .git/config of a repository created with --object-format=sha256
[core]
	repositoryformatversion = 1
	filemode = true
	bare = false
	logallrefupdates = true

[extensions]
	objectFormat = sha256

# repositoryformatversion = 1 signals that "extensions" entries
# must be understood by the reading Git version, or it must refuse
# to operate on this repository at all

9. Object types compared side by side

All concepts discussed so far, blobs, trees, commits and, implicitly, tags, follow the same pattern: content goes in, a SHA hash comes out, stored immutably under .git/objects. Even so, a number of misconceptions about what each object type actually stores persist stubbornly. The following table puts the most common misconceptions side by side with Git's actual behavior, along with the command that lets you verify each claim yourself.

Object type Common misconception Actual behavior Command to verify it
Blob Also stores the filename and path Raw content only, the name lives in the tree git cat-file -p <hash>
Tree Is recursively the whole directory tree Represents one level, references sub-trees git ls-tree -l <hash>
Commit Stores a diff against the previous commit References a complete tree snapshot git cat-file -p <hash>
Hash format SHA-1 and SHA-256 repos are simply compatible A repository always uses exactly one format grep objectFormat .git/config
Loose object Stays a single loose file forever git gc eventually packs loose objects into packfiles git count-objects -v

Once these five rows have sunk in, Git stops looking like a collection of isolated commands and starts looking like a single, consistent object graph. Every other command, from git rebase to git bisect, can be traced back to the same question: which blobs, trees and commits are being read here, and which new objects does it create along the way?

Mironsoft

Git workflows, repository maintenance and CI/CD pipelines for PHP and Magento teams

Want to understand and use repository internals properly?

We help development teams keep Git repositories healthy, from object store maintenance and packfile optimization to branching strategies built on a solid technical understanding of Git.

Git training

Hands-on workshops on Git internals, the object model and advanced workflows

Repository audit

Reviewing existing repositories for size, history and object store hygiene

CI/CD integration

Setting up automated pipelines and hooks around Git for Magento projects

10. Summary

The Git object model of blobs, trees and commits is not trivia for internals enthusiasts, it is the foundation every higher-level Git command is built on. A blob stores only the raw, compressed content of a file, with no name or path. A tree represents exactly one directory level and references blobs and further trees through their hashes. A commit is a complete snapshot, not a diff: it points to exactly one root tree plus zero, one, or multiple parent commits, and carries author, committer and message metadata.

Every one of these objects is identified by the SHA hash of its own content, historically SHA-1 with 40 hex characters, and since Git 2.29 optionally SHA-256 with 64 hex characters. git cat-file -t/-p/-s makes these objects directly visible, git hash-object -w creates them manually using the same procedure that git add also uses internally. Physically, every object initially lives as a single, zlib-compressed file under .git/objects, before git gc eventually moves it into a more space-efficient packfile.

The Git object model at a glance

Blob

Raw, compressed file content. No name, no path. Created by git add or git hash-object -w.

Tree

One directory level: mode, type, hash, name per entry. Nested through further tree objects.

Commit

Complete snapshot: one tree hash, parent hash(es), author/committer, message. No stored diff.

Hashing & storage

SHA-1 (40 characters) or SHA-256 (64 characters). Loose files under .git/objects, packed later.

11. FAQ: Git object model, blobs, trees, commits

1What is the difference between a blob and a file in the working directory?
A file has a name, a path and lives on disk. A blob only stores the compressed content, addressed by hash. Name and path live exclusively in the tree object.
2Does a tree object store full paths or just names?
Only names relative to its own level. Nested directories arise from nested tree objects referencing each other by hash.
3Why is a commit a snapshot and not a diff?
A commit references a complete root tree. Diffs are only computed on demand by comparing two trees, they are not stored information.
4What is the difference between SHA-1 and SHA-256 in Git?
SHA-1: 40 hex characters, historical default. SHA-256: 64 hex characters, selectable since Git 2.29 via --object-format=sha256. A repository always uses only one format.
5How do I find out which hash format my repository uses?
extensions.objectFormat in .git/config shows sha256 if set. If the entry is missing, the repository uses SHA-1 as the default.
6What exactly does git cat-file -p do?
Decompresses an object and shows it formatted for its type: blob as raw content, tree as a list of entries, commit with tree, parent, author and message.
7What is git hash-object without the -w flag good for?
Computes only the hash a content would have as a blob, without writing it. Useful for checking whether a version already exists as an object.
8What does git add have to do with git hash-object?
git add uses the same hashing and writing procedure as hash-object -w internally, then only updates the matching index entry to point at the new hash.
9Where exactly do Git objects live on disk?
As loose files under .git/objects/xx/yyyy..., where the first two hex characters form the directory name. Each file is individually zlib-compressed.
10What is the difference between a loose object and a packfile?
Loose object: one file per object. Packfile: many objects bundled with delta compression between similar objects, much more space efficient. git gc performs the conversion automatically.