Ask a working engineer what the Git index is for and the answer is almost always one word: staging. Not wrong, exactly — git add writes into it, and the porcelain layer never lets you forget the word “staged.” But it’s dangerously incomplete. The index is a single on-disk binary file doing three structurally unrelated jobs at once: it stages the content that will become your next commit, it caches enough filesystem metadata to make git status return instantly instead of re-hashing your working tree, and it becomes — mid-merge — the literal workspace where a conflict physically lives, the same path checked in three times over. Most engineers exercise all three behaviors weekly and were only ever taught the name for one.
This piece works through what .git/index actually stores at the byte level, why cached stat data is the real reason git status feels instant, what quietly defeats that shortcut, how core.fsmonitor closes the gap the stat cache can’t close alone, and how a merge conflict is, mechanically, the index temporarily forgetting how to have only one entry per path. It closes with the less comfortable question: what does it cost a system when the fast path and the source of truth are the same structure?

What .git/index Actually Stores
Start with what it isn’t: the index is not a Git object. It has no blob, tree, or commit envelope, no zlib compression, no home in .git/objects/. It’s a distinct binary file with its own format, defined precisely by the gitformat-index specification, and its own trailing checksum — a structure Git maintains entirely on its own terms.
The file opens with a 12-byte header: a 4-byte signature reading DIRC — the spec’s own shorthand for “dircache,” a name the project quietly retired everywhere except these four bytes — a 4-byte version number (2, 3, or 4, all still in use), and a 32-bit entry count. What follows is a sorted list of entries, one per tracked path in raw byte order, capped by a trailing hash checksum over everything before it.
Each entry is far denser than “a filename.” Per the spec, it carries:
- Full
stat(2)metadata from the last time Git touched the file: ctime and mtime (seconds and nanoseconds); device and inode numbers; a mode field encoding object type (regular file, symlink, orgitlinkfor a submodule) and Unix permission bits; uid, gid, file size. - The object’s hash — the SHA-1 (or SHA-256, in a SHA-256 repository) of the blob this path currently resolves to, the same hash
git hash-objectwould give the file’s content. - A 16-bit flags field packing an assume-valid bit, an extended-flag bit, a 2-bit stage number, and the name length. Version 3+ adds a second flags word for the skip-worktree and intent-to-add bits sparse checkouts and
git add -Nuse. - The path itself, padded in versions 2/3 to an 8-byte boundary; version 4 drops padding for prefix-compression against the previous path.
None of that is staging metadata. Device numbers, inode numbers, and nanosecond mtimes exist purely so a later Git invocation can compare cheap filesystem facts against a cached record instead of reading content — staging and caching interleaved inside the same bytes per entry, not layered on top of each other.
The format is also genuinely extensible — credit, not just critique. After the sorted entries, the file can carry named extensions: TREE (already-hashed subtrees, so write-tree skips untouched directories), REUC (saved conflict data for git rerere), UNTR (the untracked-file cache below), FSMN (the fsmonitor cache), plus newer entries for parallel loading and sparse directories. Each 4-byte signature is deliberately cased — uppercase mandatory, an older Git must refuse to proceed if it doesn’t recognize one; lowercase safe to skip — real forward-compatibility engineering that lets new caching layers bolt on without touching the entries that determine what gets committed.
| What people assume | What’s actually on disk |
|---|---|
| A plain list of staged file paths | Sorted binary records: full stat(2) metadata, mode, a 2-bit stage, flags, and the path — not a text list |
Only files you’ve explicitly git add-ed | Every tracked file, whether or not its content has changed since the last commit |
| One entry per tracked file, always | Up to three simultaneous entries per path during an unresolved merge (stages 1/2/3), collapsing to one (stage 0) once resolved |
| A Git object, hashed and stored like a blob or tree | Not a Git object at all — no zlib envelope, no home in .git/objects/, its own format and its own trailing checksum |
| Rebuilt from scratch on demand | A persistent file, rewritten in place, versioned, and extensible via named extensions that old Git versions can safely ignore |

