git diff
git diff is the command that shows you exactly what has changed, line by line, before you commit anything. It compares two snapshots of your project — your working directory, the staging area (the index), and any commit in history — and prints the differences in a compact, readable format. Learning to read git diff output fluently is one of the highest-leverage skills in Git: it is how you catch bugs, leftover debug code, and unintended changes before they become part of your project’s permanent history.
Overview: How git diff Works
To understand git diff you first need to understand the three places Git keeps a version of your files: the working directory (the actual files on disk that you edit), the index (also called the staging area — a snapshot of what will go into your next commit), and the repository (the committed history, with HEAD pointing at the tip of your current branch). git diff compares any two of these.
Internally, every version of a file’s content is stored as a blob object, addressed by the SHA-1 hash of its content. A tree object represents a directory snapshot: it lists filenames and the blob (or sub-tree) hash for each one. A commit object points to one tree — the full snapshot of the project at that point — plus its parent commit(s), an author, and a message. A branch like main is nothing more than a file containing the SHA-1 of a commit; HEAD usually points at a branch, and the branch points at a commit. When you run git diff, Git does not look up a pre-computed diff anywhere — no such thing is stored in the repository. Instead it takes the two trees (or a tree and the working files) you asked to compare, walks them path by path, and for every file whose blob hash differs, runs a diff algorithm (Myers’ algorithm by default) over the two versions to produce a minimal set of added and removed lines.
Three comparisons come up constantly:
git diffwith no arguments compares the working directory to the index — it shows changes you have made but not yet staged withgit add.git diff --staged(equivalentlygit diff --cached) compares the index to HEAD — it shows exactly what will go into your next commit.git diff HEADcompares the working directory directly to HEAD, skipping the index — it shows every change, staged or not, in one combined view.
git diff can also compare arbitrary commits, tags, or branches, since under the hood a branch name and a commit hash resolve to the same thing: a tree it can walk.
Syntax
The general form is:
git diff [options] [<commit1>] [<commit2>] [-- <path>...]
Common options:
| Form | What it compares / does |
|---|---|
git diff |
Working directory vs. the index (unstaged changes) |
git diff --staged / --cached |
Index vs. HEAD (what the next commit will contain) |
git diff HEAD |
Working directory vs. HEAD (staged + unstaged combined) |
git diff <commit> |
Working directory vs. an arbitrary commit |
git diff <commit1> <commit2> |
Two arbitrary commits, oldest first for a “what changed” view |
git diff branchA..branchB |
Tip of branchA directly against tip of branchB |
git diff branchA...branchB |
branchB against the common ancestor (“merge base”) of both branches |
-- <path> |
Restrict the diff to one file or directory |
--stat |
Summary: files changed and a count of insertions/deletions, no line detail |
--name-only |
List only the names of changed files |
--name-status |
List changed file names with a status letter (A/M/D/R) |
-U<n> |
Show <n> lines of context around each change instead of the default 3 |
--word-diff |
Highlight changed words instead of whole lines — useful for prose or long lines |
--color=always |
Force colored output even when piping to another program |
For a quick overview before reading the full detail, run:
git diff --stat
git diff --name-only
Examples
Example 1: Unstaged changes in the working directory
Start with a small app.js file already committed to the repository:
echo "console.log('user logged in');" >> app.js
git diff
Output:
diff --git a/app.js b/app.js
index e69de29..3b1e6f2 100644
--- a/app.js
+++ b/app.js
@@ -1,3 +1,4 @@
function login(user) {
console.log(`Welcome, ${user}`);
}
+console.log('user logged in');
The diff --git line names the file being compared. The index line shows the blob hashes before and after, truncated, plus the file mode. The ---/+++ lines label the “before” (a/) and “after” (b/) versions. The hunk header @@ -1,3 +1,4 @@ means “starting at line 1, the old version had 3 lines; starting at line 1, the new version has 4 lines.” Lines with no prefix are unchanged context, and the + line is the new line git diff detected — it has not been staged or committed yet, only written to disk.
Example 2: Staged vs. unstaged, side by side
git add app.js
git diff
git diff --staged
Output:
$ git diff
$ git diff --staged
diff --git a/app.js b/app.js
index e69de29..3b1e6f2 100644
--- a/app.js
+++ b/app.js
@@ -1,3 +1,4 @@
function login(user) {
console.log(`Welcome, ${user}`);
}
+console.log('user logged in');
After git add app.js, the index now matches the working directory, so plain git diff (working directory vs. index) prints nothing at all — this surprises a lot of beginners into thinking their change vanished. It didn’t; it moved into the staging area. git diff --staged now shows the same hunk, because it compares the index to HEAD, and HEAD still has the old version of the file.
Example 3: Comparing two branches
git switch -c feature/login-page
echo "console.log('session started');" >> app.js
git commit -am "feat: log session start on login"
git switch main
git diff main..feature/login-page
Output:
diff --git a/app.js b/app.js
index 3b1e6f2..8a2f9c1 100644
--- a/app.js
+++ b/app.js
@@ -1,4 +1,5 @@
function login(user) {
console.log(`Welcome, ${user}`);
}
console.log('user logged in');
+console.log('session started');
Here git diff main..feature/login-page compares the tip commit of main directly against the tip commit of feature/login-page, showing everything that branch has added since it split off. This is close to the diff GitHub shows you in a pull request’s “Files changed” tab.
How It Works Step by Step
When you run plain git diff, Git performs roughly these steps:
- Git reads the index file (
.git/index), which records, for every tracked path, the blob hash Git believes is currently staged. - For each tracked path, Git hashes the current content of the file on disk — it compares content, not timestamps, for the final result.
- Where the on-disk hash differs from the index’s recorded hash, Git loads both blobs’ content and runs a diff algorithm over the two texts to find the minimal set of line insertions and deletions that transform one into the other.
- The result is formatted as unified diff output: a header per file, then one or more hunks, each with a few lines of unchanged context around the changed lines.
For git diff --staged, step 1 is replaced with reading the tree of the commit HEAD points to, and step 2 compares that tree’s blob hashes against the index’s blob hashes instead of the working files — the working directory is not even consulted. This is why a change you’ve edited on disk but not staged never shows up in git diff --staged, and a change you’ve staged but then edited again on disk shows up differently in git diff (index vs. working) and git diff --staged (HEAD vs. index).
For a two-commit or two-branch diff, there is no working directory or index involved at all — Git simply loads both commits’ trees and walks them, which is also why comparing commits is fast even in a huge repository: it only touches the objects for paths that actually changed.
Two-dot vs. three-dot ranges
git diff A..B and git diff A B are equivalent — a straight comparison of B against A. git diff A...B instead first computes the merge base (the most recent common ancestor commit) of A and B, then diffs B against that merge base. For a feature branch that started from main and has not been rebased, three-dot notation shows only the changes made on the feature branch — ignoring anything new that has landed on main in the meantime — which is usually the more useful view when reviewing a branch’s own contribution.
Common Mistakes
Mistake 1: Assuming git diff shows staged changes.
# after staging a file, this prints nothing --
# it is NOT comparing what will be committed
git add app.js
git diff
Plain git diff only ever compares the working directory to the index. Once a change is staged, the working directory and the index agree, so there is nothing left to show. The fix is to use git diff --staged (or --cached) to see what is actually queued for the next commit, or git diff HEAD to see everything — staged and unstaged — in one pass.
Mistake 2: Mixing up two-dot and three-dot branch ranges.
# expecting only the feature branch's own changes,
# but main has moved on since the branch was created,
# so this also includes every commit main gained meanwhile
git diff main..feature/login-page
If main has advanced since feature/login-page branched off, main..feature/login-page includes both the feature branch’s real changes and the reverse of whatever landed on main afterward, producing a confusing, noisy diff. Use git diff main...feature/login-page (three dots) to diff against the merge base instead, which isolates just the feature branch’s own commits — this is the same comparison GitHub uses for a pull request’s diff view.
Best Practices
- Run
git diff --stagedright before every commit — it’s the last chance to catch a strayconsole.log, a commented-out block, or a file you didn’t mean to include. - Use
git diff --statfirst on a large change set to see which files moved, then drill into individual files withgit diff -- <path>. - Prefer
git diff main...feature/your-branch(three dots) when reviewing what a branch contributes, so unrelated upstream commits don’t clutter the output. - Configure a visual diff tool with
git difftoolfor large or binary-adjacent changes where a side-by-side view is easier to read than terminal output. - Keep commits small and diffs short — a diff you can read in a few seconds is a diff you’ll actually review; a 2,000-line diff mostly gets skimmed.
- Write Conventional Commits messages (
feat:,fix:,refactor:,docs:) that describe what the diff you just reviewed actually does, while the change is still fresh in your head.
Practice Exercises
- In a scratch repository, create a file, commit it, then edit it without staging. Predict what
git diff,git diff --staged, andgit diff HEADwill each show before you run them, then verify. - Create a branch named
feature/nav-bar, make two commits on it, then switch back tomainand make an unrelated commit there too. Comparegit diff main..feature/nav-baragainstgit diff main...feature/nav-barand explain in your own words why they differ. - Stage a change to a file, then edit that same file again without re-staging. Run
git diffandgit diff --stagedand confirm each shows a different set of changes — work out which lines belong to which command before checking.
Summary
- git diff compares two snapshots — working directory, index, or any commit — and computes the difference on the fly; nothing is pre-stored.
- Plain
git diffshows unstaged changes (working directory vs. index);git diff --stagedshows what will actually be committed (index vs. HEAD);git diff HEADshows both combined. - A hunk header like
@@ -1,3 +1,4 @@tells you the starting line and line count of a change region in the old and new versions. - Two-dot (
A..B) diffs compare tips directly; three-dot (A...B) diffs compare against the merge base — use three-dot when reviewing a branch’s own contribution. - Always run
git diff --stagedbefore committing — it’s the cheapest way to catch mistakes before they enter permanent history.
