Introduction
Python's error handling reads deceptively simply — try, except, done. But the difference between code that fails clearly and code that fails mysteriously comes down to a handful of habits: catching the right exception, preserving the original cause, and knowing when not to raise at all.
This post covers the practices that keep Python failures debuggable in production.
Prefer EAFP over Look-Before-You-Leap
Python idiom favors EAFP — "Easier to Ask Forgiveness than Permission." Rather than checking whether an operation will succeed, attempt it and handle the failure:
# LBYL — racy, verbose, and easy to get subtly wrong
if "user" in data and data["user"] is not None:
name = data["user"].name
# EAFP — Pythonic, and free of the time-of-check/time-of-use gap
try:
name = data["user"].name
except (KeyError, AttributeError):
name = "unknown"
Beyond being idiomatic, EAFP avoids the race window between the check and the use — the check can pass and then the state change before you act on it.
Catch Narrow Exceptions, Never Bare except
The most damaging habit in Python error handling is the bare except: (or except Exception: used as a catch-all). It swallows everything — including KeyboardInterrupt, SystemExit, and the NameError from your own typo.
# DON'T: hides bugs and can't be interrupted with Ctrl-C
try:
result = process(payload)
except:
result = None
# DO: name exactly what you expect and can handle
try:
result = process(payload)
except ValueError as exc:
logger.warning("invalid payload, skipping: %s", exc)
result = None
Catch the specific exceptions a block can actually recover from. Anything else should propagate — an unexpected exception is information, and hiding it turns a clear crash into a silent wrong answer.
Preserve the Cause with raise ... from
When you catch a low-level exception and raise a domain-specific one, chain them explicitly. Python then prints both tracebacks with a clear "The above exception was the direct cause of the following":
class OrderError(Exception):
"""Domain-level failure while processing an order."""
def place_order(order_id: int) -> Order:
try:
return repository.load(order_id)
except DatabaseError as exc:
raise OrderError(f"could not load order {order_id}") from exc
The from exc is what keeps the root cause visible. Dropping it (a bare raise OrderError(...)) throws away the original traceback — the exact thing you need at 3 a.m.
Build a Small Exception Hierarchy
Define your own exception types, rooted at a single base, so callers can catch at whatever granularity they need:
class AppError(Exception):
"""Base for all application errors."""
class ValidationError(AppError):
"""Input failed validation."""
class ExternalServiceError(AppError):
"""A downstream dependency failed."""
Now a caller can except ValidationError for precise handling, or except AppError to catch anything the application deliberately raises — while still letting truly unexpected exceptions (a KeyError from a bug) surface loudly.
Don't Raise for Expected Outcomes — Return Instead
Exceptions are for the exceptional. When a "failure" is a routine, expected result — a lookup that finds nothing, validation of user input — returning a value is often clearer and faster than raising:
from dataclasses import dataclass
@dataclass
class ParseResult:
value: int | None
error: str | None
def parse_port(raw: str) -> ParseResult:
try:
port = int(raw)
except ValueError:
return ParseResult(None, f"not a number: {raw!r}")
if not 1 <= port <= 65535:
return ParseResult(None, f"port out of range: {port}")
return ParseResult(port, None)
Returning None for a not-found lookup, or a small result object for parsing, keeps the happy path free of try/except noise and makes the failure a normal branch instead of hidden control flow.
Clean Up with Context Managers
Any resource — files, connections, locks — should be managed with a with block so cleanup happens even when an exception is raised mid-operation:
with open