Level 3 · Real Programs

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
PARANOIA[Formidable: Success]

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
BlockRuns when
tryAlways. The risky part
exceptOnly if a matching exception was raised
elseOnly if no exception was raised
finallyAlways, 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.

Exercise 1

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.

Exercise 2

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:
        pass
Reveal solution
  1. Bare except. It catches typos, Ctrl+C and everything else.
  2. pass as the handler. The failure is now completely invisible; the function silently returns None and the caller has no idea why.
  3. No with. If int() raises, the file is never closed.
  4. 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
None
Exercise 3

A 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.
+100 XP