Custom Exceptions in Python
Error Handling
Designing Custom Exceptions
Name your errors, add context, and keep your code understandable.
Why custom exceptions
except ValueError catches every ValueError — yours, the standard library's, and your dependencies'. A custom type lets callers catch exactly your failure mode and nothing else. It also turns tracebacks into documentation: CardDeclined: insufficient funds explains itself; a generic RuntimeError does not.
The minimal version
A class that inherits from Exception, with a docstring. That is the whole recipe:
class CardDeclined(Exception):
"""Raised when the payment provider declines a card."""
raise CardDeclined("insufficient funds")
# CardDeclined: insufficient funds
The message you pass at the raise site works exactly like a built-in's. On naming: most exception names end in Error (ConfigError, TimeoutError), mirroring the standard library; event-style names like CardDeclined are fine when they read more naturally.
A small hierarchy
Group related failures under a shared base class so callers can be as precise or as broad as they need:
class BillingError(Exception):
"""Base exception for billing failures."""
class CardDeclined(BillingError):
pass
At application scale, one root plus a child per subsystem covers most needs — catching the base catches every child:
class AppError(Exception):
"""Base class for every error this application raises."""
class ConfigError(AppError):
"""Configuration is missing or invalid."""
class NetworkError(AppError):
"""A remote call failed."""
try:
raise ConfigError("DATABASE_URL is not set")
except AppError as e: # matches ConfigError too
print(f"{type(e).__name__}: {e}")
# ConfigError: DATABASE_URL is not set
This is ordinary inheritance — except matches an exception class or any of its subclasses.
Adding metadata
Store structured context (IDs, reasons) as attributes, and always call super().__init__ so str(e), e.args, and pickling keep working:
class CardDeclined(BillingError):
def __init__(self, *, user_id: str, reason: str):
self.user_id = user_id
super().__init__(f"Card declined: {reason}")
try:
raise CardDeclined(user_id="u_42", reason="insufficient funds")
except CardDeclined as e:
print(e.user_id) # u_42
print(e) # Card declined: insufficient funds
Expose helpful attributes for logging and analytics — a handler can read e.user_id instead of parsing the message.
Catching custom exceptions
try:
process_payment(user_id)
except CardDeclined as exc:
logger.warning("Card declined for user %s: %s", exc.user_id, exc)
except BillingError:
logger.error("Billing failure")
Catch specific subclasses first, then broader base classes — Python runs the first matching handler, so a base class listed first would shadow the specific one (see try/except ordering).
Wrapping third-party exceptions
At a boundary — config loading, API clients, database access — translate low-level exceptions into your own so callers depend on your types, not your current dependencies:
import json
from pathlib import Path
def load_config(path):
try:
return json.loads(Path(path).read_text())
except FileNotFoundError as exc:
raise ConfigError(f"config file not found: {path}") from exc
except json.JSONDecodeError as exc:
raise ConfigError(f"invalid JSON in config file: {path}") from exc
from exc keeps the original traceback attached (exception chaining), so except ConfigError is all a caller ever writes, while the log still shows whether the root cause was a missing file (FileNotFoundError) or bad JSON. Swap JSON for TOML later and no caller changes.
Frequently Asked Questions
Should I inherit from Exception or BaseException?
Exception, always. BaseException is reserved for exceptions that should escape normal handling — KeyboardInterrupt and SystemExit live there so that "except Exception" cannot swallow Ctrl-C or shutdown. A custom error derived from BaseException would slip past every ordinary handler in your codebase, which is almost never what you want.
Do I need a whole hierarchy, or is one exception class enough?
Start with one class per distinct failure a caller might handle differently. Introduce a shared base class as soon as you have two or three related errors — it costs one line and lets callers choose between catching one failure mode or the whole family. Do not pre-build deep trees for errors nobody catches.
Where should custom exceptions live in my project?
A single dedicated module, conventionally exceptions.py (or errors.py), at the top of the package. Every other module imports from it, which prevents circular imports and gives users of your library one obvious place to discover what can go wrong.
# myapp/exceptions.py
class AppError(Exception):
"""Base for all myapp errors."""
class ConfigError(AppError):
"""Configuration is missing or invalid."""