Why git status Is Fast: The Stat-Cache Shortcut
Without the index’s stat fields, “has this file changed” has exactly one honest answer: open it, read it, hash it, compare the hash to what the last commit recorded. Do that for every tracked file on every git status, and the cost scales with your working tree’s content, every single time.
The index turns that into a cache lookup instead. Because every entry already carries the mtime, ctime, size, and mode Git observed last time it touched that path, git status can usually decide a file is unchanged by calling stat() and comparing the result against those cached numbers — no content read, no hash computed. Git only falls back to reading and re-hashing when the comparison is ambiguous, which is rare: most files, most of the time, genuinely haven’t changed, and stat() is orders of magnitude cheaper than a read-and-hash.
That shortcut has a documented failure mode with its own name: the “racy Git” problem, described in git.git’s own racy-git.adoc. Timestamp resolution on many filesystems is coarse — commonly one second — so a file modified, indexed, and modified again within that same tick can leave mtime unchanged even though its content isn’t. Git calls such an entry “racily clean”: when a recorded mtime is close enough to the index file’s own write timestamp for the race to be plausible, Git doesn’t trust the match — it compares actual content, and zeroes that entry’s cached size on the next index write so a later run is forced to check again rather than trust a coincidence. The stat cache is a shortcut Git is willing to use, but not one it trusts blindly at the exact moment trusting it would be indistinguishable from being wrong.

What Defeats the Cache: Slow Filesystems, Containers, and Huge Trees
Even when every comparison above resolves cleanly, there’s a cost the stat cache never removes: stat() still has to be called, once per tracked path, before any comparison happens. On a local, fast filesystem that’s close to free. The moment the filesystem call itself stops being cheap, the shortcut stops behaving like one — and the index’s cache role breaks down for reasons that have nothing to do with Git’s own logic.
Git’s own docs name the case directly: git-update-index documents --assume-unchanged as “sometimes helpful when working with a big project on a filesystem that has a very slow lstat(2) system call (e.g. cifs)” — an acknowledgment that a network filesystem can make the syscall the stat cache depends on expensive enough to matter. NFS shares hit the same wall: stat() becomes a round trip, not a local kernel call. Containerized and virtualized dev environments inherit the same shape whenever the working tree sits on a bind-mounted volume or synced folder — the cache assumes stat() is cheap, and any layer proxying filesystem calls instead of serving them locally defeats it regardless of how correct the cached data is.
Two other conditions defeat the shortcut in quieter ways:
| Condition | What breaks | Git’s answer |
|---|---|---|
Slow lstat() over a network filesystem (CIFS documented by name; NFS shares behave similarly) | Every path pays a network round trip before any comparison happens | --assume-unchanged, git update-index --skip-worktree, or moving the working tree off the network mount |
| ctime touched by something other than a real content change | The comparison flags a false positive and forces an unnecessary re-hash | core.trustctime=false drops ctime from the comparison, documented as “useful when the inode change time is regularly modified by something outside Git” |
| Huge trees — hundreds of thousands of tracked paths | The stat() walk itself dominates, one syscall per path, even when every single comparison is a clean hit | core.untrackedCache plus core.fsmonitor (next section) skip the walk entirely for directories that haven’t changed |
Git’s own docs are unusually candid about the precondition here: enabling core.untrackedCache carries the caution “before setting it to true, you should check that mtime is working properly on your system” — an admission that not every filesystem gives you an mtime worth caching against. The stat cache’s speed was never a property of the index alone; it’s a bet that the filesystem underneath plays fair, and that bet doesn’t always pay off.

core.fsmonitor: Skipping the Stat Walk Entirely
The one cost none of the previous fixes remove — the stat() walk itself, one syscall per tracked path, every invocation, regardless of how many files actually changed — is what core.fsmonitor removes. Instead of asking the filesystem about every path, Git asks a long-running background process what changed since it last checked.
The git-fsmonitor--daemon docs describe it plainly: it’s “a long running process used to watch a single working directory and maintain a list of the recently changed files and directories,” so git status “can be increased [in performance]” by asking for a summary of changes instead of scanning the disk. core.fsmonitor=true starts a built-in monitor with no external dependency; a filesystem path instead makes Git treat it as a hook script — the older pattern, historically built around Facebook’s Watchman. Per current git-config docs, the built-in daemon’s documented platform support is Windows and macOS — check your installed Git’s release notes for other platforms. Version compatibility matters too: Git 2.35.1 and earlier don’t understand the boolean form at all, treating true/false as hook pathnames, and 2.26–2.35.1 default to an older hook protocol that silently falls back to a full scan if the hook isn’t present.
Inside the index, an fsmonitor-aware Git records what it learned as the FSMN extension, sitting alongside the entries it makes redundant to stat(). Git’s own git-status docs are explicit that core.untrackedCache and core.fsmonitor work together, not as alternatives: with both enabled, “Git only has to enumerate the exact set of directories that have changed recently” — collapsing a full-tree walk into a lookup against whatever the daemon already knows.

