How to Find the Commit That Broke Your Code With git bisect
Learn how git bisect uses binary search to pinpoint the exact commit that introduced a bug, both by hand and fully automated with git bisect run.
You deploy on Friday. Nobody notices anything wrong. Three weeks and forty commits later, a support ticket says a customer was charged the wrong total. You open the pricing code, and it looks fine. You look at the last commit, and it looks fine too. Somewhere in those forty commits is the one that quietly broke the math, and checking each one by hand would take all afternoon.
Table Of Content
- What You Will Learn
- Prerequisites
- How git bisect Works Under the Hood
- Step 1: Build a Small Project With a Real Regression
- Step 2: Find a Known-Good Commit to Bisect From
- Step 3: Walk Through a Manual Bisect
- Start the session
- Test, mark, repeat
- Read the verdict
- Step 4: Automate It With git bisect run
- Step 5: Handle Commits You Cannot Test
- Common Mistakes and Gotchas
- Starting bisect with uncommitted changes
- Flaky or environment-dependent tests
- Bugs that are not monotonic
- Forgetting git bisect reset
- Merge-heavy histories
- How to Confirm It All Worked
- Next Steps
This is exactly the problem git bisect was built to solve. It is a Git command that finds the exact commit that introduced a bug by testing a handful of commits and narrowing the search each time, the same way you would look up a word in a paper dictionary by opening to the middle and deciding which half to check next. In this tutorial you will build a small project with a real, reproducible bug, then use git bisect to hunt it down by hand and again fully automated, so you understand both what is happening and how to make Git do it for you.
What You Will Learn
- What
git bisectdoes and why it is faster than checking commits one at a time - How to run a manual bisect session:
start,bad,good, and reading the result - How to automate the whole search with
git bisect runand a test command - How to handle a commit that cannot be tested at all, with
git bisect skip - The mistakes that make bisect give you a wrong or unhelpful answer
Prerequisites
- Git installed and runnable from a terminal. This tutorial was written and tested against Git 2.55.0 on Windows, but every command here has worked the same way since Git 1.x and will behave identically on macOS or Linux.
- Basic familiarity with everyday Git commands:
commit,log,checkout. If you are still getting comfortable with Git itself, our guide to connecting Git to GitHub and opening your first pull request covers the fundamentals first. - Python 3 with
pip, only to run the demo project’s test suite. The technique itself has nothing to do with Python:git bisectworks with any language, as long as you have some command that can tell you “this revision is fine” or “this revision is broken.” This walkthrough uses pytest 9.1.1, but a shell script,make test, or a singlecurlcall that checks a response would work exactly the same way.
How git bisect Works Under the Hood
A few terms first, since the rest of this tutorial leans on them. A commit is one saved snapshot of your project’s history. HEAD is Git’s name for “the commit you currently have checked out.” A regression is a bug that was not there before and got introduced by some change, as opposed to a bug that has always existed. Your working tree is the actual files on disk right now, which normally matches whatever commit is checked out.
Every command and exit-code rule in this tutorial was checked directly against Git’s own git-bisect documentation, and then personally run to confirm it behaves the way the docs describe.
git bisect needs two reference points to start: one commit you know is good (the bug was not there yet) and one you know is bad (the bug is present, often just your current HEAD). Git then checks out the commit exactly halfway between those two in the history, and asks you a single question: is this one good or bad? Based on your answer, it throws away the half of the range that can no longer contain the first bad commit, and checks out the new midpoint of what is left. Each answer cuts the remaining suspects roughly in half, which is why a history of, say, 40 unknown commits takes about six tests to fully resolve instead of 40. That halving is also where the name comes from: to bisect literally means to cut something into two parts.
Step 1: Build a Small Project With a Real Regression
To see bisect do real work, you need real history to search through: a project with a working version, a bug quietly introduced partway along, and a few unrelated commits layered on top afterward, the way a real codebase actually looks. Create a fresh folder and turn it into a Git repository.
mkdir pricing-demo && cd pricing-demo
git init -b main
git config user.email "[email protected]"
git config user.name "Your Name"
Add a first, correct version of a small pricing function. It takes a base price, a discount percentage, and a tax percentage, and returns the final total: discount first, then tax on whatever is left.
def calculate_price(base_price, discount_percent, tax_percent):
discount_amount = base_price * (discount_percent / 100)
price_after_discount = base_price - discount_amount
tax_amount = price_after_discount * (tax_percent / 100)
total = price_after_discount + tax_amount
return round(total, 2)
Save that as pricing.py, add a small pytest suite in test_pricing.py alongside it:
from pricing import calculate_price
def test_basic_discount_and_tax():
# $100 item, 20% off, then 8% tax on the discounted price.
assert calculate_price(100, 20, 8) == 86.40
def test_no_discount_full_tax():
# With no discount, tax applies to the full price.
assert calculate_price(100, 0, 10) == 110.0
def test_small_order():
assert calculate_price(50, 10, 5) == 47.25
Commit both files, then create a virtual environment and install pytest so you can actually run the suite:
git add pricing.py test_pricing.py
git commit -m "Initial pricing calculator with discount and tax"
python -m venv venv
venv/bin/pip install pytest # on Windows: venv\Scripts\pip install pytest
venv/bin/python -m pytest -q
... [100%]
3 passed in 0.02s
While you are at it, tell Git to ignore the virtual environment folder you just created, as its own small commit:
echo "venv/" > .gitignore
git add .gitignore
git commit -m "Add .gitignore for local test environment"
Good, a clean baseline. Now grow the project the way real projects grow: a genuine feature, then another, and then one commit that looks like harmless cleanup but quietly changes the math. Add a member discount tier:
def calculate_price(base_price, discount_percent, tax_percent, is_member=False):
if is_member:
discount_percent += 5
discount_amount = base_price * (discount_percent / 100)
price_after_discount = base_price - discount_amount
tax_amount = price_after_discount * (tax_percent / 100)
total = price_after_discount + tax_amount
return round(total, 2)
Add a matching test, then commit:
def test_member_discount():
# Members get an extra 5 points off the discount percentage.
assert calculate_price(100, 20, 8, is_member=True) == 81.0
git add -A && git commit -m "Add member discount tier support"
Add input validation as its own, separate commit, which is also the last commit where everything still works correctly:
def calculate_price(base_price, discount_percent, tax_percent, is_member=False):
if base_price < 0:
raise ValueError("base_price cannot be negative")
if is_member:
discount_percent += 5
discount_amount = base_price * (discount_percent / 100)
price_after_discount = base_price - discount_amount
tax_amount = price_after_discount * (tax_percent / 100)
total = price_after_discount + tax_amount
return round(total, 2)
import pytest
def test_negative_price_raises():
with pytest.raises(ValueError):
calculate_price(-10, 0, 0)
git add -A && git commit -m "Add input validation for negative prices"
venv/bin/python -m pytest -q
..... [100%]
5 passed in 0.02s
Now make the change that introduces the bug. It is deliberately framed as a tidy-up, because that is how regressions actually sneak in: nobody commits a message that says “break the math.”
def calculate_price(base_price, discount_percent, tax_percent, is_member=False):
if base_price < 0:
raise ValueError("base_price cannot be negative")
if is_member:
discount_percent += 5
discount_amount = base_price * (discount_percent / 100)
price_after_discount = base_price - discount_amount
tax_rate = tax_percent / 100
tax_amount = base_price * tax_rate
total = price_after_discount + tax_amount
return round(total, 2)
Look closely and you will spot it: tax is now calculated on base_price, the original price, instead of price_after_discount, the discounted price. Every discounted order now gets overcharged on tax. Commit it:
git add -A && git commit -m "Refactor discount and tax calculation for clarity"
venv/bin/python -m pytest -q
FAILED test_pricing.py::test_basic_discount_and_tax - assert 88.0 == 86.4
FAILED test_pricing.py::test_small_order - assert 47.5 == 47.25
FAILED test_pricing.py::test_member_discount - assert 83.0 == 81.0
3 failed, 2 passed in 0.03s
Notice that test_no_discount_full_tax still passes. When the discount is 0, price_after_discount equals base_price, so the bug produces the same number as the correct code by coincidence. That is a useful, realistic detail: a test suite only catches a regression if at least one test actually exercises the broken code path with inputs that expose it. A weaker suite with only that one test would have shipped this bug silently.
Finally, pile on a few more commits the way a real week of work would. In the repository used to test this tutorial, each of these five actually added the small, working feature its message describes (a logging call, a helper that totals several orders at once, a currency formatter, a currency-symbol lookup added to it, and a receipt-printing helper), and none of them ever come near the tax calculation again. If you just want to reproduce the shape of the history without retyping five small features, empty commits work exactly as well for bisect’s purposes, since bisect only cares about which commit flips the test suite from passing to failing:
git commit --allow-empty -m "Add debug logging to pricing calculations"
git commit --allow-empty -m "Add bulk order pricing helper"
git commit --allow-empty -m "Add currency formatting helper"
git commit --allow-empty -m "Add support for multiple currency symbols in formatting"
git commit --allow-empty -m "Add receipt printing helper"
Either way, the point is the same: none of them touch the buggy line. Your history now looks like this:
git log --oneline --reverse
82bd563 Initial pricing calculator with discount and tax
a3eb00e Add .gitignore for local test environment
9f6447a Add member discount tier support
d23be54 Add input validation for negative prices
1a16a53 Refactor discount and tax calculation for clarity
e80281a Add debug logging to pricing calculations
5a090c5 Add bulk order pricing helper
bdee3ea Add currency formatting helper
a9d7ae3 Add support for multiple currency symbols in formatting
351a996 Add receipt printing helper
Ten commits, and only one of them is the problem. Confirm the bug is still there at the tip of the branch:
venv/bin/python -m pytest -q
3 failed, 2 passed in 0.03s
Step 2: Find a Known-Good Commit to Bisect From
Bisect needs a starting boundary on each side. The bad end is easy: HEAD, since you just confirmed the bug is present there. For the good end, you need any commit you are confident predates the bug. It does not need to be the very first commit; any confirmed-good point works, and starting further back just costs one or two extra test cycles. Here, the very first commit is a safe, obvious choice since you know it only had the original, correct implementation.
Step 3: Walk Through a Manual Bisect
Start the session
git bisect start
git bisect bad HEAD
git bisect good 82bd563
status: waiting for both 'good' and 'bad' commits
status: waiting for 'good' commit(s), 'bad' commit known
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[1a16a535f39d853c5f922b5eb9522bdb92420194] Refactor discount and tax calculation for clarity
Git already checked out a candidate commit for you (in this run it happened to land right on the actual culprit, though normally it takes a couple of steps to get there) and told you roughly how many tests are left. Note the “4 revisions left… roughly 2 steps” line: that estimate is the point of a binary search. Testing 8 unknown commits one at a time would take up to 8 tries; bisect expects to resolve it in about 3.
Test, mark, repeat
At each checkout, run your test command and tell bisect what you found.
venv/bin/python -m pytest -q
3 failed, 2 passed in 0.03s
git bisect bad
Bisecting: 1 revision left to test after this (roughly 1 step)
[9f6447a4f2463e19d38dace4e9871e1aad9675cb] Add member discount tier support
venv/bin/python -m pytest -q
4 passed in 0.02s
git bisect good
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[d23be5442b25936347414243943f50b6e697a87e] Add input validation for negative prices
venv/bin/python -m pytest -q
5 passed in 0.01s
git bisect good
Read the verdict
1a16a535f39d853c5f922b5eb9522bdb92420194 is the first 'bad' commit
commit 1a16a535f39d853c5f922b5eb9522bdb92420194
Author: Your Name <[email protected]>
Date: Tue Aug 18 15:51:37 2026 -0400
Refactor discount and tax calculation for clarity
pricing.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
Three real test runs found the exact commit, out of ten. Git also keeps a record of the whole session, which is worth looking at once, since it is the same information the automated version below will produce on its own. Git prints this with full 40-character hashes rather than the short ones used elsewhere in this tutorial:
git bisect log
git bisect start
# status: waiting for both 'good' and 'bad' commits
# bad: [351a99669d7452f712b42ee89abdca4bc167d3ad] Add receipt printing helper
git bisect bad 351a99669d7452f712b42ee89abdca4bc167d3ad
# status: waiting for 'good' commit(s), 'bad' commit known
# good: [82bd5632cf82ddcbaad63a31800048cb4a658a89] Initial pricing calculator with discount and tax
git bisect good 82bd5632cf82ddcbaad63a31800048cb4a658a89
# bad: [1a16a535f39d853c5f922b5eb9522bdb92420194] Refactor discount and tax calculation for clarity
git bisect bad 1a16a535f39d853c5f922b5eb9522bdb92420194
# good: [9f6447a4f2463e19d38dace4e9871e1aad9675cb] Add member discount tier support
git bisect good 9f6447a4f2463e19d38dace4e9871e1aad9675cb
# good: [d23be5442b25936347414243943f50b6e697a87e] Add input validation for negative prices
git bisect good d23be5442b25936347414243943f50b6e697a87e
# first 'bad' commit: [1a16a535f39d853c5f922b5eb9522bdb92420194] Refactor discount and tax calculation for clarity
If you ever make a mistake mid-session, such as typing good when you meant bad, save this log to a file, edit out the wrong line, run git bisect reset, then git bisect replay <file> to restore a corrected session instead of starting over.
Confirm the diagnosis by looking at the actual change. The full command prints the commit message and a standard unified diff; the lines that matter are the ones changing tax_amount:
git show 1a16a53 -- pricing.py
- tax_amount = price_after_discount * (tax_percent / 100)
+ tax_rate = tax_percent / 100
+ tax_amount = base_price * tax_rate
That is the bug, confirmed. When you are done, always run this to return to your normal branch and clean up bisect’s internal state:
git bisect reset
Previous HEAD position was d23be54 Add input validation for negative prices
Switched to branch 'main'
Until you run reset, you are sitting on a detached HEAD at whatever commit bisect last checked out, not on your branch. It is easy to forget this, commit new work from there by accident, and then wonder where it went.
Step 4: Automate It With git bisect run
Typing good or bad by hand is fine for a handful of steps, but if you already have a command that exits with a clear pass or fail, you can hand the entire loop to Git. git bisect run <command> checks out each candidate, runs your command, and reads its exit code using a specific contract straight from Git’s own documentation: exit 0 means good, any exit code from 1 to 127 except 125 means bad, exit 125 means “skip this one, it cannot be tested” (covered in the next section), and anything else aborts the whole search. pytest already exits 0 when every test passes and 1 when any test fails, which is exactly what bisect expects, so no wrapper script is needed at all:
git bisect start HEAD 82bd563 --
git bisect run venv/bin/python -m pytest -q
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[1a16a535f39d853c5f922b5eb9522bdb92420194] Refactor discount and tax calculation for clarity
running 'venv/bin/python' '-m' 'pytest' '-q'
3 failed, 2 passed in 0.04s
Bisecting: 1 revision left to test after this (roughly 1 step)
[9f6447a4f2463e19d38dace4e9871e1aad9675cb] Add member discount tier support
running 'venv/bin/python' '-m' 'pytest' '-q'
4 passed in 0.02s
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[d23be5442b25936347414243943f50b6e697a87e] Add input validation for negative prices
running 'venv/bin/python' '-m' 'pytest' '-q'
5 passed in 0.02s
1a16a535f39d853c5f922b5eb9522bdb92420194 is the first 'bad' commit
bisect found first 'bad' commit
Same three tests, same answer, zero manual judgment calls. This is the version worth wiring into a debugging habit: whenever you find a regression with an unknown start date and a test that reproduces it, git bisect run is almost always faster than reasoning about the history by eye. Just like the manual version, you still need to run git bisect reset afterward, since bisect run leaves you on the detached commit it found rather than returning you to your branch automatically.
Step 5: Handle Commits You Cannot Test
Sometimes a commit in your search range simply will not run. Maybe it has a genuine syntax error from a half-finished change, or it depends on a library version you do not have installed. Marking a broken build as either “good” or “bad” would poison the search with a false answer, so Git gives you a third option: git bisect skip, or exit code 125 in an automated run.
To see what that looks like for real, this is a case where the untestable commit sits immediately next to the actual regression, which is worth seeing because it is a genuine limitation, not just a footnote. A wrapper script for git bisect run is needed here, since pytest’s own exit codes cover “tests passed” (0) and “tests failed” (1), but not “the code would not even import.” pytest actually reports that case as exit code 2:
#!/bin/sh
# git bisect run calls this once per commit. pytest already exits 0 for
# good and 1 for bad, which bisect understands natively -- this only
# translates pytest's "collection error" exit code (2) into bisect's
# skip code (125) so a commit that can't even be imported doesn't get
# misread as a confirmed "bad" result.
venv/bin/python -m pytest -q
code=$?
if [ "$code" -eq 2 ]; then
exit 125
fi
exit "$code"
Save that as bisect_test.sh. With a broken, unparseable commit sitting directly before the real regression in the history, running the search through this wrapper produces:
git bisect start HEAD <last-good-commit> --
git bisect run sh bisect_test.sh
Bisecting: 0 revisions left to test after this (roughly 1 step)
[817790016bf2c0b4a9c4fbf81af2da0203d88197] Refactor discount and tax calculation for clarity
running 'sh' 'bisect_test.sh'
3 failed, 2 passed in 0.03s
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[4370da9b93dc4997ad7389434cd876819d748a11] Start streaming discount preview (WIP)
running 'sh' 'bisect_test.sh'
1 error in 0.08s
There are only 'skip'ped commits left to test.
The first 'bad' commit could be any of:
4370da9b93dc4997ad7389434cd876819d748a11
817790016bf2c0b4a9c4fbf81af2da0203d88197
We cannot bisect more!
That is the honest limit of skip: when the untestable commit happens to be immediately adjacent to the true first-bad commit, bisect cannot squeeze a test in between them, so it can only tell you “it is one of these two, go look at the diff yourself.” In this case that is a two-line comparison and an easy call, but it will not always be that small. This is a good reason to keep an untestable window as short as possible, for example by fixing an obviously broken build in its own follow-up commit rather than leaving it broken for a long stretch of history.
Common Mistakes and Gotchas
Starting bisect with uncommitted changes
Git needs to move your working tree between commits at every step, so it refuses to start if you have uncommitted edits that would be overwritten. This was verified directly:
git bisect start HEAD 82bd563 --
Bisecting: 4 revisions left to test after this (roughly 2 steps)
error: Your local changes to the following files would be overwritten by checkout:
pricing.py
Please commit your changes or stash them before you switch branches.
Aborting
Commit or git stash your changes first, then start the bisect.
Flaky or environment-dependent tests
Bisect trusts your answer completely. A test that occasionally fails for unrelated reasons, network calls, timing, shared state between test runs, will occasionally hand bisect a wrong verdict, and a wrong verdict at any step silently sends the whole search down the wrong half of the history with no warning. Before trusting a bisect result on a suspicious test, run that specific test two or three times in a row to make sure it is deterministic.
Bugs that are not monotonic
Binary search assumes the property you are testing changes exactly once across the range: good, good, good, then bad, bad, bad, forever. If a bug was introduced, accidentally fixed by an unrelated later change, and then reintroduced, that assumption breaks, and bisect can converge on the wrong commit without any error message telling you so. If a bisect result looks implausible, check whether the same symptom disappears and reappears elsewhere in the history before trusting it.
Forgetting git bisect reset
Both the manual and automated workflows leave you on a detached HEAD once they finish; neither returns you to your branch on its own. Get in the habit of running git bisect reset as soon as you have noted the answer, the same way you would close a file after reading it.
Merge-heavy histories
If your project merges feature branches instead of using a single line of commits, a bug can technically first appear “on” a merge commit even though every individual commit that went into it was fine in isolation, because the combination of two branches is what broke. Add --first-parent when starting a bisect (git bisect start --first-parent) to stay on the mainline and treat each merge as one unit, which avoids bisect wandering into a side branch that was never intended to be tested on its own.
How to Confirm It All Worked
- The bisect finished with a single, specific commit hash, not an ambiguous range (an ambiguous range means something was skipped or a test answer was inconsistent).
git show <commit>on that hash shows a change that plausibly explains the symptom you were chasing, the way the tax calculation line did here.- You ran
git bisect resetandgit statusshows you back on your original branch with no leftover bisect state. - Writing a regression test for the specific bug (if one did not already exist) and confirming it fails on the identified commit and passes on the one before it is the strongest possible confirmation.
Next Steps
Once this feels natural, a few directions worth exploring: wire git bisect run into a short investigation script that your team can rerun the next time a similar regression shows up, so nobody has to remember the flags. If your test suite takes too long to run at every single commit, look at git bisect skip combined with narrower, faster targeted checks instead of the full suite. And once bisect has named a commit, git blame and git log -p <file> are natural next tools for understanding why that change was made in the first place, not just what it changed.








No Comment! Be the first one.