Git Bisect: Find the Commit That Introduced a Bug
Find the commit that introduced a regression with Git bisect. Follow a tested Python example, automate the search, and learn when a test error can produce the wrong result.
Suppose your app should accept a file exactly at its size limit, but the current version rejects it. An older revision works. That gives git bisect two useful facts: a known passing version and a known failing version. Git chooses revisions to test between them until it can identify the change that introduced the failure.
The crucial preparation is a check that reliably distinguishes those outcomes. Git chooses the next commit; your test decides what “good” and “bad” mean. The Pro Git debugging chapter describes this search-and-classify workflow.
This guide builds a disposable 16-commit repository, finds an intentional regression manually, then repeats the search automatically. The example was run with Git 2.51.1 and Python 3.12.14 on Linux. It also demonstrates a misleading result caused by a broken test command, because recognising that failure is as useful as learning the happy path.
What Git is narrowing down
A regression is behaviour that worked before and now fails. For this example, “good” means that three upload-size checks return their expected Boolean values. It says nothing about the quality of the rest of the application.
In a simple linear history with one transition from passing to failing, a passing revision moves the lower boundary forward. A failing revision moves the upper boundary back. The remaining question gets smaller after each useful test.
Real histories can contain branches and merges. Git reasons about commit ancestry, so the search is not simply a date-sorted list of everything committed between two releases. The Git project's explanation of the bisection algorithm describes which ancestors remain candidates. Our deliberately linear example makes the narrowing easy to see.
Before using this on a project, settle three things: the exact symptom, a revision that demonstrably passes the check, and a revision that demonstrably fails it. A release tag is a convenient name for a commit; test its behaviour before assigning the label.
Create a small history with a known answer
You need Git 2.28 or later and Python 3 available in your terminal. The setup uses the initial-branch option documented in Git 2.28’s init reference . The commands below use python3; if your installation uses python or py -3, substitute that invocation consistently, including after git bisect run. Use Git Bash on Windows or a terminal on macOS/Linux for these shell examples. Only the Linux environment named above was exercised for this article.
Create a new working folder outside an existing project and save the following two files there. The setup script creates a new bisect-lab subfolder and refuses an existing destination. It creates local commits without contacting a remote or changing your global Git settings.
Save this as create_bisect_lab.py:
"""Create a local-only Git history. Requires Git 2.28+ and Python 3."""
from pathlib import Path
import os
import subprocess
import sys
repo = Path(sys.argv[1] if len(sys.argv) > 1 else "bisect-lab").resolve()
repo.mkdir() # Refuse to overwrite an existing directory.
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
def git(*args):
return subprocess.check_output(
["git", "-C", str(repo), *args], env=env, text=True
).strip()
git("init", "-q", "-b", "main", "--template=")
git("config", "user.name", "Bisect Lab")
git("config", "user.email", "[email protected]")
git("config", "commit.gpgsign", "false")
git("config", "core.autocrlf", "false")
for step in range(1, 17):
operator = "<=" if step < 10 else "<"
(repo / "upload.py").write_text(
"def accepts_upload(size, limit):\n"
f" return size {operator} limit\n",
encoding="utf-8",
)
(repo / "notes.txt").write_text(f"Revision {step}\n", encoding="utf-8")
git("add", "upload.py", "notes.txt")
git("commit", "-q", "-m", f"Demo revision {step:02d}")
if step == 1:
git("tag", "demo-good")
git("tag", "demo-bad")
print(f"Created {repo}")
print("Known endpoints: demo-good and demo-bad. Regression: revision 10.") The history has 16 numbered commits. Revision 01 receives the tag demo-good; revision 16 receives demo-bad. At revision 10, the comparison changes from <= to <. Other commits update a notes file, giving the search several irrelevant changes to eliminate.
This is a controlled demonstration, so the answer is deliberately visible in the setup. The exercise is to recover that answer from behaviour.
Keep one test outside the changing history
Save check_upload.py beside the setup script, outside bisect-lab:
"""Run from the lab repository root; keep this file outside its history."""
from pathlib import Path
import runpy
import sys
if not Path(".git").exists():
print("ABORT: Run this checker from the lab repository root.", file=sys.stderr)
sys.exit(128)
source = Path("upload.py")
if not source.is_file():
print("SKIP: This revision does not contain upload.py.")
sys.exit(125)
try:
accepts_upload = runpy.run_path(str(source))["accepts_upload"]
cases = [(99, 100, True), (100, 100, True), (101, 100, False)]
failures = []
for size, limit, expected in cases:
actual = accepts_upload(size, limit)
if actual is not expected:
failures.append(f"size={size}: expected {expected}, got {actual!r}")
except Exception as error:
print(f"ABORT: Checker could not evaluate this revision: {error}", file=sys.stderr)
sys.exit(128)
if failures:
print("BAD: " + "; ".join(failures))
sys.exit(1)
print("GOOD: Below-limit, exact-limit and above-limit cases passed.") The checker loads the currently checked-out upload.py and tests sizes 99, 100 and 101 against a limit of 100. These are simple integer inputs to a function; the example does not upload files or run a server.
Its outcomes are deliberate: 0 for the expected behaviour, 1 for a failed comparison, 125 when the implementation file is absent, and 128 when the check cannot run as intended. In this small fixture, unexpected execution errors stop the investigation. In your own project, a particular exception might be the regression you are trying to detect, so define that outcome explicitly.
Keeping the checker outside the repository also keeps its question constant as Git switches revisions. The bisect manual's scripted-test example recommends this separation.
Create the history and enter it:
python3 create_bisect_lab.py
cd bisect-lab
git status --short --branch The status should show main without changed paths. The status reference explains that --short --branch combines a compact change listing with branch information.
Verify both endpoints before starting
First check the known-good tag:
git checkout --detach demo-good
python3 ../check_upload.py The checker should print:
GOOD: Below-limit, exact-limit and above-limit cases passed. Now return to main, whose tip is the known-bad revision in this generated repository:
git checkout main
python3 ../check_upload.py The expected result is:
BAD: size=100: expected True, got False The first invocation exits 0; the second exits 1. If you see a missing-file message, an import error or another unexpected result, fix that before starting. These two checks establish that the exact command you intend to automate can distinguish the endpoints.
A detached checkout while inspecting an old revision is normal. If that state is unfamiliar, Jitsumi's detached HEAD guide explains how it differs from being on a branch.
Find the regression manually
Start from main after the endpoint checks:
git bisect start demo-bad demo-good
python3 ../check_upload.py The bad endpoint comes first. Git checks out a candidate revision, and the second command evaluates it. Read the result before assigning a label:
| Checker result | Your next action |
|---|---|
|
| Run |
|
| Run |
|
| Run |
|
| Stop and investigate; do not assign a passing or failing label. |
After each label, Git may check out another revision. Run python3 ../check_upload.py again and classify that new result. Continue until Git reports the first bad commit.
In the verified example, both the manual and automated searches visited revision 08, then 12, then 10, then 09. The outcomes were good, bad, bad and good. Those four checks narrowed the boundary to revision 09 passing and revision 10 failing.
The figure shows the tested boundaries after each observation. The shaded interval is the remaining search interval, not a claim that every commit inside it was tested.
*Observed in the 16-commit lab: 08 passed, 12 failed, 10 failed and 09 passed. Each span joins a checked good boundary (open circle) to a checked bad boundary (square). Position shows commit order, not time.*
Revision numbers describe this fixture's order, not elapsed time. Four checks were enough for this history; a different graph, skipped commits or a different search range can require another sequence.
Inspect the reported commit before ending the session
One detail is easy to miss: the commit left checked out need not be the reported first bad commit. In the Git 2.51.1 run here, HEAD remained at revision 09, the last revision tested, while the result correctly identified revision 10.
Inspect the result through the hash Git printed, or use the result reference while the session still exists:
git show refs/bisect/bad
git diff refs/bisect/bad^ refs/bisect/bad -- upload.py For this linear history, the comparison shows the change that rejects the exact limit:
- return size <= limit
+ return size < limit git show displays the selected commit and its patch; git diff can compare two specified commits. Here, ^ selects the first parent. That parent notation is documented in the Git user manual ; merge commits need more careful parent selection.
Preserve the classification log, then leave the session:
git bisect log > ../bisect-session.txt
git bisect reset
git status --short --branch In the example, reset returned to the original clean main checkout. The log stays in the parent folder. Save the result before resetting, because the temporary bisect references are removed when the session ends.
Automate the same verified check
Once the manual session has ended, repeat the investigation from main:
git bisect start demo-bad demo-good
git bisect run python3 ../check_upload.py This time Git runs the checker after each checkout and classifies its exit status. The tested automated run found the same revision 10 in four invocations. Its reported commit hash will differ if your generated commits have different timestamps.
The documented exit-status contract is:
| Test process exit status | Bisect interpretation |
|---|---|
|
| Good |
|
| Bad |
|
| Skip this revision |
|
| Abort the run |
An unrelated tool error can fall inside the “bad” range. That is why a harness should return a deliberate verdict rather than blindly forwarding every failure.
After inspecting the automated result, save its log and reset as before:
git bisect log > ../bisect-automatic.txt
git bisect reset A broken test can produce a convincing wrong answer
To check this failure mode, the verification run deliberately supplied Python with a nonexistent script path in the disposable repository. Python exited 2 without executing the upload test.
Git treated those invocations as bad revisions. It reported revision 02 as the first bad commit and exited successfully, even though the intended bug was introduced at revision 10. The search had answered a different question: which candidate also produced the command error?
This observation is specific to the tested setup, but the practical lesson is broader: a successful bisect run does not establish that your intended test executed. Read the output on both endpoints. A working good endpoint is particularly valuable because it exposes a command that fails everywhere.
Command-not-found errors are a related case with an extra wrinkle. Although 126 and 127 fall within the manual's bad range, Git 2.51.1 detected the always-missing command in a separate trial, retried it on the known-good revision and rejected the bogus result. Do not rely on that check to catch other mistakes: it did not protect against the missing Python script returning 2.
For a real harness, decide what each failure means. A missing local runtime should stop the run. A historical revision that lacks a feature needed for this particular check might be untestable. A reproducible exception caused by the target bug should count as bad. These decisions belong to the symptom you are investigating.
When the result needs more investigation
Skipping can leave several possible commits
In another run of the fixture, revision 10 was explicitly marked as skipped. Git could narrow the failure to revision 10 or revision 11, but it could not choose between them. That is a useful boundary with an unresolved gap.
Use git bisect skip or exit 125 when a revision cannot fairly answer the chosen question. If a failed build is the regression you want to find, marking every build failure as skipped would discard exactly the evidence you need.
When skipping leaves several candidates, restore the missing test capability if practical, inspect the candidate changes, or report the uncertainty. Do not promote the first hash in an ambiguous list into a confirmed culprit.
Intermittent failures need a stable decision rule
Suppose the same revision passes once and fails on the next run. A single passing observation cannot reliably move the search boundary. Stabilise the inputs and environment, or define a repeated-test rule and recognise its uncertainty before proceeding.
Record the runtime, dependencies, configuration and test data that affect the symptom. A code checkout cannot isolate a change in a live API, database or randomly selected input. This is a consequence of asking the search to compare revisions under one consistent question.
The Git project's historical discussion of sporadic bugs explains why false passing classifications are a problem. It discusses possible approaches; it is not a promise that ordinary bisect makes an intermittent test reliable.
A fix followed by another regression is a different search
The fixture has one transition: every revision before 10 passes, and every revision from 10 onward fails. Real code can break, be fixed and break again.
Choose endpoints around the episode you want to understand. Otherwise, a binary classification can hide an earlier failing region when a later passing revision excludes its ancestors. Treat the returned boundary as evidence within the searched history and classifications.
Merges also change the question. The --first-parent option follows the integration line and can identify the merge that brought in a regression. Use that intentionally when you want the integration point; it answers a different question from finding the original change on the merged branch.
Apply the workflow to an existing project
Begin by checking and preserving your work. A separate checkout is useful when you want to investigate historical revisions while leaving current edits in place.
For example, if your current HEAD is a confirmed failing revision, create a detached worktree at an unused sibling path:
git worktree add --detach ../project-bisect HEAD
cd ../project-bisect The worktree manual describes separate working files and indexes with shared repository history. This gives you another checkout; it does not isolate databases, ports, credentials or services that the tested application uses. Prepare dependencies and appropriate test data before running historical code. Jitsumi's worktree guide covers the setup and eventual removal in more detail.
Adapt the lab's process to your actual revisions: run the same reproducer at both endpoints, start the search with the failing revision first, and keep the harness accessible throughout the checkouts. Avoid narrowing to one file too early if the failure might come from configuration or a dependency change elsewhere.
When bisect finishes, retain the reported commit, its classifications and the reproducer. Recheck that commit and the relevant parent in your test checkout, inspect the change, and decide what the fix needs to preserve. A commit can contain useful work alongside the regression, so the result alone is not a reason to revert it wholesale.
Finish the investigation by running git bisect reset. Then turn the reproducer into a permanent regression test alongside the fix. The lasting improvement is a check that catches the same boundary mistake before it reaches another release.
Sources and further reading
Documentation checked on 28 October 2026. The numbered history, checker and observations above are an original local demonstration.
- Git:
git-bisectreference - Pro Git: Debugging with Git
- Git: Fighting regressions with git bisect , the historical algorithm and testing discussion
- Git:
git-showreference - Git:
git-diffreference - Git:
git-statusreference - Git 2.28:
git-initreference - Git user manual: naming commits
- Git:
git-worktreereference