The Other Job: How a Merge Conflict Lives Inside the Index
Everything above describes the index behaving as a cache. During an unresolved merge, the same 2-bit stage field introduced earlier does something structurally unrelated: it lets one path own more than one entry at the same time.
Stage 0 is the ordinary case — one entry per path, ready for the next commit’s tree. The moment a merge conflicts on a path, Git stops writing a stage-0 entry and writes up to three instead, one per stage, coexisting in the same flat, sorted structure. gitrevisions defines them precisely: “stage 1 is the common ancestor, stage 2 is the target branch’s version (typically the current branch), and stage 3 is the version from the branch which is being merged” — the formal source of the shorthand “base,” “ours,” and “theirs.”
That “typically the current branch” qualifier is doing real work — it’s the kind of detail engineers can miss for years. During git rebase, the labels flip. git-rebase‘s own docs state it directly: “a rebase merge works by replaying each commit from the working branch on top of the upstream branch. Because of this, when a merge conflict happens, the side reported as ours is the so-far rebased series, starting with upstream, and theirs is the working branch. In other words, the sides are swapped.” Stage 2 is still “ours” and stage 3 still “theirs” mechanically — but which branch those words point at depends on whether you ran merge or rebase.
| Stage | gitrevisions‘ definition | During git merge | During git rebase -m |
|---|---|---|---|
| 0 | No stage — the normal, resolved entry | Unconflicted content, ready to commit | Same |
| 1 | The common ancestor | The merge-base version of the file | Same |
| 2 | “The target branch’s version (typically the current branch)” — colloquially “ours” | Your current branch’s version | The upstream branch you’re rebasing onto |
| 3 | “The version from the branch which is being merged” — colloquially “theirs” | The incoming branch’s version | Your original working branch, whose commits are being replayed |
You don’t need porcelain to inspect any of this. git-ls-files --unmerged lists every stage entry for every conflicted path at once — its docs describe an unmerged path as recording “up to three such pairs; one from tree O in stage 1, A in stage 2, and B in stage 3.” gitrevisions‘ colon syntax addresses any one directly as a blob: git show :1:path prints the common-ancestor content, :2:path “ours,” :3:path “theirs,” no checkout required. git checkout --ours/--theirs are porcelain sugar over the same lookups. Resolving a conflict is nothing more than git add <path> collapsing whichever stage-1/2/3 entries exist back down to a single stage-0 entry — “mark as resolved” is a rewrite of a handful of index records, not a separate subsystem.

One File, Three Names: The Honest Design Critique
Git’s own documentation can’t settle on what to call this file, and that’s evidence, not sloppiness. The gitglossary defines index as “a collection of files with stat information, whose contents are stored as objects… a stored version of your working tree,” adding it “can also contain a second, and even a third version of a working tree, which are used when merging” — its own acknowledgment of the multi-stage behavior above. The same glossary carries a separate entry for cache, and its complete definition is one line: “Obsolete for: index.” Git’s docs formally retired a name for this file — yet the magic bytes at the start of every .git/index still read DIRC, “dircache,” and the porcelain command for touching it directly is still git update-index. Meanwhile, git-add‘s own docs introduce a third name in their first sentence: “the ‘index’ (also known as the ‘staging area’) is what you use to prepare the contents of the next commit” — a name that appears nowhere in the binary format’s own spec.
Three names, live simultaneously in Git’s own canon: index in the current glossary and every spec document, cache frozen into the on-disk signature and command surface after being declared obsolete everywhere else, and staging area promoted in porcelain docs as the friendly gloss for newcomers. That’s not inconsistent technical writing. A project that’s maintained precise, cross-referenced docs for two decades doesn’t accidentally leave three names for one file lying around — it does that when the file never settled into one coherent responsibility, and the vocabulary drifted with it. Staging, stat-caching, and merge-conflict bookkeeping are three genuinely different jobs, each with its own natural name, welded into one binary format because three separate structures — three separate consistency stories, three separate places for add, status, and a three-way merge to reach for information — would have cost more to build and reason about in 2005 than accepting the coupling. That’s a legitimate trade-off. It’s still, honestly, a design smell: every “why is my index corrupted” report and every confusing partial-add edge case traces back to one structure carrying responsibilities that don’t share a natural boundary.

