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.
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.
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.
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.
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.
custom exception classes, exception hierarchies, adding attributes, best practices