Chapter 10

When Things Break

Chapter 2 first mentioned ValueError and moved on. Every chapter since has had at least one fence marked to raise on purpose, each time promising you'd deal with it properly later. This is later: reading a traceback without panicking, and catching a specific error instead of letting it crash your script.

Reading a traceback

Here's what it actually looks like when the float("twelve") mistake from Chapter 2 crashes an unguarded script:

Terminal
$ python track.py
Traceback (most recent call last):
  File "track.py", line 4, in <module>
    price = float(raw_amount)
ValueError: could not convert string to float: 'twelve'

Read it from the bottom up. The last line is the actual error: what kind it is (ValueError) and what Python was doing when it happened. The lines above that are the call stack, working backward from where the crash happened to where the script started, each one naming a file and a line number. For a short script like this one, the stack is one frame long; in a bigger program it can be many, and the bottom line is still where to look first. The traceback isn't a sign you broke something unrecoverably, it's Python telling you precisely where and why, so you can go straight to the line and fix it.

try / except

try/except runs a block, and if a specific kind of error happens inside it, runs a different block instead of crashing:

raw_amount = "twelve"

try:
    price = float(raw_amount)
    print(f"Parsed: {price}")
except ValueError:
    print(f"'{raw_amount}' doesn't look like a number.")
Output
'twelve' doesn't look like a number.

Only the exception you name is caught. Naming ValueError specifically, rather than catching everything, means a genuinely different bug still crashes loudly instead of being silently swallowed by a handler that wasn't meant for it.

raw_amount = "4.50"

try:
    price = float(raw_amount)
    print(f"Parsed: {price}")
except ValueError:
    print(f"'{raw_amount}' doesn't look like a number.")
Output
Parsed: 4.5

When raw_amount does parse, the except block never runs at all; try only steps in when something inside it actually raises.

A friendlier version of load_expenses

Chapter 9's load_expenses() crashes with FileNotFoundError if the file doesn't exist yet, which is exactly right the very first time someone runs this book's tracker and there's no history to load:

def load_expenses(path):
    expenses = []
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                raw_amount, category, note = line.split(",")
                expenses.append({
                    "amount": float(raw_amount),
                    "category": category,
                    "note": note,
                })
    except FileNotFoundError:
        pass
    return expenses

expenses = load_expenses("does-not-exist.txt")
print(expenses)
Output
[]

except FileNotFoundError: pass catches specifically a missing file and does nothing, pass is a statement that does literally nothing, leaving expenses as the empty list it started as. Confirm it still works normally when the file is there:

expenses = load_expenses("expenses.txt")
print(len(expenses))
Output
5

else and finally

else runs only if the try block didn't raise; finally runs no matter what, error or not:

raw_amount = "4.50"

try:
    price = float(raw_amount)
except ValueError:
    print("couldn't parse it")
else:
    print(f"parsed cleanly: {price}")
finally:
    print("done checking this entry")
Output
parsed cleanly: 4.5
done checking this entry

You won't need else and finally often; try/except alone covers most of what this book does. They're worth recognizing when you see them in other code: else for "only if nothing went wrong," finally for "always, as clean-up."

assert, a different kind of check

assert is a separate tool from try/except, for a different job: stating something you believe must be true, and crashing loudly if it isn't.

def add_expense(expenses, amount, category, note=""):
    assert amount > 0, "an expense amount should be positive"
    expenses.append({"amount": amount, "category": category, "note": note})
    return expenses

expenses = add_expense([], 4.50, "coffee")
print(expenses)
Output
[{'amount': 4.5, 'category': 'coffee', 'note': ''}]

Give it a negative amount and it stops you immediately, with the message you wrote:

add_expense([], -4.50, "coffee")

