The four git object types: blobs, trees, commits, and tags
Everything in a git repository boils down to four object types stored in a content-addressable key-value store. Here's how blobs, trees, commits, and tags fit together.
At its core, git is a content-addressable filesystem with a version-control UI bolted on top. Strip away the porcelain commands and you’re left with a simple key-value store: you hand git some bytes, it hands you back a 40-character SHA-1 (or SHA-256) hash, and that hash is the only thing you ever need to get those bytes back.
Every hash points at an object, and there are exactly four kinds.
1. Blobs
A blob stores the raw contents of a single file — and nothing else. No filename, no permissions, no timestamps. Just bytes.
$ echo "hello mgit" | git hash-object --stdin
9f8c8a... # the blob's id
Because the id is derived from the content, two identical files anywhere in your history share one blob. Rename a file a hundred times and the blob never changes — only the things that point at it do.
2. Trees
A tree is git’s notion of a directory. It’s an ordered list of entries, where each entry records:
- a mode (file, executable, symlink, or subdirectory),
- a name (the filename or directory name), and
- the id of the blob or tree it points to.
100644 blob 9f8c8a... README.md
040000 tree a1b2c3... src
So a tree references blobs (files) and other trees (subdirectories), forming a Merkle tree of your entire working directory. Change one byte in one file and every tree on the path up to the root gets a new id — that cascading is exactly what makes git tamper-evident.
3. Commits
A commit is a snapshot plus metadata. It points at one root tree (the state of the whole project at that moment) and carries:
- the author and committer (name, email, timestamp),
- the commit message, and
- zero or more parent commit ids.
tree a1b2c3...
parent 7d8e9f...
author Ada <ada@example.com> 1718870400 +0000
committer Ada <ada@example.com> 1718870400 +0000
Add packfile reader
Zero parents means a root commit. One parent is a normal commit. Two or more parents is a merge. Follow the parent links and you’ve walked the entire history — no diffs required, because each commit already has a complete snapshot via its tree.
4. Tags (annotated)
An annotated tag is a named, signed pointer to another object — almost always a commit. Unlike a lightweight tag (which is just a ref), an annotated tag is a real object with its own id, a tagger, a message, and an optional GPG signature.
object 7d8e9f...
type commit
tag v1.0.0
tagger Ada <ada@example.com> 1718870400 +0000
mgit 1.0
How they’re stored
Each object is serialized as a short header — "<type> <size>\0" — followed by the raw content, then zlib-compressed and written to .git/objects/ under a path derived from its hash. These are loose objects: one file per object.
.git/objects/9f/8c8a... # loose blob, zlib-compressed
That’s wonderfully simple, but it doesn’t scale: a repository with millions of objects becomes millions of tiny files. The fix is to roll many objects into a single compressed packfile — which is exactly what we’ll dig into next.
For a system like mgit that puts repositories on object storage, this model is a gift: every object is immutable and addressed by its own hash, so it maps cleanly onto a bucket where keys are content hashes and values never change.