3.4 Custom Exceptions and Exception Chaining
Key Takeaways
- Custom exception classes must inherit from Exception (or a relevant standard exception subclass) to follow standard Python error conventions.
- Custom constructors should call super().__init__(*args) to properly initialize the .args tuple and ensure standard string representations function properly.
- Implicit exception chaining automatically assigns the prior active exception to the __context__ attribute when a secondary exception occurs inside an except or finally block.
- Explicit exception chaining uses the raise NewException(...) from original_exc syntax to set the __cause__ attribute and document direct causality in tracebacks.
- Chained exception tracebacks can be suppressed using raise NewException(...) from None, setting __suppress_context__ to True.
Custom Exceptions and Exception Chaining
While Python's built-in exceptions cover standard operating system, arithmetic, and data structure errors, complex applications require domain-specific error representations. Creating custom exception classes allows libraries and applications to signal domain failures (such as payment rejections, inventory shortages, or authentication timeouts) cleanly. Furthermore, Python 3 provides sophisticated exception chaining mechanisms to trace root-cause failures across architectural layers.
Defining User-Defined Exceptions
All user-defined exception classes must inherit from Exception (or one of its subclasses). Never inherit directly from BaseException.
The Minimalist Custom Exception
The simplest custom exception requires only a class definition and a pass statement:
class ApplicationError(Exception):
"""Base exception for all domain errors in this application."""
pass
class UserNotFoundError(ApplicationError):
"""Raised when a requested user ID does not exist."""
pass
Inheriting from Exception automatically provides full support for constructor arguments, .args tuple storage, and standard traceback formatting.
Designing Domain Exception Hierarchies
In production systems, custom exceptions should be structured in a hierarchical tree. This allows consuming code to choose between catching broad category-level errors or handling granular, specific failures.
class PaymentGatewayError(Exception):
"""Root exception for payment processing system."""
pass
class CardValidationError(PaymentGatewayError):
"""Raised when credit card format or checksum fails."""
pass
class ExpiredCardError(CardValidationError):
"""Raised specifically when card expiration date is in the past."""
pass
class InsufficientFundsError(PaymentGatewayError):
"""Raised when account balance cannot cover the charge."""
pass
Polymorphic Consumption
Callers can intercept errors at any desired level of granularity:
try:
process_charge(user_card, amount=250.00)
except ExpiredCardError:
prompt_user_for_new_card()
except PaymentGatewayError as e:
# Catches InsufficientFundsError, CardValidationError, or general gateway errors
log_payment_failure(e)
Customizing Constructors and __str__
Custom exceptions can accept additional domain-specific attributes (e.g. error codes, timestamps, metadata) by overriding __init__(). Always invoke super().__init__() to keep the built-in .args attribute synchronized.
class ValidationError(Exception):
def __init__(self, field_name, value, rule_violation):
self.field_name = field_name
self.value = value
self.rule_violation = rule_violation
# Generate a descriptive message
message = f"Field '{field_name}' with value '{value}' failed validation: {rule_violation}"
# Forward message to Exception base class to populate .args
super().__init__(message)
def __str__(self):
return f"[Validation Failure] {self.field_name}: {self.rule_violation} (received: {self.value})"
try:
raise ValidationError("email", "invalid-user@", "Missing top-level domain")
except ValidationError as err:
print(f"Display: {err}")
print(f"Field: {err.field_name}")
print(f"Args: {err.args}")
# Output:
# Display: [Validation Failure] email: Missing top-level domain (received: invalid-user@)
# Field: email
# Args: ("Field 'email' with value 'invalid-user@' failed validation: Missing top-level domain",)
Exception Chaining: Implicit vs Explicit
When building multi-layered software (e.g. database adapters, web frameworks), an exception in a lower layer often triggers an exception in a higher layer. Python 3 provides built-in mechanisms to link these exceptions together.
1. Implicit Exception Chaining (__context__)
If an exception is raised while handling another exception inside an except or finally block, Python automatically captures the original exception and attaches it to the new exception's __context__ attribute:
try:
int('invalid_number') # Raises ValueError
except ValueError:
# An unexpected error occurs while handling ValueError
data = 10 / 0 # Raises ZeroDivisionError
When this runs, Python's default traceback displays both errors linked with the message:
Traceback (most recent call last):
File "script.py", line 2, in <module>
int('invalid_number')
ValueError: invalid literal for int() with base 10: 'invalid_number'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "script.py", line 5, in <module>
data = 10 / 0
ZeroDivisionError: division by zero
2. Explicit Exception Chaining (__cause__ and raise ... from)
When deliberately translating a low-level exception into a high-level domain exception, use the raise NewException(...) from original_exc syntax. This explicitly populates the new exception's __cause__ attribute:
class DatabaseConnectionError(Exception):
pass
def connect_to_database():
try:
import socket
s = socket.create_connection(('192.0.2.1', 5432), timeout=1.0)
except (socket.timeout, OSError) as original_err:
# Explicitly declare that original_err caused DatabaseConnectionError
raise DatabaseConnectionError("Unable to reach primary DB replica") from original_err
When explicit chaining is used, Python displays:
Traceback (most recent call last):
File "script.py", line 7, in connect_to_database
s = socket.create_connection(('192.0.2.1', 5432), timeout=1.0)
TimeoutError: timed out
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "script.py", line 9, in connect_to_database
raise DatabaseConnectionError("Unable to reach primary DB replica") from original_err
DatabaseConnectionError: Unable to reach primary DB replica
3. Suppressing Exception Context (raise ... from None)
In public APIs or security-sensitive modules, you may want to raise a clean domain exception without exposing low-level system details, file paths, or internal tracebacks. Using raise NewException(...) from None sets __suppress_context__ = True and suppresses the chained output:
def get_secret_config(key):
try:
return raw_internal_dictionary[key]
except KeyError:
# Suppress internal dictionary KeyError and show only clean ConfigurationError
raise ConfigurationError(f"Configuration key '{key}' not set.") from None
Traceback (most recent call last):
File "script.py", line 6, in get_secret_config
raise ConfigurationError(f"Configuration key '{key}' not set.") from None
ConfigurationError: Configuration key 'DATABASE_PASSWORD' not set.
What is the recommended base class when creating user-defined application exceptions in Python?
What occurs when the syntax raise CustomError('Failed') from original_error is executed?
How can a developer completely suppress the display of prior exception context in a traceback when raising a new exception from inside an except block?
Given the following class definition: class CustomScoreError(Exception): def init(self, score): super().init(f'Score {score} is out of range') self.score = score try: raise CustomScoreError(105) except CustomScoreError as e: print(e.args[0]) What is printed to standard output?