A Debugging Playbook That Always Works

Detective work beats guessing, every time

Advanced 16 min

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:

  1. The last line first. That is the actual error and the actual message. KeyError: 'total' tells you a dictionary had no total key.
  2. 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.
  3. 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.

Example
$ 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'
Read the last line, then find the deepest line of your own code.

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)

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.

Example
# 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})")
Output
row 2 fails: {'item': 'doodad', 'units': 0} -> ZeroDivisionError: division by zero
smallest failing example: risky({'units': 0})
Loop the whole input, catch, and report which item fails.

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 variable
  • n — run the next line
  • s — step into the function being called
  • c — continue until the next breakpoint
  • q — 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.

Example
$ 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
Two questions at the prompt, and the bug is found: the column is called value, not total.

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.

Example
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.
"
Symptom, cause, proof. Two minutes now, an hour saved later.

Common mistake: Changing several things at once

Why it happens:

You have three theories and trying them together feels faster.

How to fix it:

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

Why it happens:

It is at the top, so it looks like the headline.

How to fix it:

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

Why it happens:

When you cannot see the cause, randomness is the only explanation left.

How to fix it:

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

Why it happens:

The error goes away, which feels like progress.

How to fix it:

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

Why it happens:

Finding the bad row felt like the hard part, so it is tempting to move straight on.

How to fix it:

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?

Why shrink a failing example as small as possible?

Which line of a traceback names the actual error?

What is a logic bug?

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")])

What to learn next

You have a method now rather than a set of instincts: reproduce it and save the input, read the traceback from the last line up to the deepest frame that is yours, shrink the failing example until it is embarrassing, check assumptions with breakpoint() instead of guessing, change one thing at a time, and write down what it turned out to be.

The exercise ended on the important distinction. Two of those three bugs never raised anything. They ran and returned a plausible lie, and no traceback will ever point at them. Tests That Catch Real Bugs is the only tool that finds that kind.