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:
$ 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.")
'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.")
Parsed: 4.5When 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)
[]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))
5else 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")
parsed cleanly: 4.5
done checking this entryYou 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)
[{'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}")
'abc' isn't a number, try again.
'12x' isn't a number, try again.
got 4.5parse_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.
- Write
safe_float(raw)that returns the parsed number, orNoneif the text doesn't parse, usingtry/except. Test it with"4.50"and with"abc". 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.- Write a small script piece that tries to divide
10by a variabledivisorset to0, catchesZeroDivisionError, and prints"can't divide by zero"instead of crashing. - 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"))
4.5
None2.
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)
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")
can't divide by zero4. 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}")
got coffeeWhere 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.