How Version Control Stores History: Snapshots, Hashes and Branches
See how Git stores history with content-addressed objects, snapshots, commit graphs and branch pointers, and why this design keeps changes cheap and safe.
Every developer uses version control, but many treat it as a black box that remembers past states of files. Looking inside one common system, Git, shows a clear and elegant design. Git does not store a list of diffs for each file. It stores snapshots of the whole project, identifies every piece of content by its hash, and records history as a graph of commits. Once you understand these few ideas, commands such as branching, merging and reverting stop looking like magic and start looking like simple operations on a small set of objects.
The object store
Git keeps its data in a hidden directory named .git. The heart of that directory is the object store, a key-value database where each value is identified by a hash of its contents. There are four kinds of object. A blob holds the bytes of one file, without its name. A tree holds a list of names and the hashes of blobs or other trees, so it represents a directory. A commit holds a pointer to one root tree, pointers to its parent commits, the author, the committer, the timestamps and the message. A tag points to another object and carries a name and optional signature.
Because the key is the hash of the content, identical content is stored once. If two files in different commits have the same bytes, they share the same blob. This content addressing gives deduplication for free and makes corruption easy to detect, since a changed byte produces a different hash.
Git's default hash has been SHA-1 for most of its history, and newer repositories can opt into SHA-256. The choice affects the length of the identifiers you see, such as the forty-character hexadecimal strings in commit logs.
Snapshots, not diffs
Many people assume Git stores differences between versions. Conceptually it stores snapshots. Each commit points to a complete tree describing the whole project at that moment. Unchanged files are not copied, because their blob hashes remain the same, so the tree simply reuses the existing object.
This design makes some operations fast. To check out an old version, Git reads the commit, follows its root tree, and writes out the blobs it references. It does not need to replay a long chain of patches. To compare two commits, Git can walk both trees and skip any subtree whose hash is identical, which quickly ignores large unchanged directories.
The commit graph
Commits link to their parents, which forms a directed acyclic graph. A normal commit has one parent. A merge commit has two or more, which records that two lines of history were joined. The first commit in a repository has no parent. Because each commit's identifier depends on its contents and its parents, altering any past commit changes its hash and every hash that follows it. This is why published history is hard to rewrite silently.
Git also stores a log of reference updates, so an accidental reset can often be recovered by looking up the previous value of a branch. The object itself usually remains in the store until a cleanup process removes unreachable objects.
Branches are just pointers
A branch in Git is a small file that holds a commit hash. The name main simply points to one commit, and that pointer moves forward when you add a new commit. Creating a branch means creating another pointer to the same commit, which is why branching is cheap. There is no copy of the project.
A special reference called HEAD points to the branch you are currently on. When you check out a different branch, Git updates HEAD and rewrites the working directory to match the snapshot of the new commit. Detached HEAD is the state where HEAD points directly to a commit instead of a branch, so new commits are not attached to any name until you create one.
The index and the working tree
Git has three areas that are easy to confuse. The working tree is the files you see and edit. The index, also called the staging area, is a list of blob hashes that will form the next commit. The object store holds everything that has been committed or staged.
When you stage a file, Git hashes its contents, stores the blob, and records the hash in the index. When you commit, Git turns the index into a tree, creates a commit object that points to it, and moves the current branch pointer. This three-stage design lets you build a commit from selected changes rather than everything in the working tree.
Merging and conflicts
A merge combines the histories of two branches. Git finds a common ancestor, compares the trees of the two branch tips against that ancestor, and applies non-overlapping changes automatically. When both branches changed the same region of the same file in incompatible ways, Git marks a conflict and asks a person to choose.
Because each commit is a full snapshot, the merge base is a precise reference point. Git uses algorithms such as three-way merge over the trees rather than trying to reconstruct the entire edit history of each file.
Packfiles and storage efficiency
Storing every version of every file as a separate loose object would waste space, especially for large text files that change a little each time. Git solves this with packfiles. When repositories are packed, Git groups related objects and stores some as deltas, meaning a description of how to rebuild one object from another. The delta approach is applied as an optimization for storage and transfer. The logical model stays the same: objects are still identified by hash and still represent full snapshots.
A small comparison
The table below compares the two common ways to model history. The numbers describe design properties rather than measured benchmarks.
| Property | Snapshot model (Git) | Patch model (older systems) |
|---|---|---|
| Unit of storage | Full tree per commit | Changes between versions |
| Branch creation | Writes one pointer | Often copies or tracks a base |
| Checkout of old version | Read one tree | Replay patches in sequence |
| Integrity check | Hash of each object | Depends on the format |
| Deduplication | Automatic by content hash | Depends on the tool |
Practical consequences
Knowing the model changes how you work. Commit small, focused changes, because each commit is a snapshot you may later want to inspect or revert. Name branches for the work they hold, since branches are cheap and long-lived names help others find work. Be careful with history rewriting on shared branches, because a rewritten commit has a new hash and everyone who based work on the old one must reconcile the difference.
Use git log --graph to see the commit graph, and git cat-file -p on a hash to see the raw contents of an object. Reading a commit and its tree by hand is one of the fastest ways to understand what the tool actually does.
Key takeaways
Git stores four kinds of objects, identified by the hash of their contents. Each commit points to a full snapshot of the project and to its parents, forming a graph that is hard to alter without changing every later hash. Branches and tags are small pointers, which makes them cheap. The index lets you choose exactly what enters the next commit. Packfiles keep storage efficient without changing the snapshot model. Understanding these few ideas makes the everyday commands predictable and makes recovery from mistakes far less stressful.