Git Objects Explained: Blobs, Trees, Commits and Tags
Underneath branches, commits and staging, Git is a content-addressable object database: a key-value store where the key is the hash of the value. It holds exactly four kinds of object, and every feature in Git is built from them.
This lesson opens that database with Git’s plumbing commands — the low-level tools the everyday commands are built on. The goal is not to make you use them daily. It is to make the storage model concrete, so that branches, merges and history stop being abstractions.
Porcelain and plumbing
Section titled “Porcelain and plumbing”Git’s own documentation splits its commands in two.
Porcelain commands are the user-facing interface: add, commit, status, log, merge. They
have friendly output and are designed to change between versions as the interface improves.
Plumbing commands are the low-level primitives: cat-file, hash-object, ls-tree, rev-parse.
Their output is stable and machine-readable, which makes them ideal for scripts — and, here, for
teaching, because they show you the data rather than a presentation of it.
The four object types
Section titled “The four object types”| Type | Stores | References |
|---|---|---|
| Blob | The raw contents of one file | Nothing |
| Tree | A directory listing: mode, type, object ID and name for each entry | Blobs and other trees |
| Commit | A root tree, parent commits, author, committer, message | One tree, zero or more commits |
| Tag (annotated) | A named, described, optionally signed pointer | Usually a commit |
That is the entire storage vocabulary. Branches are not objects — they are references, plain files containing an object ID. Lesson 12 covers where those live.
How object IDs are computed
Section titled “How object IDs are computed”An object’s ID is the SHA-1 hash of a short header plus its content:
<type> <byte-length>\0<content>You can verify this by hand. Git says the blob for the six bytes hello\n is:
printf 'hello\n' | git hash-object --stdince013625030ba8dba906f756967f9e9ca394464aAnd hashing the header-plus-content directly gives the same answer:
printf 'blob 6\000hello\n' | sha1sumce013625030ba8dba906f756967f9e9ca394464aIdentical. There is no magic in the ID — it is a plain hash of a precisely defined byte string.
printf 'hello\n' | git hash-object --stdinWhat it doesComputes and prints the object ID Git would assign to the given content, without writing anything into the repository.
Why we run itIt demonstrates that IDs derive from content alone. The --stdin form lets you hash arbitrary bytes without creating a file.
Expected resultA 40-character hexadecimal ID. Identical input always produces an identical ID, in any repository, on any machine.
Why content addressing matters
Section titled “Why content addressing matters”Deduplication is automatic. Identical content hashes identically, so it is stored once — regardless of filename, directory, branch, or how many commits contain it. A file unchanged across a thousand commits occupies one blob.
Objects are immutable. Changing content changes the hash, so you get a new object rather than a modified one. Nothing in the database is ever edited in place.
History is tamper-evident. A commit’s ID covers its tree and its parent ID. Alter an old commit and its ID changes; every descendant referenced the old ID, so every descendant’s ID changes too. You cannot quietly rewrite the middle of a history.
Integrity is checkable. Git can re-hash any object and compare. git fsck does this across the
whole database.
How an object is stored on disk
Section titled “How an object is stored on disk”A loose object is written to .git/objects/, split into a two-character directory and a 38-character
filename, and zlib-compressed:
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464aDecompressing that file gives back exactly the bytes that were hashed:
b'blob 6\x00hello\n'The two-character split exists because some filesystems perform badly with hundreds of thousands of entries in one directory.
A blob stores file content. It does not store the filename, the path, the permissions, or any timestamp — only bytes.
git cat-file -p HEAD:greeting.txtWhat it doesPretty-prints the contents of any object, formatting it according to its type.
Why we run itIt is the general-purpose object reader. For a blob it prints the file content; for a tree or commit it prints a structured listing.
Expected resultFor a blob, the file's exact contents.
hello againThe HEAD:greeting.txt syntax means “the object at path greeting.txt in the commit HEAD points at” —
a convenient way to reach a blob without knowing its ID.
Two more useful flags:
git cat-file -t ce01362 # typegit cat-file -s ce01362 # size in bytesblob6A tree is a directory listing. Each entry has a mode, a type, an object ID and a name.
git ls-tree HEADWhat it doesLists the entries of a tree object — the direct contents of one directory.
Why we run itIt shows the structure Git builds from the flat index at commit time, including which entries are files and which are subdirectories.
Expected resultOne line per entry: mode, type, object ID, then the name.
100644 blob 13ab7f7412573d479aa8b41ce1e29a9f9f2a62d5 greeting.txt040000 tree 755d89e1c086583a7bef11c39dbdd6859858a3f6 srcTwo entries: a file and a subdirectory. The subdirectory is another tree object, which you can read the same way:
git cat-file -p HEAD:src100644 blob 9f1b437537a2acdadafd3174f6f0af9c1a04f5e4 app.pyThe modes are a small fixed set:
| Mode | Meaning |
|---|---|
100644 | Regular file |
100755 | Executable file |
120000 | Symbolic link |
040000 | Directory (another tree) |
160000 | Gitlink — a submodule commit reference |
Git records only whether a file is executable; it does not preserve full Unix permissions.
To see every file in a commit, flattened:
git ls-tree -r HEAD100644 blob 13ab7f7412573d479aa8b41ce1e29a9f9f2a62d5 greeting.txt100644 blob 9f1b437537a2acdadafd3174f6f0af9c1a04f5e4 src/app.pyCommits
Section titled “Commits”A commit object is small and entirely text:
git cat-file -p HEADtree 06f3e565236176c7633ec5bb2471844d53aafa24parent c86ff2b9962c64f6e2d5361b57f4f6d7d875d90bauthor Ada Lovelace <ada@example.com> 1787405411 +0000committer Ada Lovelace <ada@example.com> 1787405411 +0000
Update greetingLine by line:
| Field | Meaning |
|---|---|
tree | The root tree — the complete snapshot of the project at this commit |
parent | The previous commit. Absent on a root commit; repeated on a merge |
author | Who wrote the change, with the original timestamp |
committer | Who created this commit object. Differs from author after rebase, amend, or applying a patch |
| (blank line) | Separates headers from the message |
| message | Your commit message, verbatim |
Everything that makes a commit a commit is in those few lines. And because the ID is the hash of all of it, changing the message, the author, the timestamp or the parent produces a different commit.
Annotated tags
Section titled “Annotated tags”The fourth type. An annotated tag is an object with its own ID, message and tagger:
git tag -a v0.1.0 -m "First working greeter"git cat-file -p v0.1.0object 4ff2767a422691863b00b07ee6e51de7a65b1919type committag v0.1.0tagger Ada Lovelace <ada@example.com> 1787403598 +0000
First working greeterA lightweight tag is different: it is just a ref file containing a commit ID, with no object of its own. The difference is visible immediately:
git cat-file -t v0.1.0 # annotatedgit cat-file -t v0.1.0-light # lightweighttagcommitThe lightweight tag resolves straight to the commit, because there is no tag object in between.
How the objects fit together
Section titled “How the objects fit together”Here is a complete two-commit repository as an object graph.
An object graph read right to left. On the far right, the branch ref main points at the second commit. That commit has a parent arrow to the first commit, and a tree arrow to a root tree. The root tree has two entries: a blob for greeting.txt, and a subtree named src. The src tree has one entry, a blob for app.py. The first commit points to its own root tree, which points to an older blob for greeting.txt and to the same src subtree — showing that unchanged content is shared between commits rather than duplicated.
The important detail is the shared src tree. Only greeting.txt changed between the two commits, so
only its blob and the root tree above it are new. The src tree and app.py blob are referenced by
both commits — one copy, two references.
This is what “Git stores snapshots, not diffs” means concretely. Every commit references a complete tree, but unchanged subtrees are shared, so the storage cost is proportional to what changed.
Walking the graph yourself
Section titled “Walking the graph yourself”You can traverse the entire structure with cat-file alone, and it is worth doing once.
-
Start at the branch ref.
Terminal window git rev-parse main -
Read the commit it names.
Terminal window git cat-file -p mainNote the
treeandparentIDs. -
Read the root tree, using the ID from step 2.
Terminal window git cat-file -p 06f3e56 -
Read a blob listed in that tree.
Terminal window git cat-file -p 13ab7f7That is your file’s content, retrieved by walking refs → commit → tree → blob by hand.
-
Step back in history by reading the parent commit.
Terminal window git cat-file -p c86ff2b
Every Git command that reads history performs this same walk. git log follows parent links.
git checkout walks commit → tree → blobs and writes them out. git diff walks two commits’ trees and
compares blob IDs.
Building a commit by hand
Section titled “Building a commit by hand”The clearest way to see that porcelain is a wrapper is to do its job with plumbing. This sequence
creates a real commit without ever running git add or git commit.
Start in an empty repository with one file:
git initprintf 'hello\n' > greeting.txt-
Store the content as a blob — this is what
git adddoes first.Terminal window git hash-object -w greeting.txtce013625030ba8dba906f756967f9e9ca394464a -
Put an entry in the index — the second half of
git add.Terminal window git update-index --add greeting.txtgit ls-files -s100644 ce013625030ba8dba906f756967f9e9ca394464a 0 greeting.txt -
Turn the flat index into a tree — the first thing
git commitdoes.Terminal window git write-tree57e9529754dc514a3ec10db2ff882018fbe1fcbf -
Create the commit object, pointing at that tree.
Terminal window git commit-tree 57e9529 -m "Add greeting"835659968291332c639b9bdfaee7acfeb569cdeeNote that this only creates the object. No branch knows about it yet.
-
Point the branch at the new commit — the last thing
git commitdoes.Terminal window git update-ref refs/heads/main 8356599
The result is an ordinary repository that ordinary commands understand:
git log --onelinegit status8356599 Add greetingOn branch mainnothing to commit, working tree cleanNothing was special-cased. git add is hash-object -w plus update-index; git commit is
write-tree plus commit-tree plus update-ref. Porcelain adds the ergonomics — reading your editor
for a message, handling multiple paths, writing the reflog — but the objects it produces are exactly
these.
Loose objects and packfiles
Section titled “Loose objects and packfiles”New objects are written loose — one zlib-compressed file each:
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a.git/objects/13/ab7f7412573d479aa8b41ce1e29a9f9f2a62d5That is simple but inefficient at scale: many small files, and no compression between similar objects. So Git periodically consolidates them into packfiles:
git gc.git/objects/pack/pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.pack.git/objects/pack/pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.idxA packfile stores many objects in one file, and — importantly — may store some as deltas against
other similar objects rather than in full. The .idx file is an index letting Git find any object in
the pack without scanning it.
Check the balance in any repository:
git count-objects -vcount: 0size: 0in-pack: 11packs: 2size-pack: 3prune-packable: 0garbage: 0size-garbage: 0count is loose objects, in-pack is packed ones. Git runs gc automatically when loose objects
accumulate, so you rarely need to invoke it.
Unreachable objects and garbage collection
Section titled “Unreachable objects and garbage collection”An object is reachable if you can get to it by starting from a ref and following references. Objects that nothing reaches — a commit from a deleted branch, an amended-away commit — remain in the database until garbage collection removes them.
The reflog counts as a starting point, which is why a commit you “lost” is usually still reachable and
recoverable. Only after the reflog entry expires and gc runs does the object actually go.
git fsck --unreachablelists objects nothing currently references.
Plumbing command reference
Section titled “Plumbing command reference”| Command | Purpose |
|---|---|
git cat-file -t <id> | Print an object’s type |
git cat-file -s <id> | Print an object’s size in bytes |
git cat-file -p <id> | Pretty-print an object’s content |
git hash-object <file> | Compute the ID content would get (-w also writes it) |
git ls-tree <tree> | List one tree’s entries |
git ls-tree -r <tree> | List all entries recursively |
git rev-parse <rev> | Resolve any revision expression to a full object ID |
git count-objects -v | Report loose and packed object counts |
git fsck | Verify the object database’s integrity and connectivity |
All of these are read-only except hash-object -w.
Common misconceptions
Section titled “Common misconceptions”“Git stores diffs between versions.” Commits reference complete trees. Packfiles may encode some objects as deltas, but that is a storage detail below the model.
“A branch is an object.” Branches are refs — files containing an object ID. Only blobs, trees, commits and annotated tags are objects.
“Blobs store filenames.” Blobs store bytes. Names live in trees, which is why the same content under two names is one blob.
“The object ID is random or sequential.” It is a hash of the object’s own bytes, fully deterministic.
“Amending edits a commit.” It creates a new one. The original remains until garbage collection.
“git gc deletes my history.” It repacks objects and prunes unreachable ones whose reflog entries
have expired. Anything reachable from a ref is never removed.
Mental Model
Section titled “Mental Model”Git is a key-value store where the key is the hash of the value.
Blobs are file contents. Trees are directory listings that name blobs and other trees. Commits point at one tree — a whole snapshot — plus their parents. Refs are sticky notes with object IDs on them.
Nothing is ever edited. New content means new objects; “changing” history means creating new objects and moving the sticky notes.
What You Learned
Section titled “What You Learned”- Git stores four object types: blobs, trees, commits and annotated tags.
- An object’s ID is
SHA-1("<type> <length>\0<content>")— verifiable by hand. - Content addressing gives automatic deduplication, immutability, tamper-evidence and integrity checks.
- Blobs hold content only; names, modes and structure live in trees.
- A commit references one root tree plus its parents; unchanged subtrees are shared between commits.
- Annotated tags are objects; lightweight tags are just refs.
- Loose objects are individual compressed files;
git gcconsolidates them into packfiles that may use delta encoding internally. - Unreachable objects survive until their reflog entries expire and
gcprunes them. cat-file,hash-object,ls-treeandrev-parselet you read the database directly.
Try It Yourself
Section titled “Try It Yourself”In a disposable repository with two or more commits. Everything here is read-only.
- Run
git rev-parse HEAD, thengit cat-file -p HEAD. Identify the tree and parent IDs. - Run
git cat-file -pon the tree ID. Identify one blob. - Run
git cat-file -pon that blob ID and confirm it matches the file’s contents. - Run
git cat-file -ton all three IDs and confirm the types. - Create a copy of an existing file under a new name, stage it, and run
git ls-files -s. Predict first: will the copy have the same object ID as the original? - Run
printf 'hello\n' | git hash-object --stdin. Compare it againstprintf 'blob 6\000hello\n' | sha1sum. - Run
git count-objects -v, thengit gc, then run it again. Watch objects move from loose to packed.
Step 5 is the one that lands: the copy has the same ID, because the content is identical. Git stores one blob, and two tree entries name it.
Next Lesson
Section titled “Next Lesson”You have seen the objects. The final lesson of this cluster shows where they live — a guided tour of a
real .git directory, connecting every concept from this cluster to a file on disk.