Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 03 / 18 25 MIN

Getting a program to run

Reading a Traceback Without Panicking

A traceback is a map, not a verdict. Learn to read it from the bottom up, and to tell the difference between where the program died and where the mistake was made.

You run a script, the terminal fills with red text, and the first instinct is to scroll to the top and start reading. That is the worst place to start. The top of a traceback is where the program began. The bottom is where it stopped, and the reason it stopped is written there in plain English.

The people who appear to debug quickly are not reading faster. They are reading in a different order, and they have stopped treating the traceback as an accusation. It is a recording of a call stack at the moment something went wrong, and every line in it is a fact you can use.

The last line names the failure

Take a file called report.py that does not work:

def load_rows(path):
    with open(path) as handle:
        return [line.strip() for line in handle]


def summarise(rows):
    return sum(int(row) for row in rows)


rows = load_rows("numbers.txt")
print(summarise(rows))

Run it with a numbers.txt that contains a blank line somewhere in the middle, and you get this:

Traceback (most recent call last):
  File "/home/ana/report.py", line 11, in <module>
    print(summarise(rows))
  File "/home/ana/report.py", line 7, in summarise
    return sum(int(row) for row in rows)
  File "/home/ana/report.py", line 7, in <genexpr>
    return sum(int(row) for row in rows)
ValueError: invalid literal for int() with base 10: ''

The bottom line tells you three things: the exception type is ValueError, the message is invalid literal for int() with base 10: '', and the value it choked on is the empty string. You now know more than you did, and you have not read a single frame.

The phrase invalid literal for int() is worth internalising because it is one of the most common messages in Python. It means int() was handed something that is not a number in string form. The colon and the quotes at the end tell you what that something was.

The frames read bottom to top

Above the exception line are the frames, and they are in the opposite order to what you want. The first frame is the outermost caller. The last frame is where execution actually stopped. Read them upward:

  1. line 11, in <module> called summarise(rows).
  2. line 7, in summarise ran a generator expression over rows.
  3. line 7, in <genexpr> attempted int(row) and failed.

The <module> label means top-level code, the part of the file that is not inside a function. A <genexpr> frame is a generator expression, which is why line 7 appears twice: the generator is defined on that line and also executes on it.

That double appearance confuses people the first time. The rule is that each frame is one function call, and a generator expression or comprehension is a function for this purpose even though it does not look like one.

The frame that raised is not always the bug

int('') is where the program died, not where the mistake was made. The mistake is in load_rows, which returned a blank string as though it were data. Fixing the failure site means adding a guard in summarise that hides the real problem for every future caller.

Exception types have shapes you can recognise

Three exception types account for most of what a beginner sees, and each one has a distinct signature. Knowing the shape tells you which layer of the program to look at before you read a single frame.

ExceptionWhat it meansWhere to look
NameError A name is used that has never been assigned, or is assigned after this line. Spelling, and definition order in the file
TypeError A value of the wrong type reached an operation: adding a string to an int, calling something that is not callable. The caller that passed the value in
AttributeError An attribute does not exist on that object, often because the object is None. The line that produced the object
KeyError A dictionary lookup for a key that is not present. The data, not the code
IndentationError The file does not parse at all, so nothing ran. The line number in the message

IndentationError is the odd one out: it happens before your code executes, so the traceback is minimal and the line number points at the offending indentation directly. Mixing tabs and spaces produces TabError, which is a subclass and prints similarly.

An AttributeError that is really a None

This is the failure that costs beginners the most time, because the message names a line that looks correct:

def find_customer(customers, customer_id):
    for customer in customers:
        if customer["id"] == customer_id:
            return customer


customer = find_customer([{"id": 1}], 2)
print(customer["email"].lower())
Traceback (most recent call last):
  File "lookup.py", line 8, in <module>
    print(customer["email"].lower())
AttributeError: 'NoneType' object has no attribute 'lower'

Read the message as a sentence about a type, not about your code. It says: the object you called .lower() on is None. That is a fact about a value. The question it raises is the useful one: why is this None?

The answer is that find_customer returned nothing when the loop completed without a match, and a function with no explicit return returns None. The bug is a missing branch, not a bad method call. Guarding the call site with if customer is not None would silence the crash and leave a function that silently returns nothing whenever it is asked for something absent.

Use the traceback to choose the fix

Once you can read the shape, the traceback answers a question you should ask before editing anything: is the code wrong, or is the input wrong?

ValueError: invalid literal for int() on a line reading a user-supplied file is an input problem, and the fix belongs where the file is read. TypeError: unsupported operand type(s) for +: 'int' and 'str' inside your own function is a code problem. KeyError on a configuration dictionary is almost always a deployment problem: the code is right and the environment is missing a value.

Getting this wrong in either direction is expensive. Treating an input problem as a code problem produces defensive checks scattered through logic that was correct. Treating a code problem as an input problem produces a validation layer that rejects good data and never fixes the bug.

Lab 20 minutes

Create a file containing exactly this, run it, and read the traceback before you change anything.

def average(values):
    return sum(values) / len(values)


def report(scores):
    for name, values in scores.items():
        print(name, average(values))


report({"ana": [8, 9], "bo": [], "cy": [7]})

Write down three things before you touch the code: the exception type, the line where it was raised, and the function that supplied the bad value.

Then decide whether the fix belongs in average, report, or the caller. There is more than one defensible answer, and the reasoning is what matters.

Show the solution and the reasoning

The failure is ZeroDivisionError: division by zero, raised inside average, and the value that caused it came from report, which passed an empty list. The traceback shows three frames: <module>, then report, then average.

Three fixes are reasonable and they mean different things.

Return None for an empty sequence and let the caller decide:

def average(values):
    if not values:
        return None
    return sum(values) / len(values)

This makes "no data" a value the caller must handle. It is the right choice when an empty input is a legitimate state, and it moves the decision to the layer that knows what an empty average should mean.

Raise a domain exception so the failure is explicit:

def average(values):
    if not values:
        raise ValueError("average() needs at least one value")
    return sum(values) / len(values)

This is better when an empty list reaching average indicates a bug upstream. The message is more useful than division by zero because it names the contract that was broken.

Skip empty rows in report and print a placeholder:

def report(scores):
    for name, values in scores.items():
        result = average(values)
        print(name, "no scores" if result is None else result)

This is the right answer when the report should be complete: every name appears, and the ones without data say so. It is the wrong answer if an empty list means something has gone wrong earlier and should not be tolerated.

What is not defensible is wrapping the division in try and except ZeroDivisionError: pass. That converts a visible failure into a report that quietly omits information, and the next person to debug it starts from nothing.

Progress

Mark this lesson done and it stays marked in this browser.