Free lesson · Git Mastery
Git's Data Model
Git's Data Model
A note from Nikhil Joshi, Senior Software Engineer at Google.
What Git Actually Stores
Module 21 covers Git internals. Up to this point, the course has covered Git from the perspective of the commands you run and the workflows they support. This module shifts perspective - it covers what Git stores, how it's structured, and why the commands behave the way they do. The internals aren't necessary for daily use, but they're foundational for understanding the harder cases.
This topic introduces the data model. The next seven topics cover the components in detail - the object database (21.2), refs (21.3), the index (21.4), packfiles (21.5), plumbing commands (21.6), the wire protocol (21.7), and garbage collection (21.8). Together, they give you the mental model that distinguishes engineers who can solve unusual Git problems from those who can only follow recipes.
I work on Google Cloud Spanner - distributed systems infrastructure. The data model that makes Git work has parallels to distributed storage systems: content-addressable storage, immutable objects, references that compose into structures. The mental model that helps with one helps with the other. This topic frames Git's data model in those terms.
The Four Object Types
Git stores data in four types of objects:
Blob. A file's content. Just the bytes - no filename, no permissions, no metadata. Two files with identical content are stored as a single blob.
Tree. A directory listing. Maps filenames to objects (blobs for files, other trees for subdirectories), with mode bits for each entry.
Commit. A snapshot. Points at a single tree (representing the entire repository state), plus metadata: parent commit(s), author, committer, message.
Tag. A named pointer to another object, with its own metadata. Annotated tags only - lightweight tags aren't objects, they're just refs (Topic 21.3).
The four types compose to represent a project's full history. Blobs hold content. Trees organize blobs into hierarchies. Commits snapshot trees at points in time. Tags name specific commits. Every operation in Git ultimately reads or writes these four object types.
The simplicity is deliberate. A small number of primitive types, each with a clear role, composing into the complex structures users see. This is the same principle that makes distributed systems tractable - small primitives with strong invariants.
Content Addressing
Each object is named by the SHA-1 hash of its content (newer Git versions also support SHA-256). The name is derived from the content; identical content always produces the same name.
# Hash a file's content without storing it
git hash-object README.md
# Output: e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
# The same content produces the same hash
echo "hello" | git hash-object --stdin
# Output: ce013625030ba8dba906f756967f9e9ca394464a
echo "hello" | git hash-object --stdin
# Output: ce013625030ba8dba906f756967f9e9ca394464a (same)
Content addressing means the storage system can deduplicate automatically. Two files with identical content occupy one blob. Two commits with identical trees share that tree. The space efficiency comes from this property; the integrity guarantees come from it too - any change to content changes the hash, which propagates up to commits and beyond.
The hash also serves as the object's identifier. When Git needs to refer to an object, it uses the hash. When it needs to retrieve an object, it looks it up by hash. The hash is the address; the content is what the address points to.
The DAG of Commits
Commits form a directed acyclic graph (DAG). Each commit points to its parent(s); the graph captures history.
flowchart RL
A[Commit A<br/>Initial commit<br/>no parents]
B[Commit B<br/>parent: A]
C[Commit C<br/>parent: B]
D[Commit D<br/>parent: B<br/>branch]
E[Commit E<br/>parent: C, D<br/>merge]
F[Commit F<br/>parent: E]
B --> A
C --> B
D --> B
E --> C
E --> D
F --> E
style A fill:#dcfce7
style E fill:#fef3c7
style F fill:#dbeafe
Commits typically have one parent - they represent linear history. Merge commits have multiple parents - they bring branches together. The initial commit has no parents. The directed acyclic property means: arrows go in one direction, history flows forward, no cycles possible.
Branches are pointers into this DAG. A branch name like main points at a specific commit; following parents from that commit gives the history reachable from main. Tags are similar.
Objects Inside Other Objects
The objects compose:
- A blob holds file content
- A tree references blobs by name
- A commit references a tree
- A tag can reference any object
# Look inside a commit
git cat-file -p HEAD
# tree abc123...
# parent def456...
# author Nikhil <nikhil@example.com> 1234567890 +0530
# committer Nikhil <nikhil@example.com> 1234567890 +0530
#
# Initial structure for the project
# Look inside the tree the commit points to
git cat-file -p abc123
# 100644 blob def456... README.md
# 100644 blob ghi789... setup.py
# 040000 tree jkl012... src/
# Look inside the blob
git cat-file -p def456
# (file content)
The recursive structure builds up: commit → tree → blob and subtrees → blob, and so on. Following the references from a commit reconstructs the full directory state at that point. The structure is what allows Git to represent any version of any project efficiently.
The composition is what makes content addressing work for whole projects, not just individual files. When the tree's contents change, the tree's hash changes. When the tree's hash changes, the commit's hash changes (because commits reference trees by hash). The chain of hashes propagates any change all the way to the commit level.
Why This Design
The data model has specific properties that make Git powerful:
Immutability. Objects, once created, never change. The hash names the content; changing the content creates a new object. This makes operations safe - you can't accidentally corrupt an object that other references depend on.
Deduplication. Identical content is stored once. File renames don't duplicate content. Reverting to an old version doesn't restore from backup; the old object never went away.
Integrity. The hash chain detects any corruption. If a single byte in a blob changes, its hash changes; the tree that references the blob has the wrong hash; the commit that references the tree has the wrong hash. Tampering becomes detectable.
Distributed-friendly. Objects can be transferred between repositories without coordination - the hash identifies whether a repository has an object. Push and fetch exchange only the objects the other side doesn't have.
History as data. The DAG captures branching, merging, and divergence. Operations like log, diff, and merge work on the data model directly.
These properties together make Git distinctly different from earlier version control systems. Subversion stored deltas against a central server; Perforce stored ordered transactions in a database; CVS stored RCS files per file. Git stores immutable, content-addressed objects with explicit graph structure. The differences enable Git's distributed nature.
A Specific Pattern at Google Spanner
In my work on distributed databases, the design patterns Git uses appear in different forms:
- Spanner uses content-addressed storage for some artifacts
- Immutability and versioning are common patterns
- Hash chains enable integrity verification
- Reference-by-hash enables decoupling
The patterns aren't identical, but the underlying ideas are recognizable. Engineers familiar with one set of systems pick up the other quickly because the mental models transfer.
This is part of why Git internals are worth learning even if you only use Git through high-level commands. The patterns appear in other distributed systems; understanding them in one context strengthens understanding in others.
A Useful Mental Exercise
To build intuition for the data model, walk through what happens when you run git commit:
- Files that changed have their content hashed; if the hash matches an existing blob, no new blob is created; otherwise a new blob object is written
- Trees for affected directories are recomputed with the new blob hashes; if a tree's content matches an existing tree, no new tree is created; otherwise a new tree object is written
- A new commit object is written, referencing the new root tree, with the parent set to the current HEAD
- HEAD is updated to point at the new commit
Every commit reuses as much as possible. Unchanged files reuse their existing blobs. Unchanged subdirectories reuse their existing trees. Only the changed paths create new objects. The space efficiency comes from this principle - most of a commit's content is references to already-existing objects.
This is why Git operations are fast even on large repositories. Looking up a commit doesn't require loading every file's history; it requires loading the commit object, then the tree, then the blobs you need. The structure makes selective access efficient.
Reading and Inspecting Objects
The plumbing commands (Topic 21.6) expose the data model directly:
# What type is this object?
git cat-file -t <sha>
# What's the size of this object?
git cat-file -s <sha>
# Print the content
git cat-file -p <sha>
# Hash a file (don't store)
git hash-object <file>
# Hash a file and store it
git hash-object -w <file>
# List all objects in the repository
find .git/objects -type f
These commands operate at the level of the data model itself. Most users never use them directly; the porcelain commands wrap them. But understanding what they do clarifies what the porcelain commands are actually doing under the surface.
What Comes Next
Topic 21.2 covers the object database in depth - how objects are stored on disk. Topic 21.3 covers refs. Topic 21.4 covers the index. Topic 21.5 covers packfiles. Topic 21.6 covers plumbing commands. Topic 21.7 covers the wire protocol. Topic 21.8 covers garbage collection.
What Goes Wrong
Warning: Common misconceptions about Git's data model:
Treating Git as storing deltas. Git stores full snapshots; delta encoding is an optimization layer (Topic 21.5).
Assuming files have identity. Files don't; their content does. Renames are reconstructed, not tracked.
Thinking branches are containers. Branches are pointers; commits are the data; branches just name them.
Assuming commits are objects with history. Each commit is a snapshot; history is reconstructed by following parent pointers.
Missing the content addressing. Same content = same hash = same object. This is the foundation of many Git behaviors.
Confusing tags and refs. Annotated tags are objects; lightweight tags are just refs.
Try It
Exercise: Build intuition for the data model:
- In a small test repository, run
git hash-object <some-file>. Note the hash.- Run
git cat-file -p HEAD. Identify the tree hash and parent hash.- Run
git cat-file -p <tree-hash>. Identify the entries.- Trace the commit graph:
git log --oneline --graphand compare to your mental model.- Find two files with identical content; verify they hash the same with
git hash-object.- Predict what
git cat-file -t <hash>will return for the various hashes you've found; verify.