The Exception Handling lesson covered try/except mechanics; this lesson covers designing your OWN exception types. A well-designed custom exception hierarchy turns 'something went wrong' into a precise, structured, catchable signal that carries exactly the context a caller needs to respond correctly.
1Beyond a Message: Exceptions as Structured Data Carriers
A minimal custom exception ā class InsufficientFundsError(Exception): """docstring""" ā is already meaningfully better than raise ValueError("insufficient funds"), because it gives the failure a distinct, catchable *type* rather than relying on callers to string-match a generic exception's message text to figure out what actually happened. except InsufficientFundsError: is precise and robust; except ValueError as e: if "insufficient" in str(e): is fragile and breaks the moment the message wording changes even slightly.
The next level of design maturity is attaching actual structured data to the exception instance, not just a formatted message string ā self.requested, self.available, self.shortfall in the example are real, typed attributes that calling code can read directly, compute with, and log as structured fields, rather than needing to parse a human-readable sentence to extract the same information programmatically. super().__init__(message) still sets the human-readable message (used by str(e) and shown in an unhandled traceback), but the exception now serves two audiences simultaneously: a human reading a log or traceback, and calling code that needs to *act* on the specific failure details.
This design ā custom type, informative message, and structured attributes ā mirrors how well-designed exceptions look across mature codebases and popular libraries (requests.exceptions.HTTPError carries a .response attribute with the actual failed response, for instance): the exception isn't just a signal that something failed, it's a rich, purpose-built data object describing precisely *how* it failed.
class InsufficientFundsError(Exception):
"""Raised when an account doesn't have enough balance for a withdrawal."""
def withdraw(balance: float, amount: float) -> float:
if amount > balance:
raise InsufficientFundsError(f"Cannot withdraw {amount}, balance is {balance}")
return balance - amountDirectly usable data, not something extracted from a message string
2Precise Catching: Different Failures, Different Responses
The entire point of designing multiple, specific custom exception types (InsufficientFundsError, AccountFrozenError, and others a real system might need) rather than raising a single generic exception for every failure mode, is enabling calling code to respond *differently* to genuinely different situations. A generic except Exception: forces a single, undifferentiated response ā or, worse, forces the calling code to inspect the exception's message text to guess which specific failure occurred, exactly the fragile string-matching pattern precise exception types exist to eliminate.
With InsufficientFundsError and AccountFrozenError as distinct types, except InsufficientFundsError as e: suggest_amount = ... and except AccountFrozenError: show_error(...) each handle their specific situation with logic tailored to it ā a shortfall suggestion makes sense for insufficient funds and makes no sense at all for a frozen account, and precise exception types let the code express that difference directly in its structure rather than through nested conditionals inside one giant except block.
This is directly connected to the SOLID principles covered earlier in this curriculum: precise, well-typed exceptions are effectively a small, purpose-built interface between the code that detects a failure and the code that needs to respond to it ā an application, in the failure-handling domain, of the same 'depend on a precise abstraction, not a vague generic one' philosophy that motivated Protocols and small interfaces in the Object-Oriented Design section.
class InsufficientFundsError(Exception):
def __init__(self, requested: float, available: float):
self.requested = requested
self.available = available
self.shortfall = requested - available
super().__init__(
f"Requested {requested}, only {available} available (short by {self.shortfall})"
)
try:
withdraw(balance=50, amount=200)
except InsufficientFundsError as e:
print(f"Need {e.shortfall} more to complete this withdrawal") # structured accessEach failure type: its own precise, appropriate response
3When a Custom Exception Is Worth Creating (and When It Isn't)
Not every possible failure needs its own bespoke exception class ā for a truly generic, one-off validation inside a small, self-contained function, raise ValueError("invalid input") remains entirely appropriate and adding ceremony around it would be over-engineering. The signal that a custom exception is worth creating is when a failure is meaningful enough, at an API boundary, that *callers outside the function itself* genuinely need to distinguish it from other failures and respond differently ā precisely the situation InsufficientFundsError versus AccountFrozenError represents for a banking system's public interface.
A related judgment call is whether to inherit from a specific built-in exception (class InvalidAmountError(ValueError):) or plain Exception ā inheriting from a relevant built-in lets existing code that already catches that built-in type (except ValueError:) continue working unmodified even after your custom exception is introduced, a form of backward compatibility worth considering deliberately, especially in a library other code depends on. Inheriting directly from Exception is appropriate when the failure doesn't naturally correspond to any existing built-in category.
The professional habit this builds: design custom exceptions at genuine API/module boundaries where callers need to differentiate and respond to specific failure modes, attach the structured data those callers will actually need, and resist the urge to create a bespoke exception class for every single raise statement in purely internal, non-boundary code where a standard built-in exception communicates the failure just as well with less ceremony.
try:
withdraw(account.balance, requested_amount)
except InsufficientFundsError as e:
suggest_amount = account.balance
show_error(f"Try withdrawing {suggest_amount} or less (short by {e.shortfall})")
except AccountFrozenError:
show_error("This account is frozen ā contact support")
# Each exception type gets a SPECIFIC, appropriate responseBuilt-in exception, fine: internal, one-off validation
4Step-by-Step Breakdown
raise ValueError('invalid input') tells a caller almost nothing actionable. A well-designed custom exception can carry exactly what went wrong, and let callers respond precisely ā let's build one.
A custom exception is just a class inheriting from Exception (or a more specific built-in) -- often needing nothing more than a docstring to be useful.
Attaching structured data (not just a message string) lets calling code programmatically inspect exactly what went wrong, not just read it.
Checkpoint: Why attach structured fields (like .shortfall) to a custom exception instead of only a formatted message string?
- āIt lets calling code programmatically use the specific failure data (e.g. e.shortfall) instead of parsing a message string
- āIt makes the exception raise and propagate faster
Catching a precise custom exception lets you respond differently than a generic except Exception would ever allow.
Checkpoint: What does catching InsufficientFundsError and AccountFrozenError as SEPARATE except clauses enable that a single generic except Exception could not?
- āEach specific failure type gets its own appropriate, differentiated response
- āIt runs measurably faster than a single generic except clause
Custom exceptions are the building blocks; Exception Hierarchy covers how to organize many of them into a coherent, catchable structure.
Raise a Real Custom Exception. Finish withdraw(): a custom exception tells the caller exactly what went wrong.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Attach structured attributes to custom exceptions, not just a formatted message
Calling code should be able to read e.shortfall or e.field_name directly, rather than parsing a human-readable message string to extract the same information programmatically.
Create custom exceptions at genuine API boundaries, not for every internal raise statement
A custom exception earns its ceremony when external callers genuinely need to distinguish and respond to it differently ā for purely internal, one-off validation, a standard built-in exception is often perfectly sufficient.
Frequent Bugs
Raising a generic Exception or ValueError for a domain-specific failure that callers actually need to distinguish and respond to differently, forcing fragile string-matching on the error message to differentiate cases.
Create a specific custom exception class for any failure mode that callers at an API boundary genuinely need to catch and handle distinctly from other failures.
Real-World Examples
A Custom Exception Carrying Validation Context for an API
A REST API needs to return a specific, structured error response (field name, invalid value, and reason) whenever request validation fails, rather than a generic 400 error with no actionable detail.
class ValidationError(Exception):
def __init__(self, field: str, value, reason: str):
self.field = field
self.value = value
self.reason = reason
super().__init__(f"Invalid {field}: {reason} (got {value!r})")
def handle_request(data: dict):
if not data.get("email"):
raise ValidationError("email", data.get("email"), "email is required")
try:
handle_request(request_data)
except ValidationError as e:
return {"error": {"field": e.field, "reason": e.reason}}, 400