Exceptions

Medium35 min read

try / except / else / finally

Why Exceptions Matters

The Problem: Return codes get ignored. Quiet failures become silent corruption. Defensive `if`s clutter every call site.

The Solution: Exceptions separate the happy path from error handling, propagate up automatically until handled, and carry a stack trace that makes debugging tractable.

Real Impact: Python's try/except/else/finally + custom exception hierarchies + ExceptionGroups (3.11+) cover every flow you'll meet, from retries to structured concurrency.

Real-World Analogy

Think of exceptions as a fire alarm:

  • raise = pulling the alarm — work stops immediately
  • except = the responder who handles the specific kind of emergency
  • finally = the cleanup crew that runs whether or not there was a fire
  • else = the all-clear that only fires when nothing went wrong
  • Exception chain = the incident report tracing what caused the alarm
try:
    value = int(user_input)
except ValueError as e:
    print(f"not a number: {e}")
    value = 0
else:
    print(f"got {value}")        # only if no exception
finally:
    print("done")                 # always runs, even on return/raise

Catching Multiple Types

try:
    parse_and_save(data)
except (ValueError, TypeError) as e:
    log(f"bad data: {e}")
except OSError as e:
    log(f"io error: {e}")

⚠️ Never use bare except:

It catches KeyboardInterrupt, SystemExit, and MemoryError too — masking serious problems and making Ctrl-C unresponsive. Use except Exception: if you must, but always catch specifically.

Raising Exceptions

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("cannot divide by zero")
    return a / b

Re-raising and Chaining

try:
    open_db()
except OSError as e:
    # Explicit chain — preserves the original cause
    raise RuntimeError("database unavailable") from e

try:
    risky()
except Exception:
    log_failure()
    raise                       # re-raise the current exception

raise ... from None

Suppress the original traceback when wrapping. Useful when the original isn't helpful: raise UserNotFound() from None.

The Exception Hierarchy

All exceptions inherit from BaseException. The ones you usually care about inherit from Exception.

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 └── Exception                  # catch-most for normal code
      ├── ArithmeticError
      │    ├── ZeroDivisionError
      │    └── OverflowError
      ├── LookupError
      │    ├── KeyError          # dict[missing]
      │    └── IndexError        # list[out_of_range]
      ├── ValueError             # right type, wrong value
      ├── TypeError              # wrong type
      ├── AttributeError         # obj.missing
      ├── NameError              # undefined name
      ├── OSError                # I/O, file not found
      │    ├── FileNotFoundError
      │    └── PermissionError
      ├── RuntimeError
      │    └── RecursionError
      └── StopIteration

Catch the specific subclass first; broad catches are more permissive.

Custom Exceptions

class AppError(Exception):
    """Base class for application errors."""

class ValidationError(AppError):
    def __init__(self, field: str, reason: str):
        super().__init__(f"{field}: {reason}")
        self.field = field
        self.reason = reason

class NotFound(AppError):
    pass


# Callers can catch broadly or specifically
try:
    handle_request()
except ValidationError as e:
    log(f"validation: {e.field}")
except AppError as e:
    log(f"app error: {e}")

Always inherit from Exception

Not BaseException. Otherwise your error escapes except Exception: blocks that handle most things — confusing for callers.

EAFP vs LBYL

Pythonic style: Easier to Ask Forgiveness than Permission (EAFP) — just try the operation and handle failure. Compare to Look Before You Leap (LBYL) — check preconditions first.

# LBYL — race condition: file could disappear between check and open
import os
if os.path.exists(path):
    with open(path) as f:
        data = f.read()

# EAFP — atomic, no race
try:
    with open(path) as f:
        data = f.read()
except FileNotFoundError:
    data = ""

EAFP is also typically faster — no extra system call — and more correct under concurrency.

Exception Groups (Python 3.11+)

Raise and catch multiple exceptions at once — essential for structured concurrency in asyncio.

try:
    raise ExceptionGroup("validation failed", [
        ValueError("bad name"),
        TypeError("bad age type"),
    ])
except* ValueError as eg:
    print("value errors:", eg.exceptions)
except* TypeError as eg:
    print("type errors:", eg.exceptions)

The except* syntax handles each subgroup separately — both branches run when both error types are present.

🎯 Practice Exercises

Exercise 1: Safe parser

Write parse_int(s, default=0) that returns the integer or default on failure. Only catch ValueError and TypeError.

Exercise 2: Custom hierarchy

Design HttpError with subclasses NotFound, Forbidden, Unauthorized, each carrying a status code. Catch them generically.

Exercise 3: Retry with backoff

Write retry(fn, attempts=3, exc=Exception) that retries on exception with exponential backoff. Re-raise after final failure.

Exercise 4: EAFP refactor

Take a piece of if dict.get(k): ... code and convert to try / except KeyError. Discuss when each style is clearer.