Exceptions: Handling Failure 🧯
Things go wrong: files vanish, networks drop, users type 'banana' into an age box. Exceptions are how Python lets you plan for that without every line becoming a check.
try and except
raw = "banana"
try:
number = int(raw)
print(f"Got {number}")
except ValueError:
print(f"'{raw}' is not a number.")
print("The program carries on.")
'banana' is not a number.
The program carries on.
Python attempts the try block. If an exception of the named type appears,
it jumps straight to the matching except and carries on afterwards. Without
that handler, the program would stop dead.
Catch what you expect, not everything
# Never do this
try:
result = 10 / 0
except: # catches literally everything
print("something went wrong")
# Do this
try:
result = 10 / 0
except ZeroDivisionError:
print("cannot divide by zero")
something went wrong
cannot divide by zero
A bare except swallows everything. Your typo in a variable name: swallowed. The user pressing Ctrl+C to quit: swallowed. A genuine out-of-memory condition: swallowed, and then the program keeps going in a state nobody designed for.
You have not handled the error. You have hidden it, and it will surface somewhere far away with no clue where it came from.
Catch several, or several at once:
def to_number(text):
try:
return int(text)
except ValueError:
return f"'{text}' has no digits I can use"
except TypeError:
return "I need text, not that"
print(to_number("42"))
print(to_number("banana"))
print(to_number(None))
def read_config(value):
try:
return 100 / int(value)
except (ValueError, ZeroDivisionError) as err:
return f"{type(err).__name__}: {err}"
print(read_config("4"))
print(read_config("0"))
print(read_config("x"))
42
'banana' has no digits I can use
I need text, not that
25.0
ZeroDivisionError: division by zero
ValueError: invalid literal for int() with base 10: 'x'
as err gives you the exception object itself, which carries the message.
Printing type(err).__name__ and err is how you log something
useful rather than "an error occurred".
else and finally
def divide(a, b):
try:
result = a / b
except ZeroDivisionError:
print(" cannot divide by zero")
return None
else:
print(" no exception, so this ran")
return result
finally:
print(" finally always runs")
print(divide(10, 2))
print(divide(10, 0))
no exception, so this ran
finally always runs
5.0
cannot divide by zero
finally always runs
None
| Block | Runs when |
|---|---|
try | Always. The risky part |
except | Only if a matching exception was raised |
else | Only if no exception was raised |
finally | Always, exception or not, even after a return |
finally is for cleanup that must happen regardless: closing a connection,
releasing a lock, deleting a temporary file. Note in the output above that it ran even
though the function had already decided to return.
Keep the try block small
values = ["12", "banana", "7"]
# Too wide: the ValueError might come from anywhere in here
try:
total = 0
for v in values:
total += int(v)
average = total / len(values)
print(average)
except ValueError:
print("something in there was not a number, but which?")
# Better: the try wraps exactly the risky line
total = 0
skipped = []
for v in values:
try:
total += int(v)
except ValueError:
skipped.append(v)
print(f"total {total}, skipped {skipped}")
something in there was not a number, but which?
total 19, skipped ['banana']
Raising your own
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"age must be a whole number, got {type(age).__name__}")
if age < 0:
raise ValueError(f"age cannot be negative, got {age}")
return f"age set to {age}"
print(set_age(30))
for bad in [-5, "thirty"]:
try:
set_age(bad)
except (TypeError, ValueError) as err:
print(f"{type(err).__name__}: {err}")
age set to 30
ValueError: age cannot be negative, got -5
TypeError: age must be a whole number, got str
Raising early with a clear message is a kindness. The alternative is that the bad value travels three functions deep and explodes somewhere that gives no hint about where it came from. This is called failing fast, and it is one of the highest-value habits in software.
Your own exception types
class InsufficientGrogError(Exception):
"""Raised when a pirate cannot afford the round."""
def __init__(self, needed, available):
self.needed = needed
self.available = available
super().__init__(f"need {needed} mugs, only {available} left")
def serve_round(crew_size, barrels):
if crew_size > barrels:
raise InsufficientGrogError(crew_size, barrels)
return f"Served {crew_size} mugs."
print(serve_round(3, 10))
try:
serve_round(12, 4)
except InsufficientGrogError as err:
print(f"Caught: {err}")
print(f"Short by {err.needed - err.available}")
Served 3 mugs.
Caught: need 12 mugs, only 4 left
Short by 8
A custom exception is a class that inherits from Exception (classes are
Lesson 31; you can copy this shape until then). It lets callers catch your
specific problem without catching everything, and it can carry structured data about
what went wrong, which a plain string cannot.
Re-raising and chaining
def load_settings(raw):
try:
return int(raw)
except ValueError as err:
raise ValueError(f"settings file is corrupt: {raw!r}") from err
try:
load_settings("not-a-number")
except ValueError as err:
print(err)
print("caused by:", type(err.__cause__).__name__)
settings file is corrupt: 'not-a-number'
caused by: ValueError
raise ... from err adds context without losing the original. In a real
traceback you see both, joined by "The above exception was the direct cause of the
following exception". It turns a mysterious low-level error into a story.
When not to use exceptions
crew = {"Guybrush": 8}
# Clumsy
try:
insults = crew["Elaine"]
except KeyError:
insults = 0
# Just say what you mean
insults = crew.get("Elaine", 0)
print(insults)
0
Exceptions are for the exceptional. If a situation is normal and expected, handle it
with ordinary logic: .get(), an if, a default. A
try around every line is as unreadable as no error handling at all.
Bulletproof number input
Write ask_number that keeps asking until it gets a valid whole number between a low and high bound, explaining each rejection.
Reveal solution
def ask_number(prompt, low, high):
"""Ask until the human gives a whole number within range."""
while True:
raw = input(f"{prompt} ({low}-{high}): ")
try:
value = int(raw)
except ValueError:
print(f" '{raw}' is not a whole number. Digits only, please.")
continue
if not low <= value <= high:
print(f" {value} is outside {low} to {high}.")
continue
return value
age = ask_number("Your age", 1, 120)
print(f"Thank you, {age}.")
Your age (1-120): banana
'banana' is not a whole number. Digits only, please.
Your age (1-120): 500
500 is outside 1 to 120.
Your age (1-120): -3
-3 is outside 1 to 120.
Your age (1-120): 42
Thank you, 42.This tiny function is genuinely production-grade: it cannot be crashed by any input, and it explains every rejection. Steal it.
What is wrong with this?
Four separate sins. Name them.
def load(path):
try:
f = open(path)
data = f.read()
number = int(data)
return 100 / number
except:
passReveal solution
- Bare except. It catches typos, Ctrl+C and everything else.
passas the handler. The failure is now completely invisible; the function silently returns None and the caller has no idea why.- No
with. Ifint()raises, the file is never closed. - The try is far too wide. Three different failures (missing file, non-numeric contents, division by zero) are treated as one nameless event.
def load(path):
"""Return 100 divided by the number in path, or None with a reason."""
try:
with open(path, encoding="utf-8") as f:
data = f.read()
except FileNotFoundError:
print(f"no such file: {path}")
return None
try:
number = int(data.strip())
except ValueError:
print(f"{path} does not contain a whole number")
return None
if number == 0:
print(f"{path} contains zero, cannot divide by it")
return None
return 100 / number
print(load("nope.txt"))
no such file: nope.txt
NoneA custom exception with data
Write a Vault function that raises a custom WrongCombinationError carrying how many attempts remain, and a caller that reports it.
Reveal solution
class WrongCombinationError(Exception):
"""Raised when the vault combination is wrong."""
def __init__(self, attempts_left):
self.attempts_left = attempts_left
super().__init__(f"wrong combination, {attempts_left} attempts left")
def try_combination(guess, correct="1-2-3", attempts_used=0):
if guess != correct:
raise WrongCombinationError(3 - attempts_used - 1)
return "The vault swings open."
for attempt, guess in enumerate(["9-9-9", "1-2-3"]):
try:
print(try_combination(guess, attempts_used=attempt))
except WrongCombinationError as err:
print(f"Denied. {err.attempts_left} left.")
Denied. 2 left.
The vault swings open.