Error handling is where good code becomes great code. Here's what I've learned.
The Golden Rule
Only catch exceptions you can handle.
# Bad: catching everything
try:
result = risky_operation()
except Exception:
pass # Now you'll never know what went wrong
# Good: catching specific exceptions
try:
result = risky_operation()
except ConnectionError:
result = cached_fallback()If you can't meaningfully handle an exception, let it propagate.
Catch Specific, Not General
# Bad
try:
data = json.loads(response.text)
user = data["user"]
email = user["email"]
except Exception as e:
logger.error(f"Failed: {e}")
# Good
try:
data = json.loads(response.text)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON: {e}")
raise
try:
email = data["user"]["email"]
except KeyError as e:
logger.error(f"Missing field: {e}")
raise ValueError(f"Response missing required field: {e}")Specific catches tell you exactly what went wrong. Generic catches hide bugs.
Custom Exceptions
Create domain-specific exceptions:
class TaskError(Exception):
"""Base exception for task operations."""
pass
class TaskNotFoundError(TaskError):
"""Raised when a task doesn't exist."""
def __init__(self, task_id: str):
self.task_id = task_id
super().__init__(f"Task not found: {task_id}")
class TaskAlreadyCompleteError(TaskError):
"""Raised when trying to complete an already-complete task."""
passNow callers can catch TaskError for any task issue, or specific subclasses for specific handling.
When to Propagate
Let exceptions bubble up when:
- The caller is better positioned to handle them
- You're in a library (don't make decisions for the user)
- The error is unrecoverable at your level
def fetch_user(user_id: str) -> User:
response = requests.get(f"/users/{user_id}")
response.raise_for_status() # Propagate HTTP errors
return User(**response.json())The caller decides whether to retry, show an error, or log and continue.
When to Transform
Transform low-level exceptions into domain exceptions:
def save_task(task: Task) -> None:
try:
db.execute("INSERT INTO tasks ...", task.to_dict())
except sqlite3.IntegrityError as e:
raise TaskAlreadyExistsError(task.id) from e
except sqlite3.OperationalError as e:
raise TaskStorageError(f"Database error: {e}") from eThe from e preserves the original exception in the chain. Debuggers and logs show both.
Context Managers for Cleanup
Use try/finally or context managers for cleanup:
# Manual cleanup
file = open("data.txt")
try:
process(file)
finally:
file.close() # Always runs
# Better: context manager
with open("data.txt") as file:
process(file) # File closes automaticallyContext managers are cleaner and harder to mess up.
The else Clause
else runs only if no exception was raised:
try:
result = parse(data)
except ParseError:
result = default_value
else:
# Only runs if parse succeeded
log_success(result)
finally:
# Always runs
cleanup()Use else to separate "happy path" code from error handling.
Logging Exceptions
Log the full exception, not just the message:
# Bad: loses stack trace
except Exception as e:
logger.error(f"Error: {e}")
# Good: preserves stack trace
except Exception as e:
logger.exception("Operation failed") # Logs full traceback
# Also good: explicit
except Exception as e:
logger.error("Operation failed", exc_info=True)Validation vs Exceptions
Use exceptions for exceptional conditions, not control flow:
# Bad: using exceptions for validation
def get_user(user_id: str) -> User:
if not user_id:
raise ValueError("user_id required")
...
# Better: validate early, return clear errors
def get_user(user_id: str) -> User | None:
if not user_id:
return None
...
# Or use explicit result types
from dataclasses import dataclass
@dataclass
class Result[T]:
value: T | None
error: str | NoneMy Patterns
-
Fail fast. Validate inputs at boundaries. Don't let bad data propagate.
-
Be specific. Catch
KeyError, notException. -
Add context. Transform exceptions with meaningful messages.
-
Preserve chains. Use
raise ... from e. -
Log properly. Use
logger.exception()for full tracebacks. -
Clean up always. Use
finallyor context managers.
The goal: when something fails, make it obvious what failed and why.