The Transferable Lesson: A Cache That Is Also a Source of Truth
Pull the Git specifics away and what’s left is a shape that recurs constantly in systems design: a structure built to accelerate a read path quietly becomes the only place a piece of state lives. Name the failure mode plainly — a cache that is also a source of truth — because the properties you normally get from a cache for free disappear the moment that happens. You can’t invalidate and safely recompute, because there’s nothing independent to recompute from. A stale read stops being merely slow and becomes a correctness bug. You can’t scale it horizontally the way you’d scale a read-through cache, because there’s exactly one authoritative copy, not many disposable ones.
The Git index is exactly this shape — the stage-0 entries are the staged content, and the stat fields in the same records are the cache — and how Git keeps that hazard from becoming silent corruption is as instructive as the coupling itself:
- It never trusts the cache for content, only for the “nothing changed” fast path. A mismatch, or even a merely suspicious match, always falls back to reading and hashing real bytes — being wrong about the cache degrades to slow, never to wrong.
- The racy-Git mitigation is a “when in doubt, don’t trust the shortcut” policy, not a “trust it and hope” one — a same-timestamp entry gets re-verified against content and has its cached size zeroed on the next write, specifically so a coincidence can’t masquerade as a confirmed match twice in a row.
- A single trailing checksum covers the whole file, so corruption of the source-of-truth entries is detectable at load time rather than propagating silently into whatever trusted them.
- New caching layers extend the file without touching the entries that matter for correctness.
FSMNandUNTRare separable, skippable records living beside the stage-0 entries, not blended into them — the fast path and the ground truth are neighbors in the same file, not the same bytes.
That’s the transferable checklist — questions worth asking before any performance cache in your system is allowed to absorb state nothing else remembers: when it’s wrong, does the system fail loud, fail slow, or fail silently wrong? Is there an independent checksum or real-data fallback catching corruption at the boundary, rather than deep inside a call path that assumed the cache was honest? Can a new cached field be added without touching the fields a mistake there would actually corrupt? And — the smallest, most diagnostic question — would five engineers on your team give you three different, defensible names for the structure? Git’s index has been exactly that kind of structure for two decades, and it works because every one of those questions has a real answer built into the format. Most homegrown equivalents never get asked.

