# Errors & debugging

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

Learn to Code roadmap · Writing real programs · 17 min read · https://levelcode.org/roadmaps/learn-to-code/errors-and-debugging

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

| Exception | Typical cause | Example |
|---|---|---|
| `NameError` | Using a variable before assigning it, or a typo in its name | `print(totl)` |
| `TypeError` | An operation on the wrong type | `"Age: " + 30` |
| `ValueError` | The right type but a bad value | `int("twelve")` |
| `IndexError` | A list position that doesn't exist | `[1, 2, 3][5]` |
| `KeyError` | A dictionary key that doesn't exist | `prices["cheese"]` |
| `AttributeError` | A method that the value doesn't have | `[1, 2].upper()` |
| `ZeroDivisionError` | Dividing by zero | `10 / 0` |
| `FileNotFoundError` | Opening a file that isn't there | `open("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.

> [!TIP]
> Fix one bug at a time and rerun after each change. And when you've fixed a tricky bug, check the rest of your code for the same mistake: bugs come in families.

## 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.

**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.

## Quiz

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

   A. The first line
   B. The last line
   C. The middle frame
   D. It isn't shown; you have to guess

   **Answer: B.** The last line names the exception and message. The frames above it show where it happened and the chain of calls that led there.

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

   A. It makes programs slower
   B. Python doesn't allow it
   C. It also hides your own bugs, such as typos raising NameError, making them very hard to find
   D. It only works with ValueError

   **Answer: C.** Catching everything turns real bugs into silent misbehaviour. Catch only the specific exception you expect and know how to handle.

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

   A. A logic error
   B. A syntax error
   C. A runtime error
   D. A FileNotFoundError

   **Answer: A.** The program is valid and doesn't crash, but it doesn't do what you intended. Python can't detect that; inspecting values is how you find it.

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

   A. Rewrite the function from scratch
   B. Change lines one by one until it works
   C. Add try/except around the whole program
   D. Reproduce it reliably with the smallest input that triggers it

   **Answer: D.** A reliable, minimal reproduction lets you test each hypothesis quickly and confirm the fix. Random changes and blanket exception handling hide problems instead of fixing them.
