Git Objects
TL;DR: Git is fundamentally a content-addressable database stored inside the .git/ folder. Every file, directory, commit, and tag in your repository is stored as an immutable object in .git/objects/, uniquely identified by a 40-character SHA-1 checksum of its header and content.
If you want to truly master Git, you must understand how it stores data. Many developers view Git as a system that tracks changes, diffs, or deltas between commits. This is a common misconception. In reality, Git is a simple, elegant content-addressable filesystem wrapped in a version control interface.
At its core, Git doesn’t care about filenames, paths, or modification times when it stores your files. It only cares about the file content itself. If you write a file, Git hashes the file’s content, compresses it, and saves it in a database. If two different files in two completely different directories have the exact same content, Git stores only a single copy of that content.
This guide will take you under the hood of Git’s database to explore the four core object types—blobs, trees, commits, and annotated tags—and show you exactly how Git structures and manages them.
What Do Git Objects Actually Do?
To understand how Git stores your project, you have to look at the four primitive object types that live inside .git/objects/. These four objects work together to represent the entire history of your codebase.
graph TD
Commit[Commit Object <br> Points to Root Tree & Parents] --> Tree1[Tree Object: Root <br> Points to Blobs & Sub-Trees]
Tree1 --> Blob1[Blob Object: main.js <br> Raw Content Only]
Tree1 --> Tree2[Tree Object: /src <br> Points to Blobs]
Tree2 --> Blob2[Blob Object: helper.js <br> Raw Content Only]
Tag[Tag Object <br> Points to Commit] --> Commit
1. Blobs (Binary Large Objects)
A blob is Git’s way of storing raw file content. The name stands for Binary Large Object, and that is exactly what it is: a stream of bytes.
- Content Only: Blobs do not store any metadata. They do not store the filename, the folder path, the creation date, or the file permissions (such as read/write/executable).
- Deduplication: Because a blob is identified solely by its hashed content, two identical files in your repository will point to the exact same blob, regardless of their filenames or where they live. This keeps Git repository sizes remarkably small.
2. Trees (Directories)
Since blobs strip away all metadata, how does Git know where your files belong or what they are named? It uses tree objects. A tree object corresponds to a directory in a traditional file system. A tree contains a list of directory entries, where each entry consists of:
- The file mode: An octal representation of permissions (e.g.,
100644for normal files,100755for executables, or040000for subdirectories). - The object type: Either
blob(for files) ortree(for subdirectories). - The SHA-1 hash: The 40-character ID pointing to the child blob or sub-tree.
- The name: The actual filename or directory name (e.g.,
index.jsorsrc/).
By nesting trees inside other trees, Git can reconstruct complex folder hierarchies.
3. Commits (The Snapshot Metadata)
A commit object links a specific snapshot of your repository to the project’s history. It provides the context of “who, when, and why” for your code. A commit object contains:
- A pointer to a tree: The SHA-1 hash of the root tree object representing the top-level directory of your project at that specific moment.
- Parent pointers: The SHA-1 hash(es) of the parent commit(s). A standard commit has one parent. A merge commit has two or more parents. The very first commit in a repository (the root commit) has zero parents.
- Author information: The name, email, and timestamp of when the code was written.
- Committer information: The name, email, and timestamp of when the commit was actually created.
- Commit message: A plain-text description explaining the changes.
4. Annotated Tags
An annotated tag is a reference that points directly to a specific object (usually a commit, though it can point to any object type). Unlike a simple reference (which is just a text file containing a commit hash), an annotated tag is a full Git object. It contains:
- The target object hash and its type.
- The tag name (e.g.,
v1.0.0). - The tagger’s name, email, and a timestamp.
- An optional message and cryptographic GPG signature.
What Happens Inside .git/ (Hands-On Exploration)
To see these objects in action, let’s build a repository from scratch and watch how Git creates and manipulates files inside the .git/objects/ directory.
Step 1: Initialize a Clean Repository
First, create an empty directory and initialize a new Git repository.
$ mkdir git-internals-demo
$ cd git-internals-demo
$ git init
Initialized empty Git repository in /path/to/git-internals-demo/.git/
If we look inside the newly created .git/objects/ directory, we’ll see it is mostly empty:
$ find .git/objects -type f
(No files are returned because Git has not created any objects yet. It only contains empty folders like info/ and pack/.)
Step 2: Create a File and Store a Blob
Let’s create a simple text file called hello.txt with the text hello world\n.
$ echo "hello world" > hello.txt
At this point, Git does not track the file. The .git/objects folder is still empty. Now, run git add to stage the file:
$ git add hello.txt
Staging a file forces Git to compress the file and write it as a blob into the object database. Let’s see what was created in .git/objects:
$ find .git/objects -type f
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
Git has created a new file inside the 3b/ subdirectory. The full SHA-1 hash of this blob is 3b18e512dba79e4c8300dd08aeb37f8e728b8dad.
To avoid file-system performance degradation caused by storing tens of thousands of files in a single folder, Git splits the 40-character SHA-1 hash:
- The first 2 characters (
3b) become the directory name. - The remaining 38 characters (
18e512dba79e4c8300dd08aeb37f8e728b8dad) become the filename.
Let’s use the low-level “plumbing” command git cat-file to inspect this object.
# Check the object type
$ git cat-file -t 3b18e5
blob
# Pretty-print the object's contents
$ git cat-file -p 3b18e5
hello world
# Check the size of the object in bytes
$ git cat-file -s 3b18e5
12
(Note: You only need to provide the first few characters of the hash as long as it is unique.)
How Git Calculates the Hash
How did Git arrive at 3b18e512...? It prepends a header to the content before hashing it. The format of the header is:
[object-type] [size-in-bytes]\0 (where \0 is the null byte).
For our hello.txt file, the raw data hashed is:
blob 12\0hello world\n
You can verify this in your terminal using Python or openssl:
$ printf "blob 12\0hello world\n" | shasum
3b18e512dba79e4c8300dd08aeb37f8e728b8dad -
This raw byte string is compressed using zlib (specifically, deflate compression) and stored directly at .git/objects/3b/18e512.... You can read the compressed file using Python:
$ python3 -c "import zlib; print(zlib.decompress(open('.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad', 'rb').read()))"
b'blob 12\x00hello world\n'
Step 3: Create a Commit (Generating Trees and Commits)
Now, let’s create a commit using git commit:
$ git commit -m "First commit"
[main (root-commit) 8c474d2] First commit
1 file changed, 1 insertion(+)
create mode 100644 hello.txt
Let’s check our .git/objects/ folder again:
$ find .git/objects -type f
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/8c/474d28d098a58f44ff53d9e8bc41e8c950269d
.git/objects/c3/ab1c3d18e80709e86bd90f84501a3cf8d74888
We now have three objects in our database.
3b18e5...(Our blob from earlier)c3ab1c...(A new tree object)8c474d...(A new commit object)
Let’s inspect the commit object using git cat-file:
$ git cat-file -p 8c474d
tree c3ab1c3d18e80709e86bd90f84501a3cf8d74888
author Jane Doe <jane@example.com> 1780741396 +0000
committer Jane Doe <jane@example.com> 1780741396 +0000
First commit
The commit object is plain text! It lists the root tree hash, the author, the committer, the timestamps, and the commit message. Notice that it does not point directly to our hello.txt blob; it points to the tree c3ab1c....
Let’s inspect the tree object:
$ git cat-file -p c3ab1c
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad hello.txt
The tree object lists its contents. Here we see the permissions (100644), the type (blob), the hash of the blob, and the actual name of the file (hello.txt). This is how Git binds filenames to contents!
Step 4: Add a Subdirectory and Modify Contents
Let’s see what happens when we create a nested folder structure and modify our file.
$ mkdir src
$ echo 'console.log("Git Internals");' > src/app.js
$ echo "hello world v2" > hello.txt
Stage and commit these changes:
$ git add .
$ git commit -m "Add source file and update hello.txt"
[main 7e891c2] Add source file and update hello.txt
2 files changed, 2 insertions(+), 1 deletion(-)
create mode 100644 src/app.js
Now look at the objects directory:
$ find .git/objects -type f | sort
.git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad
.git/objects/4b/825dc642cb6eb9a0acc1e140d7d914d682490d
.git/objects/7e/891c2fc886c99c8bf8a89ee1be2a09c2a382e2
.git/objects/8c/474d28d098a58f44ff53d9e8bc41e8c950269d
.git/objects/9a/c084f938d8f8d689622d99214b72605f624d77
.git/objects/c3/ab1c3d18e80709e86bd90f84501a3cf8d74888
.git/objects/d6/906059d007c6f2df83f4b48bc4a5b060d4b9bf
.git/objects/fa/01d43d1a6015bd67634f19b8813a7c64a3d4f8
We now have eight objects. Let’s trace down from the latest commit 7e891c...:
$ git cat-file -p 7e891c
tree fa01d43d1a6015bd67634f19b8813a7c64a3d4f8
parent 8c474d28d098a58f44ff53d9e8bc41e8c950269d
author Jane Doe <jane@example.com> 1780741520 +0000
committer Jane Doe <jane@example.com> 1780741520 +0000
Add source file and update hello.txt
Notice that this commit has a parent field pointing to our first commit 8c474d.... This links the chain of history. Now inspect the root tree fa01d4...:
$ git cat-file -p fa01d4
100644 blob 9ac084f938d8f8d689622d99214b72605f624d77 hello.txt
040000 tree d6906059d007c6f2df83f4b48bc4a5b060d4b9bf src
This root tree contains:
- A new blob
9ac084...representing the modifiedhello.txt. - A new tree
d69060...representing thesrcdirectory.
Let’s check the contents of the src tree d69060...:
$ git cat-file -p d69060
100644 blob 4b825dc642cb6eb9a0acc1e140d7d914d682490d app.js
Inside the src tree, we find the blob for app.js (4b825d...).
Through this simple DAG (Directed Acyclic Graph) of commit -> tree -> sub-tree -> blob, Git stores your entire project structure recursively and efficiently.
Detailed Command Options & Advanced Usage
While porcelain commands like git status shield you from the object database, plumbing commands let you read, write, and verify objects directly.
1. Advanced git cat-file Usage
The git cat-file command is the ultimate debugging tool for Git objects.
git cat-file -e <hash>(Exit Code Check): Checks if an object exists in the database. Returns0if it exists,1if it doesn’t. Extremely useful in automation scripts.git cat-file --batch: Starts an interactive loop where you can paste hashes on standard input, and Git returns their type, size, and content immediately. This avoids the overhead of booting the Git executable repeatedly.$ echo "3b18e5" | git cat-file --batch 3b18e512dba79e4c8300dd08aeb37f8e728b8dad blob 12 hello world
2. Writing Objects with git hash-object
You can write blobs directly to the database without staging files.
git hash-object -w <filename>: Computes the SHA-1 hash of a file and writes (-w) the compressed blob into the database.git hash-object --stdin -w: Reads text from standard input, hashes it, and saves it.
Even though this string is now in$ echo "Unstaged data" | git hash-object --stdin -w b9b7f58d0473a216260eb7b0bcbf78bf35a4d65a.git/objects/, no commit or branch points to it. It is considered a loose, dangling object and will eventually be cleaned up by Git’s garbage collector.
3. Crafting Custom Trees with git mktree
You can construct directory structures manually without writing files to disk. git mktree takes a list of file modes, object types, hashes, and filenames, and outputs a tree object hash.
$ printf "100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad\tmanually-built.txt\n" | git mktree
81f44a307044df62e15bc32b90b8529e79b97cf8
This is how advanced Git tooling and IDEs stage and commit files programmatically without relying on standard file-system writes.
Real-World Scenario
Scenario A: Recovering a Deleted File
Imagine a developer accidentally runs a command that destroys their working directory and index, but the .git/objects/ folder remains intact. Since Git keeps every staged file as a blob, they can recover their lost files even if they never committed them!
- Find all dangling blobs (blobs not referenced by any commit or tree):
$ git fsck --lost-found Checking object directories: 100% (256/256), done. dangling blob b9b7f58d0473a216260eb7b0bcbf78bf35a4d65a - Verify the contents of the dangling blob:
$ git cat-file -p b9b7f58d0473a216260eb7b0bcbf78bf35a4d65a Unstaged data - Restore the content back to the workspace:
$ git cat-file -p b9b7f58d0473a216260eb7b0bcbf78bf35a4d65a > recovered_file.txt
Scenario B: Finding a Large File Bloating Your Repo
A developer accidentally committed a 100MB video file, and even though they deleted the file in a subsequent commit, their .git/ folder size is still massive. This happens because Git commits are immutable, and the large blob is still referenced in the repository history.
- Run a command to list all objects sorted by size:
Output:$ git rev-list --objects --all | \ git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | \ awk '$1 == "blob" {print $3, $2, $4}' | \ sort -rn | head -n 5104857600 a2b3c4d5... media/intro.mp4 - You can identify the exact blob hash (
a2b3c4d5...) and name (media/intro.mp4) that is bloating your repository. You can then use a history-rewriting tool likegit-filter-repoto purge that blob from your objects database completely.
Common Pitfalls and Mistakes
1. Modifying Files Directly in .git/objects/
The Mistake: Trying to fix a corrupted file or manually editing metadata inside the .git/objects folder.
Why it fails: Since Git objects are content-addressed, if you edit even one character of a compressed object, its zlib structure will break, or its SHA-1 hash will no longer match the folder name/filename. Git will throw a fatal: loose object ... is corrupt error.
The Fix: Never modify files in .git/objects/ manually. If you need to fix history, use porcelain commands like git commit --amend or interactive rebase.
2. Believing “Git Stores Diffs”
The Mistake: Thinking that Git stores a base version of a file and then keeps line-by-line track of additions and deletions. Why it’s wrong: This is how older systems like CVS or SVN worked. Git stores full snapshots of your files. Every commit points to a tree containing full blobs. While this sounds like it would consume excessive disk space, Git uses delta-compression inside packfiles during garbage collection to pack similar files together and store only differences. But at the logical layer, Git is snapshot-based, not diff-based.
3. Loose Object Disappearance after git gc
The Mistake: Running scripts that look for loose files inside .git/objects/??/ and suddenly finding they have disappeared, assuming data loss.
Why it happens: Git runs automatic garbage collection (git gc). This process takes hundreds of loose objects and compresses them into a single binary file called a packfile (located in .git/objects/pack/). The individual loose files are deleted, but the objects themselves are still safely accessible in the database.
Command Quick Reference
| Command | Category | Description |
|---|---|---|
git cat-file -t <hash> | Plumbing | Prints the type of an object (blob, tree, commit, tag). |
git cat-file -p <hash> | Plumbing | Pretty-prints the content of an object. |
git cat-file -s <hash> | Plumbing | Returns the size of an object in bytes. |
git hash-object -w <file> | Plumbing | Calculates hash and writes the file to .git/objects. |
git ls-tree <tree-hash> | Plumbing | Lists the contents of a tree object (similar to ls -la). |
git verify-pack -v <idx-file> | Plumbing | Lists size and information for objects inside a packfile. |
git fsck | Porcelain | Verifies the connectivity and validity of objects in the database. |
FAQ (People Also Ask)
What is the difference between a blob and a file in Git?
A file contains content, metadata (like name and permissions), and is positioned within a directory tree. A blob is only the raw content, stripped of any name, directory location, or date. In Git, the file structure and names are stored in tree objects, while the contents are stored as blobs.
How does Git handle duplicate files in a repository?
If you have five copies of the exact same 10MB image in different directories across your repository, Git will only store one 10MB blob in .git/objects/. The five different tree entries will all point to the exact same SHA-1 hash. This makes Git incredibly efficient at handling duplicate files.
What happens if there is a SHA-1 hash collision in Git?
A SHA-1 hash collision occurs when two different pieces of content yield the same 40-character hexadecimal string. While mathematically possible, the probability of a collision occurring by chance is about $1$ in $10^48$. If a collision does occur, Git will fail to write the new object, and the commit will be rejected. Modern Git has mitigation schemes to detect SHA-1 collision attacks.
Why are Git objects stored in folders named after the first two characters of their hash?
Operating systems experience performance degradation when a single directory contains tens of thousands of files (as search lookups become slow). By using the first two characters of the SHA-1 hash to create $256$ subdirectories (from 00 to ff), Git distributes files evenly, ensuring fast disk access and lookup times.
Test Your Knowledge
Loading quiz…