Bad error handling hides bugs. Good error handling makes debugging easy. Here's how to do it right.
The Basics
try:
result = risky_operation()
except SomeError:
handle_error()Simple enough. The problems start with the details.
Never Catch Bare Exceptions
# Bad - catches everything, including bugs
try:
process_data()
except:
pass
# Also bad
try:
process_data()
except Exception:
passThis catches KeyboardInterrupt, SystemExit, and actual bugs like TypeError. You'll never know something went wrong.
Catch Specific Exceptions
# Good - catch what you expect
try:
response = httpx.get(url)
response.raise_for_status()
except httpx.ConnectError:
logger.error("Could not connect to server")
return None
except httpx.TimeoutException:
logger.error("Request timed out")
return None
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error: {e.response.status_code}")
return NoneEach exception type gets appropriate handling.
Use Finally for Cleanup
file = open("data.txt")
try:
process(file)
finally:
file.close() # Always runs, even if exceptionOr better, use context managers:
with open("data.txt") as file:
process(file)
# Automatically closedThe Else Clause
Code in else runs only if no exception occurred:
try:
result = parse(data)
except ParseError:
logger.error("Failed to parse")
result = None
else:
# Only runs if parse succeeded
logger.info(f"Parsed: {result}")
save(result)Keeps the "happy path" separate from error handling.
Create Custom Exceptions
# Define your own hierarchy
class AppError(Exception):
"""Base exception for this application."""
pass
class ValidationError(AppError):
"""Input validation failed."""
pass
class NotFoundError(AppError):
"""Resource not found."""
pass
class AuthenticationError(AppError):
"""Authentication failed."""
passUsage:
def get_user(user_id):
user = db.find_user(user_id)
if not user:
raise NotFoundError(f"User {user_id} not found")
return userAdd Context to Exceptions
class ValidationError(Exception):
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
# Usage
raise ValidationError("email", "Invalid email format")
# Catching
except ValidationError as e:
print(f"Field '{e.field}' failed: {e.message}")Re-raising Exceptions
Preserve the original traceback:
try:
process()
except SomeError:
logger.error("Processing failed")
raise # Re-raises with original tracebackWrap with context:
try:
process(user_data)
except ValidationError as e:
raise ProcessingError(f"Failed to process user") from eThe from e chains the exceptions, showing both in the traceback.
Fail Fast
Don't hide errors early in the process:
# Bad - continues with bad data
def process_users(users):
results = []
for user in users:
try:
results.append(transform(user))
except:
pass # Silently skip
return results
# Good - fail immediately
def process_users(users):
return [transform(user) for user in users]
# If one fails, caller knows immediatelyGraceful Degradation
Sometimes you want to continue despite errors:
def fetch_all_data(sources):
results = []
errors = []
for source in sources:
try:
results.append(fetch(source))
except FetchError as e:
errors.append((source, e))
logger.warning(f"Failed to fetch {source}: {e}")
if errors:
logger.warning(f"{len(errors)} sources failed")
return results, errorsReturn both results and errors. Let the caller decide.
Logging Exceptions
import logging
logger = logging.getLogger(__name__)
try:
process()
except ProcessError:
logger.exception("Processing failed")
# .exception() includes the full tracebackUse logger.exception() inside except blocks—it automatically includes the traceback.
Validation Patterns
Validate early, fail with clear messages:
def create_user(name, email, age):
errors = []
if not name:
errors.append("Name is required")
if not email or "@" not in email:
errors.append("Valid email is required")
if not isinstance(age, int) or age < 0:
errors.append("Age must be a positive integer")
if errors:
raise ValidationError(errors)
return User(name=name, email=email, age=age)API Error Handling
Return appropriate responses:
from fastapi import HTTPException
def get_user(user_id: int):
try:
return user_service.get(user_id)
except NotFoundError:
raise HTTPException(status_code=404, detail="User not found")
except AuthenticationError:
raise HTTPException(status_code=401, detail="Not authenticated")
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e))Testing Exceptions
import pytest
def test_raises_on_invalid_input():
with pytest.raises(ValidationError) as exc_info:
validate("")
assert "required" in str(exc_info.value)
def test_raises_correct_type():
with pytest.raises(NotFoundError):
get_user(99999)My Guidelines
- Catch specific exceptions — never bare except
- Fail fast — don't hide errors
- Add context — make debugging easy
- Use custom exceptions — for domain errors
- Log at boundaries — API handlers, job runners
- Re-raise unknown errors — don't swallow bugs
- Test error paths — they matter
Handle errors like they'll happen in production at 3 AM. Because they will.