AnyLearn
All lessons
Programmingintermediate

The Object Database: Git Is a Content-Addressed Store

Git is usually taught as a set of commands. Underneath it is a key-value store where the key is a hash of the content, and four object types built on that one idea. This lesson derives the hash by hand, shows what a commit actually contains, and explains why the design makes history tamper-evident.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

One data structure under all the commands

Git has a reputation for an inconsistent command line, and it is deserved. The data model underneath is small, regular, and worth learning first, because every confusing command becomes obvious once you know what it manipulates.

Git is a content-addressed key-value store. You hand it content, it returns a hash; you hand it the hash, it returns the content. The key is computed from the value, so it is not chosen and cannot be changed independently.

Everything is built from four object types stored that way, and there are no others. Not a diff, not a changeset, not a patch: those are computed on demand for display, and the storage is whole snapshots.

That is the first surprise for people arriving from other version control systems. Git does not store what changed. It stores what everything was, at each commit, and derives the differences when asked.

The rest of the model follows from that choice, including the properties that make branching cheap.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. One data structure under all the commands

Git has a reputation for an inconsistent command line, and it is deserved. The data model underneath is small, regular, and worth learning first, because every confusing command becomes obvious once you know what it manipulates.

Git is a content-addressed key-value store. You hand it content, it returns a hash; you hand it the hash, it returns the content. The key is computed from the value, so it is not chosen and cannot be changed independently.

Everything is built from four object types stored that way, and there are no others. Not a diff, not a changeset, not a patch: those are computed on demand for display, and the storage is whole snapshots.

That is the first surprise for people arriving from other version control systems. Git does not store what changed. It stores what everything was, at each commit, and derives the differences when asked.

The rest of the model follows from that choice, including the properties that make branching cheap.

2. Deriving the hash by hand

The hash is not of the file's bytes. It is of a small header plus the bytes, and computing it manually is the fastest way to make the model concrete.

The format is the object type, a space, the content length in bytes, a null byte, then the content.

$ printf 'hello\n' > a.txt
$ git hash-object a.txt
ce013625030ba8dba906f756967f9e9ca394464a

$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a  -

The same hash, computed with a standard tool and no Git involved. The content is six bytes, hello plus a newline, and the header says so.

Two consequences follow immediately.

Identical content is stored once. The same file in fifty directories, or unchanged across a thousand commits, hashes identically and occupies one object. Snapshot-per-commit storage is affordable because the snapshots share almost everything.

The name is a checksum. Retrieving an object and hashing it again verifies it, so corruption is detectable rather than silent.

3. Four types, and only four

Blob. File contents. Nothing else: no filename, no permissions, no timestamp. A blob is bytes, which is why renaming a file creates no new blob.

Tree. A directory. It lists entries, each with a mode, a type, a hash and a name, and it can point at blobs or at other trees.

100644 blob ce013625030ba8dba906f756967f9e9ca394464a    a.txt

That is a real tree listing. The filename lives here, in the tree, rather than in the blob, which is exactly why identical content under different names shares one object.

Commit. A pointer to one tree, plus zero or more parent commits, an author, a committer, and a message.

Tag. A named pointer to an object with its own message and author, used for annotated tags.

That is the whole vocabulary. A repository is these four types in a store keyed by hash, and every Git command is a way of creating them, reading them, or moving pointers to them.

Notice what is absent: there is no object representing a change. A diff between two commits is computed by comparing their trees when you ask for it.

4. What a commit really contains

Reading a commit object directly settles several common misconceptions at once.

$ git cat-file -p HEAD
tree 2e81171448eb9f2ee3821e3d447aa6b2fe3ddba1
author T <t@e.com> 1785236627 +0200
committer T <t@e.com> 1785236627 +0200

first

That is the entire object. A few lines of text.

A commit points at one tree, which is the complete state of the project at that moment. Not a change: a full snapshot of every file.

Author and committer are separate fields, with separate timestamps. The author wrote the change; the committer applied it. They differ when a patch is applied by a maintainer or when a commit is rebased, which is why rebasing preserves authorship while changing the committer.

The parent is absent here because this is the first commit. Later commits list one parent, merges list two or more, and that field is the only thing that gives history its shape.

A commit with no parents is a root; the parent links are what make the whole thing a graph rather than a pile.

5. How the objects point at each other

Every arrow is a hash, and every hash is computed from the content of the thing it points at. That makes the whole structure a Merkle tree, the same construction used in the lesson Applied Modern Cryptography and in distributed ledgers.

The property that buys is significant. A commit's hash depends on its tree, which depends on every blob and subtree beneath it, and on its parent, which depends on all of history before it.

So changing anything, anywhere in the past, changes that object's hash, which changes its parent's, and so on to every descendant. You cannot alter history without every subsequent commit hash changing.

That is why comparing one commit hash is enough to verify an entire repository state, and why rewriting shared history is visible rather than sneaky: the commits after the rewrite are different objects with different names.

The right-hand branch is the sharing. The parent's tree points at the same blobs for every unchanged file, so a commit touching one file in a large repository creates one new blob, a few new trees along its path, and one new commit.

flowchart LR
A["Commit: tree + parent + author + message"] --> B["Tree: the root directory"]
A --> C["Parent commit"]
B --> D["Blob: a.txt contents"]
B --> E["Tree: src/ directory"]
E --> F["Blob: main.go contents"]
C --> G["Its own tree, sharing unchanged blobs"]

