Engineering

Debugging Like a Detective: Five Steps to Find a Bug Before You Ask AI

Reproduce, shrink, form a hypothesis, halve the search with git bisect, then verify: five steps you can do yourself, and how to hand AI a good bug report afterwards. With a real 20-commit practice repo, an answer key and a bug report template.

  • #debugging
  • #git bisect
  • #AI agent
  • #exercise
  • #checklist

Slide deck

Click the slides, then use the arrow keys to move. Press L to change language and F for full screen.

Open full screen

For anyone who writes or reads code, including beginners. No advanced Git or prior debugging knowledge needed. Every term comes with an everyday picture, and the calculation parts are marked so you can skip them.

Every command and output in this post comes from a real run on a practice repo (Git 2.56.0, Python 3.14.8), and there is a script so you can recreate it. Commit hashes will differ on your machine because they depend on time. There is no single agreed theory of how skilled people debug, so the five steps below are a synthesis of practice, not a procedure tested in controlled studies. Examples labeled "illustration" are reconstructed and name no real person or company.


Quick summary#

  • Debugging is its own skill, and it does not go away because AI exists. If you ask AI before you know where the bug is, you will not have grounds to judge its answer.
  • Five steps: reproduce the bug reliably, shrink the failing case, form one hypothesis that could be wrong, halve the suspect range, then fix and add a test so the bug cannot return.
  • The halving step has a built-in Git tool, git bisect. In this post's real example, 19 suspect commits needed 4 checks to find the culprit, versus up to 19 if you walk through them one by one.
  • The culprit in the example is a commit named "refactor: simplify discount check", a change that looks harmless. The bug is one character: >= became >.
  • After the five steps you hand AI a short bug report (template at the end). Then AI helps faster and you can check its explanation.
  • Limits: this needs a test that gives the same answer every time. Bugs that depend on timing, data or environment are much harder.

Hard words, plain meaning#

Term Plain meaning
Bug A place where the program does something other than what we expect.
Reproduce Make the bug happen on purpose, the same way every time, like recreating the scene of a crime.
Minimal reproduction The smallest example in which the bug still happens. Remove anything more and it disappears.
Hypothesis A guess about the cause that could be proven wrong, like "if the culprit came through the back door there will be mud on the kitchen floor".
Bisect Each test removes half of the suspect range, like guessing a number from 1 to 100 by asking "is it above 50?".
Regression Something that used to work and broke because of a later change.
Test A small piece of code that runs and says "pass" or "fail", no reader needed.
Exit code The number a command returns when it finishes: 0 means fine, anything else means a problem. Git uses it to know which commit is good.

After this post you can explain#

  1. Why a bug needs to be reproduced and shrunk before you fix it, and what a hypothesis that "can be wrong" looks like.
  2. How git bisect finds the bad commit by halving, and why 19 commits need only about 5 checks.
  3. What a good bug report for AI contains, and why to send it after you have narrowed the range yourself.

1. A picture first: the detective at the scene#

Picture a detective arriving at a scene. They do not rush to arrest the first suspicious person. They leave the scene as it is, write down what happened, drop details that do not matter so the core shows, then form a specific hypothesis: "if the culprit came through the back door there will be mud on the kitchen floor". They go and check exactly that. If it is wrong, they drop the hypothesis and try another. At the end they have evidence others can check.

Debugging is the same. Each of the five steps matches something the detective does:

Detective Debugging
Keeps the scene untouched Reproduce the bug reliably
Drops irrelevant details Shrink the failing case
Forms a checkable hypothesis Form one hypothesis and predict the result
Rules out suspects Halve the suspect range
Files a report so the case does not reopen Fix, add a test, write it down

Takeaway: a good detective is not the one who guesses right first, but the one with a way to rule out wrong possibilities one by one.


2. Why still learn this when AI exists#

2.1 A survey suggests the skill is still valued#

BairesDev's Dev Barometer Q2 2026 (1,569 developers in 77 countries, 1,059 juniors and 510 seniors, published June 2026) reports that 48% of junior developers rank problem-solving and analytical thinking as the most important skill for getting hired, almost three times the 18% who name proficiency with AI tools. The same survey found that only 16% of senior developers say juniors fully understand the AI-generated code they submit.

Three cautions. First, BairesDev is a software outsourcing company with a commercial interest in the topic. Second, the original page refused access when I opened it, so I read the numbers through search results quoting their press release and data deck, not from the original tables. Third, the 16% is seniors' opinion of juniors, not a measurement of real understanding. Read it as a signal, not hard evidence.

