Custom Exception Hierarchies
Domain exceptions group related failures so callers catch at the right granularity. A shallow tree under one package-specific base beats dozens of unrelated Exception subclasses.
Search across all documentation pages
Domain exceptions group related failures so callers catch at the right granularity. A shallow tree under one package-specific base beats dozens of unrelated Exception subclasses.
class AppError(Exception):
"""Base for all service errors."""
class ValidationError(AppError):
def __init__(self, field: str, message: str) -> None:
self.field = field
super().__init__(f"{field}: {message}")When to reach for this:
class BillingError(Exception):
"""Raised for billing subsystem failures."""
class CardDeclined(BillingError):
def __init__(self, last4: str, reason: str) -> None:
self.last4 = last4
self.reason = reason
super().__init__(f"card *{last4} declined: {reason}")
class InsufficientFunds(CardDeclined):
pass
class InvoiceError(BillingError):
pass
def charge(card_last4: str, amount: float) -> None:
if amount > 1000:
raise InsufficientFunds(card_last4, "insufficient funds")
if amount < 0:
raise InvoiceError("negative amount")
def handle_payment() -> str:
try:
charge("4242", 2000)
except CardDeclined as exc:
return f"retry with new card ({exc.reason})"
except BillingError:
return "billing unavailable"
return "ok"
print(handle_payment())What this demonstrates:
BillingError catches any billing failureCardDeclined adds fields for handlers and logsException (not BaseException) for application errors__init__ before super().__init__(message)| Layer | Base | Example |
|---|---|---|
| Library | LibraryError | ConnectionTimeout |
| App domain | AppError | OrderNotFound |
| HTTP adapter | Map to status | 404, 422, 503 |
except Exception masking bugs - too broad handlers hide KeyboardInterrupt if mis-scoped. Fix: catch your base, then specific types.field, code).from exc.| Alternative | Use When | Don't Use When |
|---|---|---|
| errno-style codes | Stable machine-readable API | Pythonic internal services |
| Pydantic ValidationError | Input parsing only | Business rule violations |
| Result types | Pure functional modules | Framework expects raises |
One per bounded context (billing, auth, inventory) plus optional app-wide base if you need a universal catch in main().
Rarely for domain errors. Subclass your domain base so handlers do not catch unrelated ValueErrors.
Register handlers: @app.exception_handler(CardDeclined) returns 402 with structured body.
Yes in 3.11+ with dataclass on Exception subclasses - ensure __init__ calls super correctly.
Noun phrases for state (UserNotFound) or verb past tense for events (PaymentDeclined).
Yes in __all__ - public API surface includes stable exception types.
Introduce base, wrap raises at boundaries, deprecate bare Exception over releases.
Concurrent failures use groups; domain hierarchies still apply inside leaf exceptions.
Add .code: str when clients branch programmatically; class type alone often suffices in Python services.
pytest.raises(BillingError) and assert isinstance(exc.value, CardDeclined) for specialization.
fromStack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.
Reviewed by Chris St. John·Last updated Jul 19, 2026