Course outline · 0% complete

0/29 lessons0%

Course overview →

Anatomy of a Traceback

lesson 8-1 · ~10 min · 23/29

What to do with a 40-line repro

Lesson 7-2's methodology says to shrink it while the failure persists, until the smallest failing input remains, before forming any hypotheses.

Minimize first. Cutting the input in half repeatedly, keeping whichever half still fails, usually leaves a tiny repro that names the bug by itself, in the way median([1, 2]) exposed the even-length case.

Forming hypotheses against the 40-line file first is the mistake, because a large input supports too many theories at once and every experiment on it is slow to run and hard to read.

The crash report, read properly

When Python crashes it prints a traceback, a listing of every function call that was active at the moment of the crash, ending with the error itself. It exists because the crashing line alone is rarely enough to diagnose anything. You also need to know how execution got there, and the traceback records exactly that chain of calls.

Beginners scroll past it as noise, and professionals read it first, because it is the cheapest and most precise evidence a bug will ever hand you. Here is one:

Traceback (most recent call last):
  File "app.py", line 10, in <module>
    print(total_cents(["4.20", "banana"]))
  File "app.py", line 6, in total_cents
    total += to_cents(p)
  File "app.py", line 2, in to_cents
    return int(float(text) * 100)
ValueError: could not convert string to float: 'banana'

Read it bottom-up:

  1. last line: what went wrong and why. The type is ValueError, and the message names the guilty value, 'banana'
  2. frame above it: where it happened, giving the file, the line number, and the exact source line
  3. the chain upward: how execution got there, since each frame called the one below it. The header says most recent call last, so the top is the outermost call

The crash site is not always the bug site. to_cents is innocent here, because someone upstream put 'banana' into a price list, and the chain is what lets you walk upstream to find them.

outermost call, in <module>called total_cents, line 6called to_cents, line 2 (crash site)ValueError: the what and the whyread upwardstart at the last line, then walk the call chain up
Traceback anatomy: the last line names the error, the frames above show how execution got there.

The same crash, caught

The exact program from the traceback above, with the crash caught so the diagnosis prints as a single line.

def to_cents(text):
    return int(float(text) * 100)

def total_cents(prices):
    total = 0
    for p in prices:
        total += to_cents(p)
    return total

try:
    print(total_cents(["4.20", "banana"]))
except ValueError as e:
    print("ValueError:", e)

Output

ValueError: could not convert string to float: 'banana'

except ValueError as e binds the exception object to e, and printing it gives the message text without the frames. That is the same information as the traceback's last line and nothing more, which is a useful way to see how much of the report those frames actually carry.

Catching the error here is for illustration, not a fix. Wrapping a crash in try and printing a friendly line is a real technique for user-facing code, and doing it while debugging discards the frames you need, so the habit worth keeping is to let it crash until you understand it.

Note the three frames in the original traceback map onto three lines of this file: the print call at module level, the total += to_cents(p) line inside the loop, and the conversion itself. Each frame is a function that was waiting for the one below it to return.

The first line to read

In a 30-line traceback, read the last line first, which names the exception type and its message.

Bottom-up, always. The last line is the what and the why, and the frames above are the where and the how, so reading them in the other order means guessing at the problem while wading through the call chain.

The last line also tends to be the most specific. ValueError: could not convert string to float: 'banana' quotes the offending value, and no amount of reading frames would have told you the value was the string 'banana' rather than an empty string or a number.

Once you have the type and message, the frames become targeted rather than exhausting. Since the crash site may be innocent, as with to_cents receiving 'banana', the chain above tells you which caller supplied the bad data, and that caller is usually where the fix belongs.

Reading a TypeError

The crash reads TypeError: can only concatenate str (not "int") to str and points at the return line. Before the fix, describe built its sentence with +.

def describe(user):
    return f"{user['name']} is {user['age']} years old"

print(describe({"name": "Ada", "age": 36}))

Output

Ada is 36 years old

The last line of the trace says + refuses to join a str and an int, and user["age"] is the int. The message names both types, which is enough to identify the operand without reading the dictionary.

F-strings convert values to text automatically, so no + and no explicit str() call is needed. Note the single quotes inside the double-quoted f-string, since f"{user["age"]}" is a syntax error in older Python versions and mixing the quote styles avoids the question entirely.

The alternative fix is str(user["age"]) inside the original concatenation, which works and is worse. Four + operators and a conversion call is harder to read than one template, and the next field added to the sentence brings the same trap back.

This is the most common TypeError in Python and the reason is worth naming. Python refuses to guess whether "3" + 4 means "34" or 7, and languages that do guess produce quieter and stranger bugs, so the crash here is the language doing you a favor.