2.2 Why asking AI first can cost you your judgment#

Say you paste the whole failing file to AI and get a fix back. If you do not know where the bug is, you have two choices: trust it or not. A Stanford study (Perry et al., 2022, 47 participants) found that the group using an AI assistant wrote less secure code on four of five tasks but was more confident that its code was secure. It used older models and a small group, but it hints at a risk worth noting: feeling sure does not go with being right.

Takeaway: using AI is fine. But narrow the suspect range yourself first, so the AI's answer has something to be checked against.


3. Five steps, with a real example#

The example: a small shopping cart program. Rule: orders of 100 or more get 10% off. The test reports the failure: an order totaling exactly 100 gets no discount. The repo has 20 commits and exactly one breaks the rule. The script that creates it is in section 5.

Step 1. Reproduce: one command, the same result#

Picture: keep the scene untouched.

The first job is not fixing but having one command that makes the bug happen every time:

python3 test_cart.py
echo "exit=$?"
FAIL total([(50, 2)]) = 100.0, want 90.0
exit=1

These two lines are the scene: an order of 2 items at 50 (total 100) should come out as 90.0 but the program gives 100.0. The exit code is 1, meaning "fail". If two runs give two different results, you have not reproduced it yet, and that comes first (see the limits section).

Step 2. Shrink: remove what does not matter#

Picture: send people out of the room until only those involved remain.

Remove parts of the failing case one at a time and ask: is the bug still there? This is the result of a real run, from a big cart down to the smallest case:

Case Data Total before discount Result Still wrong?
3 line items [(25, 2), (25, 2), (0, 5)] 100 100.0 yes
Drop the price 0 line [(25, 2), (25, 2)] 100 100.0 yes
Merge into one line [(50, 2)] 100 100.0 yes
One item [(100, 1)] 100 100.0 yes

The number of lines, items and prices do not matter. What remains is: the total before discount is exactly 100. The minimal failing case is now one line: total([(100, 1)]).

Step 3. Form one hypothesis that can be wrong, and predict the result first#

Picture: "if the culprit came through the back door there will be mud on the kitchen floor".

From step 2, the hypothesis: the discount function handles the boundary at exactly 100 wrongly. A good hypothesis lets you predict. If it is right: below 100 no discount, exactly 100 should get a discount but does not, above 100 gets a discount. Only then run it:

discount(99.99)   # 0.0
discount(100)     # 0.0 (should have been 10.0)
discount(100.01)  # 10.0

As predicted: the discount starts after 100, not at 100. The hypothesis holds, and you now know you are looking for a "greater than" that should be "greater than or equal". A hypothesis that fits every outcome is useless. If the result here had been discount(100) = 10.0, you would have to drop this hypothesis.

You may already see the fix. But in a real project that function may have been touched by many people, and the question "which change broke it" matters as much as "where is it broken". That is step 4.

Step 4. Halve: remove half of the suspects each time#

Picture: guess a number from 1 to 100. You do not ask "is it 1, is it 2", you ask "is it above 50?". Each question removes half.

The history has 20 commits. The first works and the latest is bad. That leaves 19 suspect commits. git bisect picks the one in the middle for you to test, then removes half. This is the real session:

git bisect start HEAD $(git rev-list --max-parents=0 HEAD)
status: waiting for both 'good' and 'bad' commits
status: waiting for 'good' commit(s), 'bad' commit known
Bisecting: 9 revisions left to test after this (roughly 3 steps)
[5f01574] feat: add free shipping limit

Git picked the middle commit and moved you there. You run the test and mark git bisect good or git bisect bad. With git bisect run, Git runs the test at every step and decides from the exit code (0 is good, 1 to 127 except 125 is bad, per the Git docs):

git bisect run python3 test_cart.py
running 'python3' 'test_cart.py'
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[f1da7cd] feat: add min order note
running 'python3' 'test_cart.py'
FAIL total([(50, 2)]) = 100.0, want 90.0
Bisecting: 2 revisions left to test after this (roughly 1 step)
[2004cf0] feat: add coupon placeholder
running 'python3' 'test_cart.py'
FAIL total([(50, 2)]) = 100.0, want 90.0
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[6b693ef] refactor: simplify discount check
running 'python3' 'test_cart.py'
FAIL total([(50, 2)]) = 100.0, want 90.0
6b693ef is the first 'bad' commit
commit 6b693ef

    refactor: simplify discount check

 cart.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
bisect found first 'bad' commit

This session made 4 checks: the middle commit (pass), then three commits that all fail, until one commit was left. The culprit is refactor: simplify discount check. Look at its change:

