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.
Last updated: August 2026

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.
Loading diagram...
Custom Exception Hierarchy and Chaining Flow
Test Your Knowledge

What is the recommended base class when creating user-defined application exceptions in Python?

A
B
C
D
Test Your Knowledge

What occurs when the syntax raise CustomError('Failed') from original_error is executed?

A
B
C
D
Test Your Knowledge

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?

A
B
C
D
Test Your Knowledge

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?

A
B
C
D