Debugging systematically
Find any bug with a method instead of guesswork.
Create a free account Try part of it here
Why it matters
Professional engineers spend more time finding bugs than writing new code. The difference between a junior and a senior isn't that seniors make fewer mistakes. Seniors have a method that finds any bug, while juniors change things at random and hope. The method also matters when you're checking an AI agent's work, or directing one to fix something.
What you'll be able to do
- Follow a repeatable method: reproduce, shrink, hypothesize, test, fix, add a regression test
- Read a full traceback, including chained exceptions, and find the real cause
- Inspect running code with labeled prints, logging and a debugger
- Find the change that broke something with bisection, and shrink a failing input automatically
What it covers
- A method, not guesswork
- Read the whole traceback
- Follow the trail
- Look inside the running code
- Bisect: find the change that broke it
- Fix the cause, not the crash
A method, not guesswork
A good doctor doesn't prescribe pills at random and see what helps. They ask questions, run a test and narrow it down. When code misbehaves, the urge is to start changing things. Resist it. Work through these steps instead:
- Reproduce it. Find input that makes the bug happen every time. A bug you can't trigger on demand, you can't confirm you've fixed.
- Shrink it. Cut the failing input down until removing anything more makes the bug vanish. Small cases make the cause obvious.
- Read the evidence. The whole error message, the traceback, the logs.
- Form one hypothesis. "I think
totalis a string here, not a number." - Test that hypothesis with the smallest experiment: a print, an assert, a debugger stop.
- Fix the cause, not the symptom, then add a test that fails without your fix. That regression test stops the bug from quietly coming back.
In plain words: make it fail on demand, make one guess, and check that guess before you change anything.
Then fix it, and add a test so it can't come back.
Read the whole traceback
A traceback is a trail of footprints, and the most useful end is the last one. Python's error report reads from the bottom up. The last line says what went wrong. The lines above show the chain of calls that led there, oldest first. Find the lowest line that's in your code: that's usually where to look.
Sometimes there are two tracebacks joined by "During handling of the above exception, another exception occurred". That means the code that was handling the first error crashed too. The first traceback is usually the real cause.
total = "5" try: print(total + 1) except TypeError: print("str + int")
Show the answer
str + int
total is text, so total + 1 raises a TypeError, and the except prints str + int. Python refuses to add text to a number. The last line of the traceback tells you just that.
Follow the trail
Run it and read the traceback from the bottom.
orders = [
{"id": 1, "total": "19.50"},
{"id": 2, "total": "7.25"},
]
def order_value(order):
return order["total"] * 1.2 # add 20% tax
def report(orders):
return [round(order_value(o), 2) for o in orders]
print(report(orders))The last line says TypeError: can't multiply sequence by non-int of type 'float'. The frame above it points at order_value. "Sequence" means a string here: total came in as text, which is common with CSV and JSON exports. The fix belongs where the data enters, with float(order["total"]), not in every place that uses it.
KeyError: 'email', and the lowest frame in your code is send_receipt(order). What's your first hypothesis?Show the answer
Some order dictionary has no "email" key
KeyError means a dictionary was asked for a key it doesn't have. Next step: find which order, for example by printing the order's id right before the failing line.
Look inside the running code
Labeled prints are the fastest tool. An f-string with = prints both the expression and its value, so you never wonder which number is which:
price, qty = "19.50", 3 print(f"{price=} {qty=} {type(price)=}")
Logging is print for code that stays, like a ship's log. Messages carry a level, from DEBUG up to ERROR, so you can switch the detailed ones on only while you investigate:
import logging, sys logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(levelname)s %(message)s", force=True) log = logging.getLogger("orders") log.debug("this is hidden at INFO level") log.info("processing order %s", 42) log.warning("order %s has no email", 42)
A debugger pauses the program so you can look around, like pausing a film to study one frame. Put breakpoint() on a line and run the script in a terminal. Python stops there, and you can type commands:
p total print a variable n run the next line s step into a function call c continue until the next breakpoint q quit
Code editors such as VS Code give you the same thing with buttons: click beside a line to set a breakpoint, then step through and watch variables update.
breakpoint() call?Show the answer
It pauses and opens the debugger, so you can inspect variables and step through
breakpoint() starts the debugger (pdb by default) at that line. Remove it before committing.
Bisect: find the change that broke it
"It worked last week." Somewhere among the last 200 commits, something broke. Think of an old string of lights with one bad bulb. You wouldn't test them in order. You'd check the middle first.
Checking all 200 commits is slow. Instead, test the commit in the middle. If it's broken, the bad change is in the first half. If it works, the change is in the second half. That's binary search again: 200 commits take about 8 checks. Git does this for you with git bisect:
git bisect start git bisect bad # the current commit is broken git bisect good v1.4 # this older version worked # git checks out a commit in the middle; you test it, then type: git bisect good # or: git bisect bad # ...repeat until git names the first bad commit git bisect reset
Bugs you'll meet again and again
The same few mistakes turn up in every language and on every team, so they're worth learning to recognize:
| bug | what it looks like |
|---|---|
| off-by-one | range(1, n) misses the last item; < where you meant <= |
| text that should be a number | "19.50" * 2 gives "19.5019.50" |
| float equality | 0.1 + 0.2 == 0.3 is False; compare with a tolerance |
| aliasing | changing a copy changes the original, because it isn't a copy |
| forgotten return | the function returns None and the caller crashes later |
| mutable default argument | a default list is shared between calls (below) |
That last one surprises everyone once. It's like a shared notepad left on a table, which still has the last person's notes on it:
def add_tag(tag, tags=[]): # the default list is made ONCE, when def runs tags.append(tag) return tags print(add_tag("sale")) print(add_tag("new")) # the "empty" default remembers "sale"! def add_tag_fixed(tag, tags=None): if tags is None: tags = [] # a fresh list on every call tags.append(tag) return tags print(add_tag_fixed("sale"), add_tag_fixed("new"))
When you're stuck, explain the code out loud, line by line, to a colleague or even a rubber duck. You'll often hear the mistake as you say it. If you ask a person or an AI for help, give them the full error, the smallest code that shows it, what you expected and what you've tried.
Show the answer
10
Each test halves the range, and 2 to the 10th is 1,024. So about 10 tests.
Fix the cause, not the crash
A painkiller doesn't mend a broken bone. The line where a program crashes often isn't where the bug is. It's where the bad data finally caused trouble.
Say total_price crashes because price is None. Adding if price is None: price = 0 there makes the crash go away. Now you're silently charging nothing for an item, and you've turned a loud bug into a quiet one. The real question is why price was None. Follow it backward: who called this function, and where did they get that value? Fix it at the place where the wrong value came in.
Changing things at random
Everyone does this when tired: change a line, run it, change another. Sometimes the error goes away, and you never learn why. Then either it comes back, or one of your random changes breaks something else.
So change one thing at a time, and predict what will happen before you run it. If the result surprises you, your idea of the code is wrong, and that surprise is the most useful information you'll get all day. Take a short break if you're stuck, and try explaining the problem aloud, even to nobody. People find the answer mid-sentence surprisingly often.
Finish the job with a test
When you've found the bug, write a test that fails because of it. Then fix it and watch the test pass. It takes a few minutes, and it does two things. It proves that you fixed the real problem, not a look-alike. And it stops this bug from ever coming back quietly. A fix that "obviously works" has a way of returning six months later.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why add a regression test after fixing a bug?