Error Handling in Python
Handle exceptions gracefully with try/except/finally, create custom exceptions, and use logging and traceback effectively.
The Exception Hierarchy
Python’s built-in exceptions form a class hierarchy. Catching a parent class catches all its children.
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError (ZeroDivisionError, OverflowError)
├── LookupError (IndexError, KeyError)
├── ValueError
├── TypeError
├── AttributeError
├── FileNotFoundError
├── OSError
└── RuntimeError
try / except / else / finally
def read_config(path: str) -> dict:
try:
with open(path) as f:
import json
return json.load(f)
except FileNotFoundError:
print(f"Config file not found: {path}")
return {}
except json.JSONDecodeError as e:
print(f"Invalid JSON in {path}: {e}")
return {}
else:
# Runs only if no exception was raised
print("Config loaded successfully")
finally:
# Always runs — cleanup goes here
print("Done reading config")
Catching Multiple Exceptions
try:
result = int(user_input) / divisor
except (ValueError, ZeroDivisionError) as e:
print(f"Input error: {e}")
Re-raising Exceptions
def process_payment(amount):
try:
charge_card(amount)
except TimeoutError:
log_failure("payment timeout")
raise # re-raise the original exception with original traceback
# Or raise a different exception, chaining the original
def load_user(user_id):
try:
return db.query(user_id)
except DatabaseError as e:
raise ValueError(f"Invalid user ID: {user_id}") from e
Custom Exceptions
Define custom exceptions to give callers precise error types to handle:
class AppError(Exception):
"""Base class for application exceptions."""
pass
class ValidationError(AppError):
def __init__(self, field: str, message: str):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
class NotFoundError(AppError):
def __init__(self, resource: str, id_: int):
self.resource = resource
self.id = id_
super().__init__(f"{resource} with id={id_} not found")
# Usage
def get_user(user_id: int):
if user_id <= 0:
raise ValidationError("user_id", "must be positive")
user = db.find(user_id)
if user is None:
raise NotFoundError("User", user_id)
return user
# Caller handles specific cases
try:
user = get_user(-1)
except ValidationError as e:
return {"error": str(e), "field": e.field}, 400
except NotFoundError as e:
return {"error": str(e)}, 404
Exception Groups (Python 3.11+)
Handle multiple concurrent exceptions from async tasks:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_data())
tg.create_task(fetch_metadata())
except* ValueError as eg:
for exc in eg.exceptions:
print(f"Value error: {exc}")
except* TimeoutError as eg:
print(f"{len(eg.exceptions)} tasks timed out")
Logging vs print
Never use print() for errors in production code. Use the logging module — it gives you levels, timestamps, file output, and structured context.
import logging
# Configure once at application startup
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.StreamHandler(), # stderr
logging.FileHandler("app.log", encoding="utf-8"), # file
]
)
logger = logging.getLogger(__name__)
def process_order(order_id: int):
logger.info("Processing order %d", order_id)
try:
result = do_work(order_id)
logger.debug("Order %d result: %s", order_id, result)
return result
except Exception:
logger.exception("Failed to process order %d", order_id)
raise
logger.exception() logs at ERROR level and automatically appends the full traceback — the most useful single call for error reporting.
Structured Logging with structlog
For production services, structured (JSON) logs are easier to query in tools like Datadog or CloudWatch:
import structlog
log = structlog.get_logger()
log.info("order.processed", order_id=42, amount=99.99, duration_ms=123)
# {"event": "order.processed", "order_id": 42, "amount": 99.99, "duration_ms": 123}
traceback Module
import traceback
try:
1 / 0
except ZeroDivisionError:
# Get traceback as string
tb_str = traceback.format_exc()
print(tb_str)
# Print to stderr
traceback.print_exc()
# Get structured traceback info
exc_type, exc_value, exc_tb = sys.exc_info()
frames = traceback.extract_tb(exc_tb)
for frame in frames:
print(f"{frame.filename}:{frame.lineno} in {frame.name}")
Context Managers for Cleanup
The contextlib.suppress context manager silently ignores specific exceptions:
from contextlib import suppress
import os
# Instead of try/except just for cleanup:
with suppress(FileNotFoundError):
os.remove("temp_file.txt")
Best Practices
Be specific — catch the narrowest exception type that makes sense:
# Too broad — hides bugs
try:
result = process(data)
except Exception:
return None
# Better
try:
result = process(data)
except (ValueError, KeyError) as e:
logger.warning("Invalid data: %s", e)
return None
Don’t swallow exceptions silently. At minimum, log them:
# Silent swallow — very bad
try:
send_email(user)
except Exception:
pass
# Acceptable
try:
send_email(user)
except Exception:
logger.exception("Failed to send email to %s", user.email)
Use finally for resource cleanup — or better, use context managers (with statements) which call __exit__ automatically.
Add context when re-raising:
try:
parse_config(path)
except json.JSONDecodeError as e:
raise ConfigError(f"Malformed config at {path}") from e
The from e preserves the original exception as __cause__, which Python displays in the traceback.