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.
Recipe
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:
- HTTP/API layers map exception types to status codes
- Callers need to catch all payment errors but not all app errors
- Libraries export a stable public exception base
- Metrics/alerts label by exception class
- Tests assert specific failure types
Working Example
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:
- Package base
BillingErrorcatches any billing failure CardDeclinedadds fields for handlers and logs- Subclasses specialize without duplicating handler logic
- Separate branches for recoverable vs generic billing errors
Deep Dive
How It Works
- Subclass
Exception(notBaseException) for application errors - One module-level or package-level base per bounded context
- Attach attributes on
__init__beforesuper().__init__(message) - Keep hierarchy shallow (base + 2-3 levels)
- Document which exceptions cross package boundaries
Hierarchy Guidelines
| Layer | Base | Example |
|---|---|---|
| Library | LibraryError | ConnectionTimeout |
| App domain | AppError | OrderNotFound |
| HTTP adapter | Map to status | 404, 422, 503 |
Gotchas
- Catch-all
except Exceptionmasking bugs - too broad handlers hideKeyboardInterruptif mis-scoped. Fix: catch your base, then specific types. - Deep inheritance trees - behavior duplicated across siblings. Fix: composition of fields, not subclass stacks.
- String-only errors - parsing message text in handlers. Fix: structured attributes (
field,code). - Leaking internal exceptions - DB driver errors reach API clients. Fix: wrap at boundary with
from exc. - Empty exception classes without docs - maintainers guess when to raise. Fix: docstring per class with raise conditions.
Alternatives
| 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 |
FAQs
How many base exceptions should an app have?
One per bounded context (billing, auth, inventory) plus optional app-wide base if you need a universal catch in main().
Should I subclass ValueError?
Rarely for domain errors. Subclass your domain base so handlers do not catch unrelated ValueErrors.
How do HTTP frameworks use hierarchies?
Register handlers: @app.exception_handler(CardDeclined) returns 402 with structured body.
Are dataclass exceptions OK?
Yes in 3.11+ with dataclass on Exception subclasses - ensure __init__ calls super correctly.
How do I name exceptions?
Noun phrases for state (UserNotFound) or verb past tense for events (PaymentDeclined).
Should libraries re-export exceptions?
Yes in __all__ - public API surface includes stable exception types.
How do I migrate from generic Exception?
Introduce base, wrap raises at boundaries, deprecate bare Exception over releases.
What about ExceptionGroup?
Concurrent failures use groups; domain hierarchies still apply inside leaf exceptions.
Do I need unique codes?
Add .code: str when clients branch programmatically; class type alone often suffices in Python services.
How do I test hierarchies?
pytest.raises(BillingError) and assert isinstance(exc.value, CardDeclined) for specialization.
Related
- Raising & Chaining - wrap with
from - Exception Groups & except* - concurrent errors
- Exceptions Basics - catch ordering
- Errors & Logging Best Practices - fail loudly
Stack 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+.