Step 07 of 10

Errors & debugging

Read tracebacks, recognise common errors, handle bad input with try and except, and find bugs methodically with prints and a debugger.

17 min 7 sections

Professional programmers don't write code that works first time. They write code, watch it fail, and fix it, quickly and calmly, because they have a method. Debugging is a skill you can learn like any other, and it matters more than knowing lots of syntax. This lesson covers the three kinds of errors, how to read Python's error reports, how to handle errors your program should expect, and a step-by-step method for finding bugs you don't understand yet.

Three kinds of errors

  1. Syntax errors: the code isn't valid Python, such as a missing colon or an unclosed bracket. Python refuses to start, and points at the line. These are the easiest to fix.
  2. Runtime errors (exceptions): the code is valid, but something goes wrong while it runs, such as dividing by zero, using a missing dictionary key or converting "abc" to a number. The program stops at that line.
  3. Logic errors (bugs): the program runs without complaint and gives the wrong answer. Python can't help you here, because as far as it knows, you asked for exactly this. These are the hardest, and the rest of this lesson is mostly about them.

Reading a traceback

When a runtime error happens, Python prints a traceback: the chain of function calls that led to the error. Here's a program with a bug:

python
# average.py
def average(numbers):
    return sum(numbers) / len(numbers)

def report(name, scores):
    print(f"{name}: {average(scores):.1f}")

report("Ana", [80, 90])
report("Ben", [])
text
Ana: 85.0
Traceback (most recent call last):
  File "/Users/ana/code/learn/average.py", line 8, in <module>
    report("Ben", [])
  File "/Users/ana/code/learn/average.py", line 5, in report
    print(f"{name}: {average(scores):.1f}")
                     ^^^^^^^^^^^^^^^
  File "/Users/ana/code/learn/average.py", line 2, in average
    return sum(numbers) / len(numbers)
           ~~~~~~~~~~~~~^~~~~~~~~~~~~~
ZeroDivisionError: division by zero

Read it from the bottom up:

  1. The last line is what happened: ZeroDivisionError: division by zero.
  2. The frame just above it is where: line 2, in average, the division.
  3. Going up, you see how you got there: average was called by report on line 5, which was called on line 8 with report("Ben", []).

So the real question is not "why does division fail?" but "why is average getting an empty list, and what should happen then?". The traceback took you straight to it. Notice that Ana's line printed fine: the program ran until the failing line, then stopped.

Common exceptions

ExceptionTypical causeExample
NameErrorUsing a variable before assigning it, or a typo in its nameprint(totl)
TypeErrorAn operation on the wrong type"Age: " + 30
ValueErrorThe right type but a bad valueint("twelve")
IndexErrorA list position that doesn't exist[1, 2, 3][5]
KeyErrorA dictionary key that doesn't existprices["cheese"]
AttributeErrorA method that the value doesn't have[1, 2].upper()
ZeroDivisionErrorDividing by zero10 / 0
FileNotFoundErrorOpening a file that isn't thereopen("missing.txt")

When you hit one you don't recognise, copy the last line of the traceback into a search engine. Somebody has had it before.

Handling errors you expect: try and except

Some errors aren't bugs: they're things the outside world will do to your program. Users type letters where you expected numbers; files go missing. For those, catch the exception and respond sensibly:

python
while True:
    text = input("How many tickets? ")
    try:
        tickets = int(text)
    except ValueError:
        print(f"'{text}' isn't a whole number. Try again.")
        continue
    if tickets < 1:
        print("Please enter at least 1.")
        continue
    break

print(f"Booking {tickets} tickets.")

Python runs the try block. If a ValueError happens inside it, Python jumps to the matching except block instead of crashing. If no error happens, the except block is skipped.

Rules for catching well:

  • Catch the specific exception you expect (ValueError), never everything. A bare except: or except Exception: also hides your own bugs, such as a typo that raises NameError, and makes them much harder to find.
  • Keep the try block small: only the line that can fail in the expected way.
  • Don't catch what you can't handle. If there's nothing sensible to do, let the program stop with a clear traceback. A crash you can see is better than wrong results you can't.

You can also raise errors yourself, to refuse bad input loudly instead of producing nonsense:

python
def average(numbers):
    if not numbers:
        raise ValueError("average() needs at least one number")
    return sum(numbers) / len(numbers)

A method for finding bugs

When the output is wrong and you don't know why, don't change things at random hoping it starts working. Work like a scientist:

  1. Reproduce it. Find the exact input that causes the problem, and make it as small as possible. A bug you can trigger every time is half fixed.
  2. Say what you expected, precisely: "for [2, 3] I expected 2.5 and got 2.0".
  3. Form a hypothesis: "I think the division is happening on whole numbers somewhere".
  4. Test it by looking at the program's actual values, with prints or a debugger.
  5. Fix it, then rerun the original failing input, and the inputs that already worked.

Look at the values: print debugging

The quickest way to test a hypothesis is to print what the program actually holds:

