The built-in Python exception hierarchy covers system errors, input/output failures, and programming mistakes. It does not cover the domain-specific failure modes of your application. A UserNotFoundError is not in the standard library. Neither is RetryableError, InsufficientFundsError, or DataCorruptionError. Those are yours to define, and defining them correctly is the difference between exception handling that communicates intent and exception handling that just prevents crashes.
This post covers the practical side: when to create custom exceptions, how to structure them, what to put in them, and the common mistakes worth avoiding.
When Built-In Exceptions Are Not Enough
Built-in exceptions work well for errors that are universal and well-understood: ValueError for bad input, KeyError for missing dictionary keys, FileNotFoundError for missing paths. They fail as a communication mechanism when:
The caller needs to distinguish between failure modes. If your service can fail with a temporary network problem or a permanent data validation error, the caller needs to handle these differently. Raising
Exception("temporary")orException("permanent")makes that distinction fragile -- it depends on string matching, not type matching.The failure carries structured data. A
DataProcessingErrorthat includes the record ID, the source file, and the failed field name is far more actionable than a plain string message. Custom exception classes let you attach that data as attributes.You want to catch all failures from a subsystem without catching everything. A background job processor can catch
ServiceErrorto handle any failure from the service layer while still lettingMemoryErrororKeyboardInterruptpropagate.
The Minimal Custom Exception
The minimal correct custom exception is three lines:
class UserNotFoundError(Exception):
"""Raised when a user lookup returns no result."""
pass
That is all you need to make UserNotFoundError a distinct type that callers can catch specifically. The docstring is the class documentation, not an error message.
You do not need to define __init__ unless you need custom attributes. The default Exception.__init__ accepts a message string and stores it as self.args[0], which is what prints when the exception is displayed.
Adding Structured Data to Custom Exceptions
When the exception needs to carry context beyond a message, define __init__:
class DataProcessingError(Exception):
def __init__(self, message, record_id=None, field=None):
super().__init__(message)
self.record_id = record_id
self.field = field
Call super().__init__(message) to preserve standard exception behavior -- the message will appear in tracebacks and str(err) will return it. Custom attributes go after.
Usage:
raise DataProcessingError(
"Invalid date format in record",
record_id=record.id,
field="created_at"
)
The caller can now access err.record_id and err.field to build a specific error response or retry payload, rather than parsing the message string.
Building a Hierarchy
A flat list of custom exceptions works for small codebases. At scale, a hierarchy rooted at a custom base class lets callers choose how broadly to catch:
class ServiceError(Exception):
"""Base for all service-layer failures."""
pass
class RetryableError(ServiceError):
"""The operation failed but may succeed on retry."""
def __init__(self, message, retry_after=None):
super().__init__(message)
self.retry_after = retry_after
class PermanentError(ServiceError):
"""The operation will never succeed on retry."""
pass
class NotFoundError(PermanentError):
"""The requested resource does not exist."""
def __init__(self, resource_type, resource_id):
super().__init__(f"{resource_type} {resource_id!r} not found")
self.resource_type = resource_type
self.resource_id = resource_id
A background job processor can now catch RetryableError to schedule retry, catch PermanentError to escalate and discard, and let unexpected exceptions propagate to the outer error handler:
try:
result = service.process(job)
except RetryableError as err:
queue.schedule_retry(job.id, delay=err.retry_after)
except PermanentError as err:
alerts.send(f"Permanent failure on {job.id}: {err}")
job.mark_failed()
Exception Chaining for Cause Preservation
When you catch a low-level exception and raise a domain-level one, use raise X from Y to preserve the original cause:
try:
raw = db.query(sql)
except DatabaseConnectionError as err:
raise RetryableError("Database temporarily unavailable") from err
The from err clause sets __cause__ on the new exception. When the traceback prints, Python shows both the original DatabaseConnectionError and the new RetryableError. The root cause is not replaced -- it is chained. The Python Enhancement Proposals on peps.python.org -- specifically PEP 3134 -- explain the design intent behind __cause__ and __context__. The Python language documentation covers the full exception hierarchy and the chaining semantics.
Without chaining, the database error disappears from the traceback. The operator sees a RetryableError with no indication of what the underlying problem was, which makes root cause analysis much harder.
Common Mistakes
Inheriting from BaseException instead of Exception. BaseException is the root of all exceptions including SystemExit and KeyboardInterrupt. Your custom exception should inherit from Exception (or a subclass), not from BaseException. Inheriting from BaseException means your exception bypasses broad except Exception handlers that are catching for cleanup purposes.
Catching your own base class too broadly in the service. A service that catches ServiceError everywhere to suppress failures is using the exception hierarchy for suppression rather than discrimination. Catch at the appropriate level: the outer error boundary handles ServiceError broadly; internal logic handles RetryableError and PermanentError specifically.
Putting too much in the message, not enough in attributes. An error message that reads "Record 4823 failed on field 'amount' with value '$14.00'" carries useful data in an inaccessible form. The record ID, field name, and value should be separate attributes so callers can route, log, or persist them without parsing.
Forgetting to call super().init(). If you define __init__ without calling super().__init__(message), the exception message will not appear in the traceback or in str(err). This is a common source of confusing traceback output.
Testing Custom Exceptions
The pytest documentation at docs.pytest.org covers pytest.raises with the match parameter for asserting on exception messages, and accessing exc_info.value for asserting on custom attributes:
def test_not_found_error_carries_resource_info():
with pytest.raises(NotFoundError) as exc_info:
service.get_user(nonexistent_id)
assert exc_info.value.resource_type == "User"
assert exc_info.value.resource_id == nonexistent_id
def test_retryable_error_on_timeout(mocker):
mocker.patch("service.db.query", side_effect=TimeoutError)
with pytest.raises(RetryableError):
service.fetch_record(record_id)
Testing that the retry logic triggers on RetryableError is only meaningful if the test also verifies that the right exception type is raised on timeout. Custom exception hierarchies make this testing pattern natural.
Controlling How Custom Exceptions Display
By default, an exception displays its class name and the message passed to __init__. Sometimes you want a different string representation -- for example, when the exception stores multiple attributes and you want the display to include all of them.
Override __str__ to customize the display:
class ValidationError(Exception):
def __init__(self, field, value, reason):
self.field = field
self.value = value
self.reason = reason
super().__init__(f"{field}: {reason} (got {value!r})")
err = ValidationError("amount", "-5", "must be positive")
str(err) # "amount: must be positive (got '-5')"
Calling super().__init__(message) with the formatted message ensures the display is correct in tracebacks, in str(err), and in logging. This is important: if you define __init__ without calling super().__init__(), the exception will display as ValidationError() with no message, which is confusing in tracebacks.
The !r formatting in f-strings applies repr() to the value, which adds quotes around strings and shows the type clearly. This is useful in exception messages where the value might be an empty string, None, or a type that looks similar to another at a glance.
Where to Go From Here
The full reference for Python exception handling patterns -- including try-except-else-finally, contextlib.suppress, logging with log.exception(), and re-raising with bare raise -- is collected in the Python Error Handling article on the 137Foundry blog. Custom exception classes are most effective when the rest of the exception handling stack is also well-structured: a hierarchy without consistent catch/raise patterns at service boundaries will not deliver its full diagnostic value.
Top comments (0)