That's AssertionError: an expense amount should be positive. The distinction that matters: try/except is for handling things that can go wrong legitimately, like a person typing text where a number belongs. assert is for catching your own mistakes early, a bug in the code that called this function with a value that should never have gotten this far. Never write except AssertionError to quietly swallow one; the whole point is a loud, immediate stop, so you notice. One more thing worth knowing: assert statements can be switched off entirely when Python is run with a specific optimization flag, so never put code with a real side effect after the comma, and never rely on assert for something a program absolutely must check, like validating input from outside your program. For that, try/except, or a plain if that raises on purpose, is the right tool.

Retrying instead of giving up

try/except and while combine into a genuinely useful pattern: keep asking until you get something usable, instead of failing on the first bad attempt. Here, attempts stands in for someone typing a value, getting it wrong twice, then getting it right, the same way this book has stood in for input() since Chapter 2:

def parse_amount(raw):
    try:
        return float(raw)
    except ValueError:
        return None

attempts = iter(["abc", "12x", "4.50"])
amount = None

while amount is None:
    raw = next(attempts)
    amount = parse_amount(raw)
    if amount is None:
        print(f"'{raw}' isn't a number, try again.")

print(f"got {amount}")
Output
'abc' isn't a number, try again.
'12x' isn't a number, try again.
got 4.5

parse_amount() returns None instead of letting ValueError escape, which is exactly what lets the while loop's condition, amount is None, decide whether to keep going. In a real script, raw = next(attempts) would be raw = input("Amount: ") instead, asking again for real each time. The shape stays identical either way: try, and if it didn't work, loop back and try again rather than crashing on the very first typo.

When not to catch

Catching an exception is a decision, not a reflex. A missing expenses file is expected the first time someone runs the tracker, so handling it makes the script friendlier. A bug in your own logic, say a typo in a dict key like entry["ammount"], is not something to quietly catch; you want that to crash loudly, with a traceback pointing at the exact line, so you find and fix it rather than hiding it behind a handler that was written for a different problem entirely. A good rule: catch the specific errors you expect and know how to recover from; let everything else crash.

Practice

Try each of these before you read the solution under it.

  1. Write safe_float(raw) that returns the parsed number, or None if the text doesn't parse, using try/except. Test it with "4.50" and with "abc".
  2. load_expenses() above swallows a missing file silently. Change it to also print a one-line message when that happens, then run it on a missing path.
  3. Write a small script piece that tries to divide 10 by a variable divisor set to 0, catches ZeroDivisionError, and prints "can't divide by zero" instead of crashing.
  4. Using the retry pattern, given attempts = iter(["", "", "coffee"]) standing in for someone being asked for a category, keep asking until you get a non-empty answer (an empty string is falsy, from Chapter 3), then print what you got.

Solutions

1.

def safe_float(raw):
    try:
        return float(raw)
    except ValueError:
        return None

print(safe_float("4.50"))
print(safe_float("abc"))
Output
4.5
None

2.

def load_expenses(path):
    expenses = []
    try:
        with open(path) as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                raw_amount, category, note = line.split(",")
                expenses.append({
                    "amount": float(raw_amount),
                    "category": category,
                    "note": note,
                })
    except FileNotFoundError:
        print(f"no history yet at {path}, starting fresh")
    return expenses

expenses = load_expenses("does-not-exist.txt")
print(expenses)
Output
no history yet at does-not-exist.txt, starting fresh
[]

3.

divisor = 0

try:
    print(10 / divisor)
except ZeroDivisionError:
    print("can't divide by zero")
Output
can't divide by zero

4. No try/except needed here, since an empty string doesn't raise; while not category: alone drives the retry.

attempts = iter(["", "", "coffee"])
category = ""

while not category:
    category = next(attempts)

print(f"got {category}")
Output
got coffee

Where this leaves you

You can read a traceback from the bottom up, catch a specific exception instead of letting your script crash, and you know when catching one is the right call and when it's hiding a bug you'd rather see. Chapter 11 uses everything so far, this chapter included, to build one complete script.