Skip to content

Error Handling Patterns

Python exceptions form a class hierarchy rooted at BaseException. Understanding this hierarchy is Essential for writing correct exception handlers.

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── StopIteration
├── StopAsyncIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── FloatingPointError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ ├── IsADirectoryError
│ ├── NotADirectoryError
│ ├── FileExistsError
│ ├── ConnectionError
│ │ ├── ConnectionRefusedError
│ │ ├── ConnectionResetError
│ │ ├── ConnectionAbortedError
│ │ └── BrokenPipeError
│ ├── TimeoutError
│ └── ProcessLookupError
├── TypeError
├── ValueError
├── AttributeError
├── RuntimeError
│ ├── NotImplementedError
│ └── RecursionError
├── NameError
│ └── UnboundLocalError
├── ImportError
│ └── ModuleNotFoundError
└── AssertionError
### Catching by Hierarchy
try:
value = int("not a number")
except ValueError as e:
print(f"ValueError: {e}") # Catches ValueError specifically
try:
d = {}
_ = d["missing"]
except LookupError as e:
print(f"LookupError: {e}") # Catches both KeyError and IndexError
try:
pass
except Exception as e:
print(f"Caught: {e}") # Catches all standard exceptions, not SystemExit/KeyboardInterrupt

Every library or application should define a custom base exception class:

class AppError(Exception):
"""Base exception for all application errors."""
def __init__(self, message, *, code=None, details=None):
super().__init__(message)
self.code = code
self.details = details or {}
def __str__(self):
msg = super().__str__()
if self.code:
return f"[{self.code}] {msg}"
return msg
class ConfigError(AppError):
"""Configuration-related errors."""
class NetworkError(AppError):
"""Network-related errors."""
class DatabaseError(AppError):
"""Database-related errors."""
class ValidationError(AppError):
"""Input validation errors."""
class ConfigLoader:
def load(self, path):
try:
with open(path) as f:
return self._parse(f)
except FileNotFoundError as e:
raise ConfigError(f"Config file not found: {path}", code="CONFIG_NOT_FOUND") from e
except ValueError as e:
raise ConfigError(f"Invalid config syntax in {path}", code="CONFIG_PARSE_ERROR") from e
def _parse(self, f):
# Simulate parsing
raise ValueError("Unexpected token at line 42")
class ServerError(Exception):
def __init__(self, host, port, reason):
self.host = host
self.port = port
self.reason = reason
super().__init__(f"{host}:{port}{reason}")
def __repr__(self):
return f"ServerError({self.host!r}, {self.port!r}, {self.reason!r})"
e = ServerError("db.example.com", 5432, "connection refused")
print(str(e)) # db.example.com:5432 — connection refused
print(repr(e)) # ServerError("db.example.com', 5432, 'connection refused')
## EAFP vs LBYL

EAFP: Easier to Ask Forgiveness than Permission

Section titled “EAFP: Easier to Ask Forgiveness than Permission”

The Pythonic approach — try the operation and handle exceptions:

## EAFP — try and handle
def get_value(data, key, default=None):
try:
return data[key]
except (KeyError, TypeError, IndexError):
return default
print(get_value({"a": 1}, "a")) # 1
print(get_value({"a": 1}, "b")) # None
print(get_value([1, 2, 3], 1)) # 2
print(get_value(42, "x")) # None

Check conditions before operating:

## LBYL — check first
def get_value_lbyl(data, key, default=None):
if isinstance(data, dict) and key in data:
return data[key]
if isinstance(data, (list, tuple)) and isinstance(key, int) and 0 <= key < len(data):
return data[key]
return default
print(get_value_lbyl({"a": 1}, "a")) # 1
print(get_value_lbyl({"a": 1}, "b")) # None
ScenarioPreferReason
File existenceEAFP (open + except)TOCTOU race condition with LBYL
Dict key accessEAFP (try/except KeyError)Cleaner, idiomatic
Type checkingLBYL (isinstance)Wrong types are programmer errors
External API callsEAFP + retryNetwork conditions change
Configuration validationLBYL at boundaryFail fast, clear error messages