Create custom exceptions¶
The question. A function in your project can fail in several distinct ways and you want callers to handle each failure differently — payment declined versus insufficient funds versus invalid order. Raising ValueError everywhere doesn't let callers tell them apart.
The answer: define a small exception hierarchy. A project-level base exception, with subclasses for each distinct failure mode. Callers catch the specific subclass they care about, or the base class to mop up anything from your module.
# A small hierarchy: one base, three specific failure modes.
# Callers can catch any subclass, or the base to handle all order failures.
class OrderError(Exception):
'''Base exception for all order-processing failures.'''
class OutOfStockError(OrderError):
'''Raised when a requested item isn't in stock in the quantity asked for.'''
def __init__(self, item: str, requested: int, available: int) -> None:
self.item = item
self.requested = requested
self.available = available
super().__init__(
f'{item}: requested {requested}, only {available} available'
)
class InvalidOrderError(OrderError):
'''Raised when an order's data is malformed.'''
class PaymentDeclinedError(OrderError):
'''Raised when a payment method was rejected.'''
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(f'Payment declined: {reason}')
def place_order(item: str, quantity: int, stock: int) -> str:
if quantity <= 0:
raise InvalidOrderError(f'quantity must be positive, got {quantity}')
if quantity > stock:
raise OutOfStockError(item, quantity, stock)
return f'Order placed: {quantity}x {item}'
# Specific handling — the caller inspects structured attributes on the exception.
try:
place_order('Keyboard', 5, 2)
except OutOfStockError as exc:
print(f'only {exc.available} of {exc.item} left — wanted {exc.requested}')
# Coarse handling — catch the base class for 'anything that could go wrong with orders'.
try:
place_order('Webcam', 0, 5)
except OrderError as exc:
print(f'order failed: {exc}')
Variant: a single __init__ that carries several attributes¶
For exceptions with more than one or two fields, a consistent __init__ pattern keeps the boilerplate tolerable. You can't cleanly use @dataclass on an Exception subclass, so this hand-rolled pattern is the usual style.
class HttpError(Exception):
'''Raised when an HTTP request fails.'''
def __init__(
self,
status_code: int,
url: str,
method: str = 'GET',
body: str | None = None,
) -> None:
self.status_code = status_code
self.url = url
self.method = method
self.body = body
super().__init__(f'{method} {url} returned {status_code}')
try:
raise HttpError(404, 'https://api.example.com/users/42')
except HttpError as exc:
print(f'Request failed: {exc}')
print(f'Status: {exc.status_code}, URL: {exc.url}')
Variant: verifying the hierarchy¶
Useful when you're not sure the subclass chain is what you think it is — especially after refactoring.
# Sanity checks — the built-in way to ask 'is this exception caught by that except clause?'
print(issubclass(OutOfStockError, OrderError)) # True
print(issubclass(OutOfStockError, Exception)) # True
print(issubclass(OrderError, OutOfStockError)) # False — parent isn't a child
Why this works¶
except OutOfStockError catches only that specific failure; except OrderError catches any subclass of it. Same mechanism Python uses for its own hierarchy — except OSError catches FileNotFoundError, PermissionError, and friends. Callers pick the level of granularity they actually need.
Putting structured data on the exception (item, requested, available) means the caller doesn't have to parse the message string to recover. That's the difference between a user-facing error UI that shows 'only 2 left' versus one that shows 'something went wrong'.
super().__init__(readable_message) is the important bit — it's what makes str(exc) sensible and what makes the default repr in a traceback say something useful. Without it you'd get OutOfStockError() and no hint about what happened.
Trade-offs¶
Don't design the hierarchy up front. Start with a single base exception plus one or two subclasses; add more as real callers say 'I need to handle X differently from Y'. A deep hierarchy you don't need is just clutter.
Keep names ending in Error — the standard-library convention. Avoid shadowing built-in names (ValueError, TimeoutError) even in your own namespace, because import confusion at the top of a file is easy to cause and annoying to debug.
For projects with many exceptions, put them all in a single exceptions.py (or errors.py) at the top of your package. One file to look at, one place to import from. The extra cell shows the hierarchy-style custom __init__ pattern that lets you carry several attributes without boilerplate.
Related reading¶
- Handle multiple exceptions — how callers catch the ones you raise.
- Avoid common error handling mistakes — naming, chaining, silent-swallow traps.
- Exception hierarchy reference — the built-in hierarchy to inherit from (or mirror).