6. Why storing snapshots is affordable

Storing a full snapshot per commit sounds wasteful, and the objection dissolves once the sharing is clear. Two mechanisms handle it.

Structural sharing. Unchanged files are the same blob, referenced again. A thousand-commit history of a project where each commit touches one file stores roughly one project's worth of blobs plus a thousand small deltas' worth of new ones.

Packfiles. Loose objects are individually compressed, and periodically Git repacks them into a packfile that stores similar objects as deltas against each other. This is a storage optimisation applied after the fact, not part of the model: the object graph is still snapshots, and the packfile is how they are written to disk.

The ordering matters conceptually. Systems that store diffs as their model must reconstruct a version by replaying a chain, so checking out an old revision costs more the older it is. Git looks up a tree and reads it, so accessing any commit costs the same.

That is what makes branching and switching cheap, and cheap branching is the reason Git's workflow looks the way it does. It is downstream of the storage decision.

7. The staging area is a real object

The index, or staging area, confuses people because it seems like an arbitrary extra step. Understanding it as a tree under construction removes the confusion.

Git has three states for content, and they are three different places.

Working directory. Ordinary files on disk. Git watches them and stores nothing of them.

Index. A file at .git/index holding the tree that the next commit will use. git add writes a blob into the object database and records its hash in the index.

Object database. The permanent store.

The important detail is that git add already creates the blob. The content is written to the object database at that moment, not at commit time. Committing then builds tree objects from the index and creates a commit pointing at the root tree.

That explains a fact people find surprising: content added and then overwritten in the working directory is still recoverable, because the blob exists. It is unreferenced rather than absent, and git fsck --lost-found will find it until garbage collection runs.

The index exists so that what you commit is chosen explicitly rather than being whatever the disk happened to contain, which is what makes partial staging possible.

8. What the model guarantees

PropertyConsequence
Key is a hash of the contentObjects are immutable; changing content makes a new object
Identical content, identical hashDeduplication is automatic, not an optimisation
Commits chain by parent hashAltering history changes every descendant hash
Commits point at whole treesAny checkout costs the same, regardless of age
Filenames live in treesRenames create no new blob
Nothing stores a diffDiffs are computed for display, on demand

The third row is the one with security weight. A repository verified at one commit hash is verified in its entirety, and signed tags extend that to identity: signing one hash attests to all of history reachable from it.

One honest caveat. The hash function was SHA-1, and a practical collision was demonstrated in 2017. Git added hardened collision detection to its SHA-1 implementation and has an experimental SHA-256 object format, and the transition is ongoing rather than complete. The property is tamper-evident in practice; treating it as cryptographic proof against a determined adversary requires knowing which hash your repository uses.

What the model does not yet explain is how anyone finds a commit. Hashes are not memorable, and nothing so far names anything. That is refs, and it is the next lesson.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What exactly does Git hash to name a blob?
    • The file's bytes alone
    • The type, a space, the content length, a null byte, then the content
    • The filename concatenated with the content
    • The content plus the current timestamp
  2. Where does a filename live in Git's object model?
    • In the blob, alongside the content
    • In the commit object
    • In the index only
    • In the tree entry that points at the blob
  3. Why does altering a past commit change every commit after it?
    • Because each commit's hash depends on its parent's hash, forming a Merkle chain
    • Because Git rewrites timestamps on every commit
    • Because the index is rebuilt from scratch
    • Because packfiles store commits as deltas
  4. Why is storing a full snapshot per commit affordable?
    • Because snapshots are stored as diffs against the previous commit
    • Because unchanged files are the same blob, referenced again
    • Because Git discards old snapshots after garbage collection
    • Because trees are compressed more aggressively than blobs
  5. What does `git add` actually do?
    • Marks the file for reading at commit time
    • Copies the file into a staging directory
    • Writes the blob into the object database and records its hash in the index
    • Computes a diff against the last commit

Related lessons

Programming
advanced

Refs: A Branch Is a File Containing a Hash

Hashes are unusable by hand, so Git names them. A branch is not a copy of anything, not a directory, and not a container for commits: it is a forty-character file. Once that is clear, checkout, reset, detached HEAD and the reflog stop being separate concepts and become one operation on one kind of file.

8 steps·~12 min
Programming
advanced

Undo: Reset, Revert, Restore, and Getting Work Back

Git's undo commands are notoriously confusing because they are usually memorised as recipes. Read against the object model they separate cleanly: each acts on a different one of the three places content lives. This lesson maps them, covers recovery from the accidents that feel unrecoverable, and states what genuinely cannot be undone.

8 steps·~12 min
Programming
advanced

Merge and Rebase: Two Answers to Divergence

Every operation so far moved a pointer. Merging is the first that has to decide something: two branches changed the same project and one result must come out. This lesson covers the three-way merge and the merge base it depends on, what a conflict actually is, and why rebase produces different commits rather than moving them.

8 steps·~12 min
Programming
advanced

The Design Philosophy: What Go Left Out

Go is unusual for what it refuses to include. Errors are values, not exceptions. Interfaces are satisfied without declaring it. Formatting is not a choice. This lesson works through the omissions, the reasoning behind each, and the real costs, because every one of them trades expressiveness for something else.

8 steps·~12 min