Code Safari

Chapter 98·Beginner·9 min read

How Git Stores History: Commits, Trees, and Blobs

A plain-English look inside Git — how it really stores your project's history. Snapshots not diffs, the three objects (blobs, trees, commits), how content hashing gives each object its ID, and why this design makes Git fast and tamper-evident. No commands to memorize, just the model.

July 13, 2026

In the last chapter we said a commit is a snapshot of your project. That word was doing a lot of quiet work, and it's worth unpacking, because almost every confusing thing about Git dissolves once you see how it actually stores history underneath. The surprise for most people: Git is simpler on the inside than it looks on the outside. It's built from just three kinds of objects, all named by their contents. Learn those and the rest of Git stops being magic.

Snapshots, not differences

The first and most important idea: Git stores snapshots, not diffs.

Many people assume version control works by saving a list of changes — "line 12 changed, line 40 was deleted" — and stacking those edits on top of each other. That's a reasonable guess, and it's how some older systems worked. Git doesn't. Every time you commit, Git records what the entire project looked like at that moment.

Commit 1: full snapshot
Commit 2: full snapshot
Commit 3: full snapshot
Each commit is a complete snapshot, not a change on top of the last

If that sounds wasteful — surely storing the whole project every commit is huge? — here's the trick: if a file didn't change, Git doesn't store it again. The new snapshot just points at the same stored copy from before. So a commit that edits one file in a thousand-file project reuses 999 unchanged files and stores only the one that differs. You get the simplicity of full snapshots with the storage cost of diffs. When you see a diff — + and - lines in a review — Git computed it on the fly by comparing two snapshots. It wasn't stored that way.

The three objects Git is made of

A snapshot is built from three types of object. That's the entire vocabulary.

  • Blob — the contents of a single file. Just the bytes, nothing else. A blob doesn't know its own filename or where it lives; it's pure content.
  • Tree — a directory. A tree is a list that maps names to blobs (files) and to other trees (subfolders). This is where filenames and folder structure live.
  • Commit — a snapshot with meaning attached. A commit points to exactly one top-level tree, and adds the author, a timestamp, a message, and a link to its parent commit (the one before it).

Put together, they nest like the project itself:

Commit
→ points to a Tree (the root folder)
→ Tree lists Trees & Blobs
→ Blobs hold file contents
How the three objects assemble one snapshot

So reading a commit is like unwrapping a parcel: the commit hands you a top-level tree, that tree lists the folders and files, each subfolder is another tree, and at the very bottom sit the blobs — your actual file contents. The whole state of your project at that moment is reachable from a single commit.

Content addressing: the ID is the content

Here's the idea that ties it together and gives Git its reliability. Every object — blob, tree, or commit — is named by a hash of its own contents (historically SHA-1, a 40-character fingerprint). This is called being content-addressed, and it has three consequences that matter.

  • Identical content, identical ID. Two files with the exact same bytes produce the exact same blob hash, so Git stores them once. The same holds across your whole history — unchanged files are automatically shared.
  • Any change changes the name. Alter a single character in a file and its blob hash changes completely. That new blob means a new tree, which means a new commit hash. A change anywhere ripples up to a brand-new commit ID.
  • History is tamper-evident. Because a commit's ID depends on its tree and its parent, you can't quietly rewrite an old commit. Editing anything in the past changes every ID after it — the tampering is impossible to hide.

Branches and HEAD: just labels

If commits chain backward through their parents, what marks the front of history — the commit you're currently on? Just a small movable label. A branch (like main) is nothing more than a pointer to one commit. Make a new commit and the branch label slides forward to point at it. That's the whole mechanism.

And HEAD is a pointer to which branch you're currently on. When you switch branches, all that changes is where HEAD points; when you commit, the branch HEAD names moves forward. There's no heavy copying involved — moving between branches is just repointing labels at existing snapshots.

HEAD → main
main → Commit C
Commit C → parent Commit B
Commit B → parent Commit A
A branch is a label on a commit; HEAD says which label you're at

This is exactly why the next chapter — branching and merging — turns out to be far less scary than its reputation. Creating a branch doesn't copy your files; it writes a tiny label. Merging isn't a mysterious blend; it's Git reasoning about two chains of snapshots that share a common ancestor.

The whole model, in one breath

Git history is a chain of commits, each pointing to a tree that describes a folder of blobs and subtrees, with every object named by a hash of its contents, and lightweight branch labels marking the tips. Snapshots, not diffs; content as identity; labels for position. That's the engine. Everything Git does — every command you'll learn — is some operation over this one small, elegant structure. Keep this picture in mind and the rest of the guide is just learning the controls.

How Git Stores History: Commits, Trees, and Blobs | Code Safari