python
def total_price(items):
    total = 0
    for item in items:
        total = item["price"] * item["quantity"]
        print(f"DEBUG item={item['name']} total={total}")
    return total
text
DEBUG item=Pen total=2.4
DEBUG item=Book total=12.0

The debug output shows total being replaced each round instead of growing: the line should be total += ..., not total = .... Remove the debug prints once you've fixed it.

Use f-strings with = to print names and values together: print(f"{total=}") prints total=12.0.

Narrow it down

If you don't know where in a long program things go wrong, check the values halfway through. If they're already wrong there, the bug is in the first half; if not, the second half. Halve again. A few rounds of this find the bad line in any program, however long.

Use a debugger

A debugger lets you pause a running program and look around: see every variable, run one line at a time, step into function calls. It's print debugging without editing the code.

In VS Code: click in the margin to the left of a line number to set a breakpoint (a red dot), then press F5 and choose "Python File". The program runs until it reaches that line and pauses. The panel on the left shows all variables; the toolbar lets you step over (run the next line), step into (go inside a function call) and continue (run to the next breakpoint).

Without an editor, write breakpoint() in your code and run it normally. Python stops there with a (Pdb) prompt: type a variable name to see it, n to run the next line, s to step into a call, c to continue and q to quit.

Explain it out loud

If you're stuck, explain the code line by line to someone else, or to an object on your desk (programmers call this rubber duck debugging). Saying "and then this adds the price to the total... oh, no it doesn't" works far more often than it should, because explaining forces you to read what the code says, not what you meant it to say.

Practice

This program is supposed to read exam scores, skip invalid or out-of-range entries, and print the average and the highest score. It has three bugs. Find them using the method above, then fix them.

python
# scores.py
def parse_scores(lines):
    scores = []
    for line in lines:
        try:
            score = int(line)
        except ValueError:
            print(f"Skipping invalid entry: {line}")
        if 0 <= score < 100:
            scores.append(score)
    return scores


def summary(scores):
    best = 0
    total = 0
    for i in range(1, len(scores)):
        total += scores[i]
        if scores[i] > best:
            best = scores[i]
    average = total / len(scores)
    return average, best


entries = ["72", "88", "abc", "95", "105", "64", "100"]
scores = parse_scores(entries)
avg, best = summary(scores)
print(f"Average: {avg:.1f}, best: {best}")

It prints Average: 67.0, best: 95. The correct output is Average: 83.8, best: 100, with a message about skipping abc. As a bonus, make summary give a clear error for an empty list, and report out-of-range scores instead of dropping them silently.

Show solution

Start by checking the values between the two steps: add print(f"{scores=}") after parse_scores. It shows scores=[72, 88, 88, 95, 64]. Already two problems before summary even runs.

Bug 1: the invalid entry isn't really skipped. After printing "Skipping", the code carries on to the range check using the previous value of score (88), so 88 is added twice. Worse, if the first entry were invalid, score wouldn't exist yet and the program would crash with NameError (try ["abc", "72"]). Fix: continue in the except block.

Bug 2: 100 is rejected. 0 <= score < 100 excludes 100, which is a valid score. This is an off-by-one error, one of the most common bugs there is. Fix: 0 <= score <= 100.

Bug 3: summary skips the first score. range(1, len(scores)) starts at index 1, so scores[0] is never added, but the total is still divided by the full length. Tracing the loop with [72, 88] by hand shows it at once: the total ends up 88, not 160. Fix: loop over the list directly.

python
def parse_scores(lines):
    scores = []
    for line in lines:
        try:
            score = int(line)
        except ValueError:
            print(f"Skipping invalid entry: {line}")
            continue
        if 0 <= score <= 100:
            scores.append(score)
        else:
            print(f"Skipping out-of-range score: {score}")
    return scores


def summary(scores):
    if not scores:
        raise ValueError("summary() needs at least one score")
    return sum(scores) / len(scores), max(scores)
text
Skipping invalid entry: abc
Skipping out-of-range score: 105
Average: 83.8, best: 100

The valid scores are 72, 88, 95, 64 and 100, which sum to 419 and average 83.8. Rewriting summary with sum and max removes the hand-written loop entirely, and with it the index bug and the fragile best = 0 starting value. Fewer lines, fewer bugs.

Key takeaways

  • Syntax errors stop the program before it runs, exceptions stop it at a line, and logic errors give wrong answers silently.
  • Read tracebacks from the bottom: what happened, where, then how the program got there.
  • Catch specific exceptions you expect, such as ValueError from user input, in small try blocks; let unexpected errors crash visibly.
  • Debug methodically: reproduce, state the expectation, hypothesise, inspect values, fix and rerun.
  • Prints, halving the search space, a debugger with breakpoints, and explaining aloud are your main tools.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Where in a Python traceback do you find what kind of error occurred?

  2. 2.Why is a bare except: that catches every error usually a bad idea?

  3. 3.A program runs without errors but prints the wrong total. What kind of error is this?

  4. 4.What's the first step when you meet a bug you don't understand?

Finished reading?

Mark it complete to fill in your roadmap.