Skip to content

Git Objects Explained: Blobs, Trees, Commits and Tags

Lesson 11 of 12Intermediate12 min readGit Fundamentals · Getting StartedVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

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.

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.

TypeStoresReferences
BlobThe raw contents of one fileNothing
TreeA directory listing: mode, type, object ID and name for each entryBlobs and other trees
CommitA root tree, parent commits, author, committer, messageOne tree, zero or more commits
Tag (annotated)A named, described, optionally signed pointerUsually 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.

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:

Terminal window
printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

And hashing the header-plus-content directly gives the same answer:

Terminal window
printf 'blob 6\000hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a

Identical. There is no magic in the ID — it is a plain hash of a precisely defined byte string.

Terminal window
printf 'hello\n' | git hash-object --stdin

What 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.

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.

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/013625030ba8dba906f756967f9e9ca394464a

Decompressing 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.

Terminal window
git cat-file -p HEAD:greeting.txt

What 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 again

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

Terminal window
git cat-file -t ce01362 # type
git cat-file -s ce01362 # size in bytes
blob
6

A tree is a directory listing. Each entry has a mode, a type, an object ID and a name.

Terminal window
git ls-tree HEAD

What 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.txt
040000 tree 755d89e1c086583a7bef11c39dbdd6859858a3f6 src

Two entries: a file and a subdirectory. The subdirectory is another tree object, which you can read the same way:

Terminal window
git cat-file -p HEAD:src
100644 blob 9f1b437537a2acdadafd3174f6f0af9c1a04f5e4 app.py

The modes are a small fixed set:

ModeMeaning
100644Regular file
100755Executable file
120000Symbolic link
040000Directory (another tree)
160000Gitlink — 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:

Terminal window
git ls-tree -r HEAD
100644 blob 13ab7f7412573d479aa8b41ce1e29a9f9f2a62d5 greeting.txt
100644 blob 9f1b437537a2acdadafd3174f6f0af9c1a04f5e4 src/app.py

A commit object is small and entirely text:

Terminal window
git cat-file -p HEAD
tree 06f3e565236176c7633ec5bb2471844d53aafa24
parent c86ff2b9962c64f6e2d5361b57f4f6d7d875d90b
author Ada Lovelace <ada@example.com> 1787405411 +0000
committer Ada Lovelace <ada@example.com> 1787405411 +0000
Update greeting

Line by line:

FieldMeaning
treeThe root tree — the complete snapshot of the project at this commit
parentThe previous commit. Absent on a root commit; repeated on a merge
authorWho wrote the change, with the original timestamp
committerWho created this commit object. Differs from author after rebase, amend, or applying a patch
(blank line)Separates headers from the message
messageYour 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.

The fourth type. An annotated tag is an object with its own ID, message and tagger:

Terminal window
git tag -a v0.1.0 -m "First working greeter"
git cat-file -p v0.1.0
object 4ff2767a422691863b00b07ee6e51de7a65b1919
type commit
tag v0.1.0
tagger Ada Lovelace <ada@example.com> 1787403598 +0000
First working greeter

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

Terminal window
git cat-file -t v0.1.0 # annotated
git cat-file -t v0.1.0-light # lightweight
tag
commit

The lightweight tag resolves straight to the commit, because there is no tag object in between.

Here is a complete two-commit repository as an object graph.

The complete object graph of a two-commit repository

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.

maincommit a745c24”Update greeting”commit c86ff2b”Initial commit”parenttree 06f3e56roottree fd7ce3croottreetreeblob 13ab7f7”hello again”blob ce01362”hello”tree 755d89esrc/blob 9f1b437app.pygreeting.txtgreeting.txtboth commits share the unchanged src/ tree and its blob

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.

You can traverse the entire structure with cat-file alone, and it is worth doing once.

  1. Start at the branch ref.

    Terminal window
    git rev-parse main
  2. Read the commit it names.

    Terminal window
    git cat-file -p main

    Note the tree and parent IDs.

  3. Read the root tree, using the ID from step 2.

    Terminal window
    git cat-file -p 06f3e56
  4. Read a blob listed in that tree.

    Terminal window
    git cat-file -p 13ab7f7

    That is your file’s content, retrieved by walking refs → commit → tree → blob by hand.

  5. 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.

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:

Terminal window
git init
printf 'hello\n' > greeting.txt
  1. Store the content as a blob — this is what git add does first.

    Terminal window
    git hash-object -w greeting.txt
    ce013625030ba8dba906f756967f9e9ca394464a
  2. Put an entry in the index — the second half of git add.

    Terminal window
    git update-index --add greeting.txt
    git ls-files -s
    100644 ce013625030ba8dba906f756967f9e9ca394464a 0 greeting.txt
  3. Turn the flat index into a tree — the first thing git commit does.

    Terminal window
    git write-tree
    57e9529754dc514a3ec10db2ff882018fbe1fcbf
  4. Create the commit object, pointing at that tree.

    Terminal window
    git commit-tree 57e9529 -m "Add greeting"
    835659968291332c639b9bdfaee7acfeb569cdee

    Note that this only creates the object. No branch knows about it yet.

  5. Point the branch at the new commit — the last thing git commit does.

    Terminal window
    git update-ref refs/heads/main 8356599

The result is an ordinary repository that ordinary commands understand:

Terminal window
git log --oneline
git status
8356599 Add greeting
On branch main
nothing to commit, working tree clean

Nothing 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.

New objects are written loose — one zlib-compressed file each:

.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
.git/objects/13/ab7f7412573d479aa8b41ce1e29a9f9f2a62d5

That is simple but inefficient at scale: many small files, and no compression between similar objects. So Git periodically consolidates them into packfiles:

Terminal window
git gc
.git/objects/pack/pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.pack
.git/objects/pack/pack-e6f5dd7a492df926ea5b55be88e9c1f189fcb56e.idx

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

Terminal window
git count-objects -v
count: 0
size: 0
in-pack: 11
packs: 2
size-pack: 3
prune-packable: 0
garbage: 0
size-garbage: 0

count 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.

Terminal window
git fsck --unreachable

lists objects nothing currently references.

CommandPurpose
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 -vReport loose and packed object counts
git fsckVerify the object database’s integrity and connectivity

All of these are read-only except hash-object -w.

“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.

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.

  • 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 gc consolidates them into packfiles that may use delta encoding internally.
  • Unreachable objects survive until their reflog entries expire and gc prunes them.
  • cat-file, hash-object, ls-tree and rev-parse let you read the database directly.

In a disposable repository with two or more commits. Everything here is read-only.

  1. Run git rev-parse HEAD, then git cat-file -p HEAD. Identify the tree and parent IDs.
  2. Run git cat-file -p on the tree ID. Identify one blob.
  3. Run git cat-file -p on that blob ID and confirm it matches the file’s contents.
  4. Run git cat-file -t on all three IDs and confirm the types.
  5. 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?
  6. Run printf 'hello\n' | git hash-object --stdin. Compare it against printf 'blob 6\000hello\n' | sha1sum.
  7. Run git count-objects -v, then git 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.

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.