git diff 6b693ef~1 6b693ef
-    if total >= 100:
+    if total > 100:

One character. The commit name says "tidy up", but it changed behavior. This is where a commit's name and its real content can disagree, and why you should find the commit with a test rather than by reading names.

Do not forget to end the session with git bisect reset so Git takes you back to your original branch.

Halving works outside Git too#

The "remove half" idea works everywhere: temporarily switch off half the lines of a long function and see if the bug remains, print a value in the middle of the flow to learn whether things break before or after that point, or turn off half of the feature flags. Each halving gets you much closer than staring at every line.

Step 5. Fix, recheck, and stop it coming back#

Picture: file a report so the case does not reopen.

Fix one character (> back to >=), then run the test:

python3 test_cart.py
echo "exit=$?"
exit=0

Pass. You also check the neighboring boundaries, and the real results are:

total([(99.99, 1)])   # 99.99  (no discount)
total([(100, 1)])     # 90.0   (10% off)
total([(100.01, 1)])  # 90.01  (10% off)

The test already existed in the repo (it checks an order of exactly 100), so this bug will be caught if someone breaks it again. If it had not, adding the test is this step: every bug should leave a test behind for itself. Better ways to write tests are in the post Testing AI-written code.

Takeaway: the five steps take you from "the program is wrong" to "one character in one specific commit", with evidence at every step. The rest is just typing the fix.


4. A calculation to check yourself: how fast halving is (optional)#

With N suspect commits, halving needs at most about log2(N) checks, rounded up. This is a real calculation:

import math
for n in (19, 99, 999, 9999):
    print(n, math.ceil(math.log2(n)), round(math.log2(n), 2))
