Whodunnit? Finding the Culprit With git blame
git blame explained: read output, narrow lines with -L, -w, -M, -C, skip bulk commits, and trace the real change behind a line.
git blame annotates every line of a file with the commit that most recently changed it, along with that commit’s author and date.
The commit it names is often the wrong one. You look up an odd line in code you did not write, and blame hands you a 4,000-file “apply prettier” commit from eighteen months ago.
That dead end is the normal outcome of a bare git blame, and getting past it is the actual skill. This article covers how to read the default output, how to narrow and de-noise it with -L, -w, -M and -C, how to walk backwards through parent commits until you reach the change that matters, and two recent additions: --diff-algorithm (Git 2.53 or later) and git last-modified (Git 2.52 or later).
Key Takeaways
- The commit git blame shows for a line is the last one that touched it, which is often a reformat, a rename, or a move rather than the change that gave the line its meaning.
- Re-running blame at the reported commit’s parent (
git blame <hash>^ -- file) and repeating is the reliable way to reach the originating change;--ignore-revand--ignore-revs-fileskip known noise commits automatically. -wignores whitespace,-Mfollows lines moved inside a file (default threshold 20 alphanumeric characters), and-Cfollows lines copied from other files (default 40), with up to three-Cflags widening the search.- Git 2.53 added
--diff-algorithmto git blame, acceptingpatience,minimal,histogramormyers, withmyersas the default. - Git 2.52 added the experimental
git last-modified, which reports the last commit to touch each path in a directory in one traversal.
How Do You Read the Default git blame Output?
Each line of default git blame output carries four fields in order: the abbreviated commit hash, the author name, the author date, and the line number, followed by the line’s content. The default format section of the man page lists these fields; Git shortens the hash to seven hex digits by default and leaves one further column free for the caret that flags a boundary commit (the oldest commits blame could reach). Dates print in ISO format unless --date or blame.date says otherwise.
git blame src/router.js
a1b2c3d4 (Jane Doe 2024-03-08 14:22:31 +0100 42) return cache.get(key) ?? fetchRoute(key);
Read left to right: a1b2c3d4 is the commit, Jane Doe and the timestamp are that commit’s author ident, 42 is the line number in the current file, and everything after the closing parenthesis is the line itself.
The important thing to understand about that hash is what it is not. It is not the commit that introduced the logic. It is the most recent commit whose diff touched the line, and in a codebase with formatters, linters, and refactors, that is frequently a mechanical change. Treat the first blame result as a lead, not a verdict.
How Do You Limit git blame to a Line Range With -L?
git blame -L 40,60 -- src/router.js restricts the annotation to lines 40 through 60, and git blame -L :handleRoute -- src/router.js restricts it to the body of the function whose name matches that regular expression. Both forms are documented under the -L option, which may be given more than once.
git blame -L 40,60 -- src/router.js
git blame -L :handleRoute -- src/router.js
The :funcname form does not parse your language. It spots function names the same way git diff works out what to print in a hunk header, and you can tune that per file type through the diff attribute in gitattributes. Both range endpoints also accept /regex/ patterns, and the end point accepts +N offsets, so -L '/^function handleRoute/,+15' is valid too.
How Do You Ignore Whitespace and Moved Code in git blame?
Passing -w makes git blame ignore whitespace when comparing versions, so an indentation-only reformat no longer claims the lines it touched. -M picks up lines that shifted around inside one file, and -C widens the hunt to lines that arrived from other files the same commit changed; the man page gives their default match thresholds as 20 and 40 alphanumeric characters respectively.
| Symptom | Flag |
|---|---|
| Line attributed to a re-indent or trailing-space cleanup | -w |
| Line attributed to the commit that reordered code inside the file | -M |
| Line arrived by copy or move from another file | -C (stackable) |
| Line attributed to a known bulk commit | --ignore-rev <hash> |
git blame -w -- src/router.js
git blame -M -- src/router.js
git blame -C -C -C -- src/router.js
Each additional -C widens the files git blame searches for the copied lines:
-Csearches the other files that same commit changed.-C -Calso searches the files touched by the commit that first added this one.-C -C -Cwidens it once more, to files in any commit.
If several -C flags carry a numeric threshold, the last one wins. A whole-file rename needs no flag at all: blame keeps tracking the lines across it by itself, and Git currently gives you no way to switch that behaviour off.
How Do You Find the Commit Before a Bulk Reformat?
To get past a mechanical commit, re-run blame at that commit’s parent, git blame <hash>^ -- src/router.js, and repeat until the commit shown is one that actually changed the line’s behaviour. The ^ suffix is standard gitrevisions syntax for the first parent, so blame starts from the state of the file just before the noise commit landed.
- Run
git blame -L 40,60 -- src/router.jsand note the hash on the line of interest. - Check the commit with
git show --stat <hash>. If it is a reformat, rename, or move, continue. - Run
git blame -n <hash>^ -L 40,60 -- src/router.js. The-nflag prints each line’s number in the original commit, which matters because line numbers drift between revisions and you may need to re-target-Lon the next pass. - Repeat from step 2 until the commit shown changes what the line does.
git blame -n a1b2c3d4^ -L 40,60 -- src/router.js
When a repository has known noise commits, skip the manual walk. --ignore-rev <hash> tells git blame to attribute lines past a specified commit, and --ignore-revs-file does the same for a whole file of hashes, written out in full, one to a line. Set blame.markIgnoredLines to flag reassigned lines with ? and blame.markUnblamableLines to flag lines that could not be reassigned with *.
git blame --ignore-rev a1b2c3d4 -- src/router.js
git blame --ignore-revs-file .git-blame-ignore-revs -- src/router.js
git config blame.markIgnoredLines true
Committing that list as .git-blame-ignore-revs and pointing blame.ignoreRevsFile at it is covered in 5 Git Dotfiles Every Developer Should Know.
Trying a Different Diff Algorithm (Git 2.53 or Later)
Git 2.53 added --diff-algorithm to git blame, accepting patience, minimal, histogram or myers (with default as an alias for myers), and myers is the default. The addition appears in the Git 2.53 release notes, and the accepted values are listed under the —diff-algorithm option in the man page.
Blame decides which parent lines correspond to which child lines by diffing the two versions, and different algorithms pair lines differently. When a commit interleaves changed and unchanged lines, as reformats often do, one algorithm may credit a line to the reformat while another credits it to the commit that originally wrote it.
git blame -L 40,60 -- src/router.js
git blame -L 40,60 --diff-algorithm=patience -- src/router.js
No algorithm is documented as more correct than another. If the default attribution looks implausible, running the same command with patience or histogram costs one extra invocation and gives you a second opinion to compare.
Asking About a Directory With git last-modified
Git 2.52 added git last-modified, which reports the commit that last changed each path in a directory in a single history traversal instead of one git log -1 per file; the command is marked experimental and its behaviour may change. The git-last-modified man page states the experimental status in its NAME line and shows the output shape as <oid> TAB <path>, one line per path, with a full object ID and no author, date, or subject.
git last-modified -r -- src/
Without -r (or a non-zero --max-depth) you get only the entries that match the pathspec itself, with no walk down into the subdirectories below them. Renames and mode changes count as modifications. The per-file loop it replaces re-walks the same commits once for every file; last-modified walks them once. It answers “what changed recently in this module”, a different question from “why does this line exist”, and it is worth reaching for before you start blaming individual files.
Blame Is a Question, Not a Verdict
The output of git blame names the last person to touch a line, and that name is almost never the answer you need. Run blame with -L to focus, -w and -M/-C to strip mechanical noise, then step back through parents (or maintain an ignore file) until the commit shown carries a message that explains the line. Once you have that commit, git show <hash> gives you the diff and the reasoning, which is the point of the exercise: understanding why the code is there, so you can change it without repeating whatever incident put it there in the first place.
FAQs
How do I find out who deleted a line, since git blame only shows lines that still exist?
git blame tells you nothing about lines that were taken out or written over, as its man page points out. Use the pickaxe instead: git log -S'some text' -- src/router.js lists every commit that added or removed that string, and adding -p shows the removal itself. Alternatively, git blame --reverse a1b2c3d..HEAD -- src/router.js walks history forward from that commit and names the newest revision in which each line was still there.
Why does git blame show 00000000 and 'Not Committed Yet' on some lines?
Those lines carry uncommitted changes. Without a revision argument, git blame annotates the working tree copy of the file, so any line that differs from HEAD gets an all-zero hash and 'Not Committed Yet' in place of the author name. Commit or stash the change, or run git blame HEAD -- src/router.js to annotate the committed version and ignore local edits entirely.
Does GitHub's blame view honour a .git-blame-ignore-revs file?
Yes. GitHub automatically applies a file named .git-blame-ignore-revs in the repository root to its blame view, using the same --ignore-revs-file mechanism as the command line, and shows an 'Ignoring revisions' banner when it does. Lines that cannot be reattributed to an earlier commit still show the ignored commit. The file does not configure local git; each developer still needs to run git config blame.ignoreRevsFile .git-blame-ignore-revs.
What is the difference between git blame and git log -L?
git blame reports one commit per line: the most recent commit that touched it in a single version of the file. git log -L 40,60:src/router.js instead traces lines 40 through 60 across history and prints every commit that changed them, each with the diff for that range, newest first. Use blame to identify a suspect quickly and log -L to watch the lines evolve. Both accept the :funcname form, as in git log -L :handleRoute:src/router.js.