Custom Exceptions

Difficulty: Intermediate

Custom exception classes let you create domain-specific error types that make your code more readable and your error handling more precise. Instead of raising generic ValueError or RuntimeError everywhere, you can define exceptions like InsufficientFundsError or InvalidTokenError that immediately communicate what went wrong. Custom exceptions are created by subclassing Exception (or one of its subclasses) and optionally adding attributes or methods.

The simplest custom exception is just a class with pass as its body: class MyError(Exception): pass. This is often sufficient because you inherit the __init__ and __str__ methods from Exception, which accept a message string and display it properly. The exception can be raised with raise MyError('something went wrong') and caught with except MyError as e, giving you a clean and specific error type.

Adding custom attributes to your exception classes makes them more useful. You can override __init__ to accept additional parameters beyond the message, store them as instance attributes, and access them in the except block. For example, a ValidationError might store the field name that failed validation and the invalid value, so the handler can produce a precise error response without parsing the message string.

Exception hierarchies allow you to organize related errors into a tree. You create a base exception for your application or library, then derive more specific exceptions from it. For example, a PaymentError base class might have InsufficientFundsError, CardDeclinedError, and ExpiredCardError subclasses. Callers can catch the base PaymentError to handle all payment errors generically, or catch specific subclasses when they need different behavior for different failure modes.

Well-designed exception hierarchies are a hallmark of professional Python code. Standard library modules like json, sqlite3, and http follow this pattern, each defining a base exception and specific sub-exceptions. When designing your own hierarchy, keep it shallow (two to three levels deep is usually sufficient), name exceptions clearly with the Error suffix, and document which exceptions each function can raise. This gives callers confidence in writing precise except clauses.

Code examples

Simple custom exception

class InsufficientFundsError(Exception):
    pass

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(
            f"Cannot withdraw ${amount}, balance is ${balance}"
        )
    return balance - amount

try:
    new_balance = withdraw(100, 50)
    print(f"New balance: ${new_balance}")
    new_balance = withdraw(new_balance, 80)
except InsufficientFundsError as e:
    print(f"Caught: {e}")

InsufficientFundsError is a minimal custom exception. It inherits everything from Exception and is raised with a descriptive message. The caller catches it specifically, knowing exactly what kind of error occurred.

Custom exception with extra attributes

class ValidationError(Exception):
    def __init__(self, field, value, message):
        self.field = field
        self.value = value
        self.message = message
        super().__init__(f"{field}: {message} (got {value!r})")

def validate_email(email):
    if not isinstance(email, str):
        raise ValidationError("email", email, "must be a string")
    if "@" not in email:
        raise ValidationError("email", email, "must contain @")
    print(f"Valid email: {email}")

try:
    validate_email("user@example.com")
    validate_email("invalid-email")
except ValidationError as e:
    print(f"Validation failed - Field: {e.field}, Value: {e.value!r}")
    print(f"Message: {e.message}")

The custom ValidationError stores field, value, and message as separate attributes. The except block can access these attributes individually rather than parsing a message string, making programmatic error handling much easier.

Exception hierarchy

class AppError(Exception):
    """Base exception for the application."""
    pass

class AuthError(AppError):
    """Authentication related errors."""
    pass

class NotFoundError(AppError):
    """Resource not found errors."""
    pass

class PermissionDeniedError(AuthError):
    """Insufficient permissions."""
    pass

def get_resource(user_role, resource_id):
    if resource_id > 100:
        raise NotFoundError(f"Resource {resource_id} not found")
    if user_role != "admin":
        raise PermissionDeniedError(f"Role '{user_role}' cannot access this")
    return f"Resource {resource_id} data"

for role, rid in [("admin", 1), ("user", 1), ("admin", 200)]:
    try:
        result = get_resource(role, rid)
        print(f"Success: {result}")
    except AuthError as e:
        print(f"Auth issue: {e}")
    except AppError as e:
        print(f"App error: {e}")

PermissionDeniedError inherits from AuthError, which inherits from AppError. Catching AuthError also catches PermissionDeniedError. NotFoundError is caught by the broader AppError handler. The hierarchy lets callers choose their level of specificity.

Using isinstance to check exception types

class DatabaseError(Exception):
    pass

class ConnectionError(DatabaseError):
    pass

class QueryError(DatabaseError):
    pass

def simulate_db_operation(operation):
    if operation == "connect":
        raise ConnectionError("Cannot reach database server")
    elif operation == "query":
        raise QueryError("Syntax error in SQL")
    print(f"Operation '{operation}' succeeded")

for op in ["insert", "connect", "query"]:
    try:
        simulate_db_operation(op)
    except DatabaseError as e:
        error_type = type(e).__name__
        is_connection = isinstance(e, ConnectionError)
        print(f"{error_type}: {e} (connection issue: {is_connection})")

Catching the base DatabaseError handles all database-related errors. Inside the handler, isinstance() checks the specific type to determine if retry logic (for connection errors) or different handling (for query errors) is appropriate.

Key points

Concepts covered

custom exception classes, exception hierarchies, adding attributes, best practices