What This Means for You
- Stop describing
.git/indexas “just the staging area.” An instantgit statusis the stat cache paying off, not luck — and a slow one means the stat cache is where to look first. - Slow
git status/git addon an otherwise-fine repo? Suspect the filesystem: NFS/CIFS mounts, container bind mounts, and synced VM folders are the documented, textbook cause. - On large working trees, enable
core.untrackedCache=trueandcore.fsmonitor=truetogether before reaching for anything more exotic — Git’s own docs state the pairing outperforms either alone. - Unrecognized content in a conflict? Check whether you’re inside a rebase — ours/theirs are genuinely swapped there, not a bug.
git show :1:path,:2:path,:3:pathread any stage of a conflicted file directly, no checkout required — useful for scripting conflict resolution.- Before building your own “fast path that’s secretly the only copy,” write down what happens when it’s wrong. Git’s index always has a real answer — make sure yours does too.
📚 Further Reading
gitformat-index— the full binary spec: header, per-entry stat fields, the 2-bit stage, extended flags, and named extensions.git-update-index— the plumbing behindgit add,--assume-unchanged, and--skip-worktree.racy-git.adoc— the original write-up of the racy-Git problem and “racily clean” detection.- What Is a Container? The Idea That Powers Modern Software (and AI) — the virtualized-filesystem model behind why bind-mounted repos defeat the stat-cache shortcut.
- Measuring Network Performance — why a CIFS or NFS mount turns a cheap local syscall into a round trip.
- Serena MCP: Give AI Coding Agents a Real Memory — a different system facing the same question: what happens when a memory/cache layer becomes load-bearing for correctness.
- The Best Books on Distributed Systems (Beginner to Expert) — the full, general treatment of the cache-vs-source-of-truth trade-off this article closes on.
❓ Frequently Asked Questions
Is the Git index the same thing as the staging area?
Same file, but “staging area” only names one of its three jobs. Git’s own git-add docs call it “the ‘index’ (also known as the ‘staging area’)” — but the file also caches stat(2) metadata to make git status fast, and holds up to three simultaneous entries per path during an unresolved merge, neither of which is staging.
Why is git status fast even in a repository with hundreds of thousands of files?
Each index entry stores stat metadata (mtime, ctime, size, mode) from the last time Git touched the file. git status compares a cheap stat() call against those cached values and only falls back to reading and hashing content when the comparison is ambiguous — rare, since most tracked files genuinely haven’t changed between checks.
Why does git status suddenly get slow on a network drive or inside a container?
The shortcut still requires calling stat() on every tracked path before any comparison happens. Git’s own docs name CIFS by example as a filesystem with “a very slow lstat(2) system call”; NFS shares, container bind mounts, and synced VM folders share the same problem — a cheap local syscall becomes a round trip or a proxied call.
Does a merge conflict break the index?
No — it uses a mechanism the index already has. A conflicted path gets up to three entries at once (stages 1/2/3: common ancestor, “ours,” “theirs”) instead of one stage-0 entry. git add <path> resolves it by collapsing those back down to one.
Is “ours” always my current branch during a conflict?
Not during a rebase. Git’s own git-rebase docs state that because rebase replays commits onto the upstream branch, “the side reported as ours is the so-far rebased series, starting with upstream, and theirs is the working branch” — the opposite of a normal merge.
What is core.fsmonitor, and do I need Watchman to use it?
No. core.fsmonitor=true starts a built-in monitor daemon with no external dependency, on the platforms it currently supports (documented as Windows and macOS). Pointing it at a filesystem path instead invokes that path as a hook script — the older pattern historically built around Facebook’s Watchman. Git 2.35.1 and earlier don’t understand the boolean form at all and misinterpret true/false as hook pathnames, so check your installed version first.
📚 Recommended Resources
Disclosure: some links below are Amazon affiliate links. If you buy through them, I earn a small commission — at no extra cost to you — which helps keep this site running.
Pro Git (2nd ed.)
Scott Chacon & Ben Straub · Book
Chapter 10, “Git Internals,” is the primary source for the index’s stat-cache role and the plumbing (update-index, write-tree) this article decomposes it into. The full text is free online at git-scm.com/book under CC BY-NC-SA 3.0 — read it there first; the print edition is a convenient reference if you’d rather have it off-screen.
Version Control with Git (3rd ed.)
Prem Kumar Ponuthorai & Jon Loeliger · Book
The deepest print treatment of Git’s command surface and index internals — including update-index, the sparse-checkout flags, and the merge-stage mechanics this article walks through in detail. A solid next step once .git/index‘s binary format has clicked.
Designing Data-Intensive Applications (2nd ed.)
Martin Kleppmann & Chris Riccomini · Book
The book that makes this article’s closing lesson click into place for systems well outside version control: what it costs when a performance cache quietly becomes a source of truth, and how checksums, careful invalidation, and separable extensions contain that hazard elsewhere in distributed systems.
For the index’s own internals rather than the porcelain around it, James Coglan’s Building Git rebuilds Git — including the index file itself — from scratch in Ruby. No Amazon listing exists for it; it’s sold ebook-only, direct from the author. If that angle interests you, it’s worth buying directly from shop.jcoglan.com/building-git — a non-Amazon purchase, no affiliate link here.