Suspect commits Max checks with halving log2
19 (this post's practice repo) 5 4.25
99 7 6.63
999 10 9.96
9,999 14 13.29

In the real run Git needed 4, not 5. The 5 is a maximum, the real number depends on where the bug is. If you walked from the oldest commit one by one, you would need 10 checks to reach commit 11 (the culprit is the 11th commit), and up to 19. Note that these numbers assume each check gives a trustworthy answer and every commit runs. If some commits cannot be built, Git lets you skip them with code 125, but you will need extra checks.


5. Exercise: do it yourself, with an answer key#

Save this script as make_repo.sh and run bash make_repo.sh. It creates a cart-demo folder with 20 commits, exactly one of which breaks the discount rule.

#!/usr/bin/env bash
# Create a practice repo with 20 commits, exactly one of which breaks the discount rule.
set -e
rm -rf cart-demo
mkdir cart-demo
cd cart-demo
git init -q -b main
git config user.name "debug-demo"
git config user.email "demo@example.com"

c() { cat >> "$2"; git add -A; git commit -q -m "$1"; }   # c "message" file < content

cat > test_cart.py <<'EOF'
import sys
from cart import total

checks = [
    ([(10, 3)], 30.0),     # under 100: no discount
    ([(50, 2)], 90.0),     # exactly 100: 10% off
    ([(60, 2)], 108.0),    # over 100: 10% off
]
bad = [(i, want, total(i)) for i, want in checks if total(i) != want]
for i, want, got in bad:
    print(f"FAIL total({i}) = {got}, want {want}")
sys.exit(1 if bad else 0)
EOF
c "feat: add subtotal, discount, total and a check" cart.py <<'EOF'
def subtotal(items):
    return sum(price * qty for price, qty in items)

def discount(total):
    if total >= 100:
        return round(total * 0.10, 2)
    return 0.0

def total(items):
    s = subtotal(items)
    return round(s - discount(s), 2)
EOF
c "feat: add shipping fee" cart.py <<'EOF'

SHIPPING = 5.0
EOF
c "docs: explain discount rule" cart.py <<'EOF'

# 10% off for orders of 100 or more.
EOF
c "feat: add currency format" cart.py <<'EOF'

def money(x):
    return f"{x:,.2f}"
EOF
c "feat: add empty cart helper" cart.py <<'EOF'

def is_empty(items):
    return len(items) == 0
EOF
c "feat: add item count" cart.py <<'EOF'

def count(items):
    return sum(qty for _, qty in items)
EOF
c "feat: add max quantity" cart.py <<'EOF'

MAX_QTY = 99
EOF
c "feat: add tax rate constant" cart.py <<'EOF'

TAX_RATE = 0.08
EOF
c "feat: add receipt title" cart.py <<'EOF'

def title(name):
    return f"Receipt for {name}"
EOF
c "feat: add free shipping limit" cart.py <<'EOF'

FREE_SHIPPING_FROM = 200
EOF
perl -pi -e 's/total >= 100/total > 100/' cart.py
git add -A; git commit -q -m "refactor: simplify discount check"
c "feat: add coupon placeholder" cart.py <<'EOF'

COUPONS = {}
EOF
c "feat: add rounding helper" cart.py <<'EOF'

def r2(x):
    return round(x, 2)
EOF
c "feat: add item label" cart.py <<'EOF'

def label(price, qty):
    return f"{qty} x {price}"
EOF
c "feat: add min order note" cart.py <<'EOF'

MIN_ORDER = 1
EOF
c "feat: add currency code" cart.py <<'EOF'

CURRENCY = "USD"
EOF
c "feat: add store name" cart.py <<'EOF'

STORE = "Demo Shop"
EOF
c "feat: add version string" cart.py <<'EOF'

VERSION = "1.0"
EOF
c "feat: add free item note" cart.py <<'EOF'

FREE_ITEM_NOTE = "none"
EOF
c "chore: add end marker" cart.py <<'EOF'

# end of file
EOF

Try it before reading the answer:

  1. Go into cart-demo, run python3 test_cart.py and write down the result (step 1).
  2. Shrink the failing case by hand, trying a few other values (step 2).
  3. Write your hypothesis as one sentence, with the results you predict for 99.99, 100 and 100.01 (step 3).
  4. Run git bisect by hand (mark each commit) once, then again with git bisect run python3 test_cart.py. Count the checks (step 4).
  5. Fix it and rerun the test (step 5).

Answer key:

  • The bad commit is named refactor: simplify discount check, the 11th of the 20 commits.
  • Its change: >= became > in the discount function of cart.py.
  • Number of checks: at most 5, and 4 in my run. Yours may be 4 or 5, with different hashes, and that is normal.
  • One trap: the test must exist in every commit you check. Here test_cart.py exists from the first commit, so it is safe. If the test were only added in a middle commit, older commits would report "bad" because the file is missing, and Git would conclude wrongly. To avoid it, keep the test script outside the repo (the Git docs also advise keeping the test script out of the repo) or pick a good commit after the test appeared.

6. A short bug report for AI: do it after the five steps#

After the five steps you have what you need to write a bug report that AI can use and you can check. A template to paste:

Bug: <one sentence>
Reproduction (smallest): <command or data>
Expected: <correct result>
Actual: <verbatim output>
Ruled out: <hypotheses tried and the results>
Suspect: <current hypothesis, with evidence>
Scope: <files, suspect commit>
Request: explain the cause before fixing,
propose the smallest change, and say what you did not check.

For this post's example the filled-in report looks like this:

Bug: an order totaling exactly 100 does not get the 10% discount.
Reproduction (smallest): total([(100, 1)])
Expected: 90.0
Actual: 100.0
Ruled out: number of lines and the price of each item do not matter.
discount(99.99) = 0.0, discount(100) = 0.0, discount(100.01) = 10.0.
Suspect: the comparison at the boundary 100 in discount().
Scope: git bisect points to the commit "refactor: simplify discount check".
Request: explain the cause before fixing and propose the smallest change.

With this report the right answer is almost too obvious: AI will point at the comparison at the boundary. More important: you can check the answer with the same test and boundary values you already have. If AI proposes a fix somewhere else, you know right away it is off. For making AI hand in evidence for its fix, see the post Make AI Output Prove Itself.


7. Where beginners trip#

Common mistake Why it fails How to avoid it
Changing many things at once The bug goes away but you do not know which change worked, and you may add new bugs Change one thing per round, rerun the test
Trusting the first hypothesis The first hypothesis is often the one you want to believe Predict the result first, and be ready to drop the hypothesis when it differs
Not saving the failing case Tomorrow you cannot reproduce it, and neither can anyone else Write down the command and data, add a test
Fixing the symptom, not the cause Adding a special case for the failing input (for example, if you see 100 just return 90) Keep asking "why" until you reach the cause
Bisecting with a flaky test One wrong check sends the whole halving off course Run the test a few times at each step, or fix the test first
The test is missing in older commits Git treats the missing file as "bad" Keep the test outside the repo, or pick a good commit after it appeared
Pasting the whole failing file to AI straight away Nothing to judge the answer against Do the five steps, then send a short bug report

7.1 Illustration: one morning, two ways#

This is an illustrative scenario and names no real person or product.

Way one: ask AI right away.

  • 09:30. A user reports: "My order of 100 did not get the discount." The dev pastes the whole cart.py to AI and asks how to fix it.
  • 09:40. AI proposes adding a special condition for a total equal to 100 inside the total function. The test passes, the dev merges.
  • 11:15. The discount function still uses >, so another place that calls discount directly (the end-of-day report) still gets it wrong. The dev loses time hunting again.

Way two: five steps first, then ask.

  • 09:30. Same report. The dev runs the test and sees the bug reproduces with one command.
  • 09:40. Shrinks it to total([(100, 1)]), predicts and tries three boundary values, and the hypothesis holds.
  • 09:50. git bisect run points to the commit "refactor: simplify discount check", a one-character change.
  • 10:00. The dev gives AI a short bug report to make sure no other place uses the same condition, fixes it at the root and merges.

What to do: build the habit of shrinking and hypothesizing before asking AI, so you can check what it says. This is an illustration, so it proves nothing. The evidence is in the studies cited: Perry et al. (2022) show how easy it is to feel overconfident with an AI assistant, and the BairesDev survey suggests problem-solving is still valued. See also the post Git for People Who Use Agents on leaving save points that give bisect something to work with.


8. Eight questions before you hand a bug to AI#

  1. Do I have one command that makes the bug happen every time?
  2. Is the case as small as it gets: if I remove anything more, does the bug disappear?
  3. What is my hypothesis, and did I predict the result first?
  4. Did the result match the prediction, or am I bending it to fit?
  5. Is this a regression (it used to work), and do I have a good commit as a starting point?
  6. What have I ruled out, and did I write it down?
  7. Does my bug report have the expected result, the actual result verbatim and what was ruled out?
  8. When AI answers, what will I use to check it: a test, boundary values, or just a feeling?

9. A balanced view: what this does and does not do#

What it does: turns "the program is wrong" into a specific place with evidence, narrows the suspect range very fast through halving, makes bug reports short and checkable, and leaves a test that stops the bug returning.

What it does not do:

  • git bisect needs a test that gives the same answer every time and a history with commits that run. Bugs that depend on timing, real data, run order or environment are much harder to reproduce.
  • If the bug has existed since the first commit, there is no good commit to start from. If the history was squashed into a few large commits, halving only points you at a big chunk.
  • Some bugs need careful code reading or domain knowledge, and are not regressions.
  • AI sometimes finds the cause faster than a person, even when you have not done all the steps. The post does not say do not ask AI, it says ask afterwards so you can check.
  • I did not find a controlled study measuring how much faster these five steps find bugs. They are a synthesis of practice, plus one real example on a small repo I built.
  • The real example is one bug in a small 20-commit repo. Real repos are far more complicated.

Do not overstate it: the five steps do not guarantee finding every bug. Do not play it down: the cost of fixing without understanding usually shows up later, when the bug returns somewhere else.


10. Conclusion#

Debugging works like a detective's job: keep the scene, shrink it, form a hypothesis that can be wrong, rule things out by halving, then file a report so the bug does not come back. Once you have done those steps, the bug report for AI becomes short, and you have something to check its answer against. Try the exercise with the 20-commit repo: it takes about half an hour and leaves you with a habit that lasts a career.


References#

Figures come from public sources as of October 2026. Every command and output in the post comes from a real run on Git 2.56.0 and Python 3.14.8, and commit hashes differ between runs.

Frequently asked questions

Why find the bug yourself before asking AI?
If you do not know where the bug is, you have nothing to judge the AI's answer against. AI can propose a fix that looks reasonable but only hides the symptom. Once you have reproduced, shrunk and hypothesized, you can write a short bug report and check the explanation you get back.
What is git bisect and when do I use it?
git bisect runs a binary search over commit history to find the first commit that broke something. You mark one good commit and one bad commit, Git checks out the middle one for you to test, and each test removes half of the range. It needs a reliable test and a history of commits that run. With git bisect run, Git runs the test for you.
How many checks does it take to find the bad commit?
About log2 of the number of suspect commits, rounded up. For 19 commits that is at most 5, and in this post's real run Git needed 4. For 999 commits it is about 10. Walking from the oldest commit one by one could take up to 999.
What should I give AI when I ask it to find a bug?
A bug report with the smallest reproduction, the expected result and the actual result (verbatim), what you already ruled out, your current hypothesis, and a request to explain the cause before fixing. The post has a template to paste.
Does git bisect work for every bug?
No. It needs a test that gives the same answer every time and a history with commits that run. Bugs that depend on timing, data or environment are harder, and if the bug has existed since the first commit there is no good commit to use as a starting point.