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.