A Debugging Playbook That Always Works
Detective work beats guessing, every time
In this lesson
You met debugging basics back in Unit 2 and common errors in Unit 3. This lesson is the method: the thing to do when the error is not one you recognize and the obvious fix does not work.
The difference between an hour and five minutes is almost never knowledge. It is whether you followed a process or started changing things.
Explain it like I’m 5
Debugging is detective work. Slow down, gather the clues in order, and test one suspect at a time instead of arresting the whole street.
Step one: make it happen on demand
A bug you cannot reproduce cannot be fixed, only guessed at. Before anything else, find the exact input that triggers it and write that down.
“It crashes sometimes” is not a bug report, it is a mystery. “It crashes on row 4,812, which has an empty date field” is a bug that is halfway solved. Half of all debugging is just this step done properly.
Step two: read the traceback backwards
A traceback looks intimidating because it is printed in an unhelpful order. Read it like this:
- The last line first. That is the actual error and the actual message.
KeyError: 'total'tells you a dictionary had nototalkey. - Then the lowest frame that is your own file. Everything above it is how you got there; everything below it is usually library code you did not write.
- Then the chain, top to bottom, if the error came from somewhere unexpected.
The habit worth building: your eye should go to the bottom of the traceback, then hunt upward for the last filename that belongs to you.
$ python report.py sales.csv
Traceback (most recent call last):
File "/home/ada/report.py", line 31, in <module>
main(sys.argv[1])
File "/home/ada/report.py", line 24, in main
print(summarize(rows))
File "/home/ada/report.py", line 17, in summarize
return f"{rows[0]['region']}: {total(rows):.2f}"
File "/home/ada/report.py", line 11, in total
return sum(row['total'] for row in rows)
File "/home/ada/report.py", line 11, in <genexpr>
return sum(row['total'] for row in rows)
KeyError: 'total'
Practice the reading rule as code. Given a traceback as text, pull out the exception on the last line and the deepest frame that belongs to your own project, the two facts you actually need. Frames from site-packages are library code and must be skipped.
import re
TRACEBACK = """Traceback (most recent call last):
File "/home/ada/report.py", line 31, in <module>
main(sys.argv[1])
File "/home/ada/report.py", line 24, in main
print(summarize(rows))
File "/usr/lib/python3.12/site-packages/tabulate.py", line 402, in render
widths = [max(len(c) for c in col) for col in columns]
ValueError: max() arg is an empty sequence
"""
frames = re.findall(r'File "([^"]+)", line (\d+), in (\S+)', TRACEBACK)
# TODO: the last line holds the error; split it into type and message
error_line = ""
kind, message = "", ""
# TODO: the deepest frame that is NOT in site-packages
mine = None
print("error: ", kind)
print("message:", message)
print("blame: ", mine)
TRACEBACK.strip().splitlines()[-1] is the error line; .split(": ", 1) splits it into the type and the message (the 1 matters, because messages can contain colons). For the frame, walk frames in order and keep overwriting mine whenever the path has no site-packages in it. The last one that survives is the deepest.
import re
TRACEBACK = """Traceback (most recent call last):
File "/home/ada/report.py", line 31, in <module>
main(sys.argv[1])
File "/home/ada/report.py", line 24, in main
print(summarize(rows))
File "/usr/lib/python3.12/site-packages/tabulate.py", line 402, in render
widths = [max(len(c) for c in col) for col in columns]
ValueError: max() arg is an empty sequence
"""
frames = re.findall(r'File "([^"]+)", line (\d+), in (\S+)', TRACEBACK)
error_line = TRACEBACK.strip().splitlines()[-1]
kind, message = error_line.split(": ", 1)
mine = None
for frame in frames:
if "site-packages" not in frame[0]:
mine = frame
print("error: ", kind)
print("message:", message)
print("blame: ", mine)
error: ValueError
message: max() arg is an empty sequence
blame: ('/home/ada/report.py', '24', 'main')
Step three: shrink it until it is embarrassing
The single most effective technique in debugging is making the failing example smaller. Delete everything not needed to reproduce the bug: other functions, other rows, other arguments.
Two good things happen. Either the example gets so small that the bug is obvious, or it stops failing, and whatever you removed last is where the bug lives. Both are wins, and the second is often faster.
The same idea at a larger scale is bisection: comment out half the program. Still broken? The bug is in the half that is left. Fixed? It is in the half you removed. Ten halvings will find a bug in a thousand-line file.
# The bug report: "the report crashes on the real file".
# 4,000 rows, so start by finding the ONE row that fails.
def risky(row):
return 100 / row["units"]
rows = [
{"item": "widget", "units": 4},
{"item": "gizmo", "units": 2},
{"item": "doodad", "units": 0},
{"item": "gadget", "units": 5},
]
for index, row in enumerate(rows):
try:
risky(row)
except Exception as err:
print(f"row {index} fails: {row} -> {type(err).__name__}: {err}")
print("smallest failing example: risky({'units': 0})")
row 2 fails: {'item': 'doodad', 'units': 0} -> ZeroDivisionError: division by zero
smallest failing example: risky({'units': 0})
Step four: check your assumptions, do not trust them
Every bug is a place where reality and your belief about the code disagree. Debugging is finding which belief is wrong, and the only way is to look.
Printing values works and needs no setup. breakpoint() works better: put it on a line, run the program normally, and Python stops there and gives you a prompt where you can inspect anything.
p name— print a variablen— run the next lines— step into the function being calledc— continue until the next breakpointq— quit
The advantage over printing is that you decide what to look at after you have stopped, rather than guessing in advance which five variables mattered.
$ python report.py
> /home/ada/report.py(11)total()
-> return sum(row["total"] for row in rows)
(Pdb) p len(rows)
4812
(Pdb) p rows[0].keys()
dict_keys(['region', 'units', 'value'])
(Pdb) q
Step five: write down the fix
When it works, spend two minutes recording three things: what the symptom was, what actually caused it, and how you proved it. Put it in the commit message.
This is not paperwork. You will meet this bug again, in another project, or in this one in eight months, and the note turns a second hour into a second minute. It is also what makes the bug worth a test, which is the whole subject of the next lesson.
git commit -m "Fix crash on rows with a blank units column
Symptom: ZeroDivisionError from total() on the real 4,812-row export.
Cause: the exporter writes an empty units field as 0 rather than
omitting the row, and risky() divides by it.
Proof: reproduced with a single row {'units': 0}; added a test.
"
Common mistake: Changing several things at once
You have three theories and trying them together feels faster.
It is faster only if you are right. Change one, test, revert if wrong. Otherwise a fix and a new bug can cancel out and teach you nothing.
Common mistake: Reading only the first line of the traceback
It is at the top, so it looks like the headline.
The top line is always the same words. The last line is the error, and the deepest frame in your own file is the place to look.
Common mistake: Assuming the computer is being random
When you cannot see the cause, randomness is the only explanation left.
Genuinely random behavior is nearly always hidden input: dictionary ordering you did not control, a timestamp, a network response, an uninitialized value. Look for the input you did not know you had.
Common mistake: Deleting the code that errors until the error stops
The error goes away, which feels like progress.
The error was the messenger. Removing it hides the fault until it resurfaces as wrong output, which is far harder to find than a crash.
Common mistake: Debugging without saving the input that triggers it
Finding the bad row felt like the hard part, so it is tempting to move straight on.
Save it to a file first. You will re-run it dozens of times, and re-deriving it each time is where the afternoon goes.
What should you do before changing any code?
A bug you cannot trigger on demand cannot be confirmed fixed, so reproduction comes first.
Why shrink a failing example as small as possible?
Both outcomes move you forward, which is why shrinking is the highest-value habit in debugging.
Which line of a traceback names the actual error?
The last line holds the exception type and message. The frames above only describe the route taken to reach it.
What is a logic bug?
It never crashes, which is exactly why it is the hardest kind to find, and why tests matter more than tracebacks for catching it.
Mini exercise (medium)
Three broken functions, each with a different kind of bug. Fix all three so they behave as their docstrings promise, then classify each bug in BUG_KIND as either "crash" (it raises) or "wrong answer" (it runs and lies). Telling those two apart is what decides whether a traceback or a test will find it.
Try it yourself. Complete the snippet and hit Run; it executes in your browser, no setup required.
ROWS = [{"price": "2.50"}, {"price": "9.99"}, {"price": "1.20"}]
def last_word(sentence):
"""The final word of a sentence."""
return sentence.split()[-2]
def average(numbers):
"""The mean of a list of numbers."""
return sum(numbers) / 10
def total_price(rows):
"""Total of the price column, which arrives from a CSV as text."""
return sum(row["price"] for row in rows)
# TODO: label each bug "crash" (it raises) or "wrong answer" (it runs and lies)
BUG_KIND = {
"last_word": "?",
"average": "?",
"total_price": "?",
}
print("last word:", last_word("the quick brown fox"))
print("average: ", average([2, 4, 6]))
print("total: ", round(total_price(ROWS), 2))
print("kinds: ", [BUG_KIND[name] for name in ("last_word", "average", "total_price")])
last_word is off by one: [-1] is the last item, not [-2]. average divides by a hard-coded number instead of len(numbers), so it never raises. total_price concatenates strings from the CSV instead of adding numbers, so it needs float() around each value.
ROWS = [{"price": "2.50"}, {"price": "9.99"}, {"price": "1.20"}]
def last_word(sentence):
"""The final word of a sentence."""
return sentence.split()[-1]
def average(numbers):
"""The mean of a list of numbers."""
return sum(numbers) / len(numbers)
def total_price(rows):
"""Total of the price column, which arrives from a CSV as text."""
return sum(float(row["price"]) for row in rows)
BUG_KIND = {
"last_word": "wrong answer",
"average": "wrong answer",
"total_price": "crash",
}
print("last word:", last_word("the quick brown fox"))
print("average: ", average([2, 4, 6]))
print("total: ", round(total_price(ROWS), 2))
print("kinds: ", [BUG_KIND[name] for name in ("last_word", "average", "total_price")])
last word: fox
average: 4.0
total: 13.69
kinds: ['wrong answer', 'wrong answer', 'crash']
assert last_word("the quick brown fox") == "fox", "[-1] is the last item; [-2] is the one before it"
assert last_word("hello") == "hello", "a one-word sentence is its own last word"
assert average([2, 4, 6]) == 4.0, "divide by len(numbers), not by a hard-coded 10"
assert average([5]) == 5.0, "a single number averages to itself"
assert round(total_price([{"price": "1.50"}, {"price": "2.50"}]), 2) == 4.0, "convert each price with float() before adding"
assert BUG_KIND == {"last_word": "wrong answer", "average": "wrong answer", "total_price": "crash"}, "only total_price raises; the other two return a plausible lie"
print("✓ Looks good!")