Python Custom Exceptions and Exception Hierarchies: Building Robust Error Handling
Introduction
When you’re building a Python application, you’ll encounter situations where Python’s built-in exceptions don’t quite fit your needs. Imagine you’re building a banking application and a withdrawal fails because of insufficient funds. You could raise a generic ValueError, but that doesn’t clearly communicate what went wrong. A custom InsufficientFundsError would be far more meaningful.
Custom exceptions allow you to create domain-specific error types that make your code more expressive and maintainable. Exception hierarchies let you organize these custom exceptions in a logical structure, enabling flexible error handling at different levels of specificity.
In this guide, we’ll explore how to design and implement custom exceptions and exception hierarchies that make your code more professional, easier to debug, and simpler to maintain. You’ll learn not just how to create custom exceptions, but when and why to use them, and how to structure them for maximum effectiveness.
Part 1: Understanding Python’s Exception System
The Built-in Exception Hierarchy
Python’s exceptions follow a hierarchy rooted in BaseException:
BaseException
โโโ SystemExit
โโโ KeyboardInterrupt
โโโ Exception
โโโ StopIteration
โโโ ArithmeticError
โ โโโ ZeroDivisionError
โ โโโ FloatingPointError
โ โโโ OverflowError
โโโ AssertionError
โโโ AttributeError
โโโ ImportError
โโโ LookupError
โ โโโ IndexError
โ โโโ KeyError
โโโ NameError
โโโ OSError
โ โโโ FileNotFoundError
โ โโโ PermissionError
โ โโโ TimeoutError
โโโ RuntimeError
โโโ TypeError
โโโ ValueError
โโโ ... (many more)
Understanding this hierarchy is crucial because:
- Specific exceptions are more informative than generic ones
- Catching parent exceptions catches all child exceptions
- Designing hierarchies follows the same principles as Python’s built-in system
Why Custom Exceptions Matter
# Without custom exceptions - unclear what went wrong
def withdraw_money(account, amount):
if amount > account.balance:
raise ValueError("Insufficient funds") # Generic error
account.balance -= amount
# With custom exceptions - crystal clear
class InsufficientFundsError(Exception):
pass
def withdraw_money(account, amount):
if amount > account.balance:
raise InsufficientFundsError(f"Cannot withdraw {amount}. Balance: {account.balance}")
account.balance -= amount
Custom exceptions provide:
- Clarity: Code readers immediately understand what went wrong
- Specificity: You can catch exactly the errors you want to handle
- Maintainability: Related errors are grouped together
- Debugging: Stack traces are more informative
- Flexibility: You can add custom attributes and methods
Part 2: Creating Simple Custom Exceptions
The Simplest Custom Exception
The most basic custom exception is just a class that inherits from Exception:
class CustomError(Exception):
pass
# Usage
try:
raise CustomError("Something went wrong")
except CustomError as e:
print(f"Caught error: {e}")
# Output: Caught error: Something went wrong
That’s it! You now have a custom exception that works exactly like built-in exceptions.
Adding a Custom Message
class ValidationError(Exception):
"""Raised when data validation fails"""
pass
# Usage
try:
email = "invalid-email"
if "@" not in email:
raise ValidationError(f"Invalid email format: {email}")
except ValidationError as e:
print(f"Error: {e}")
# Output: Error: Invalid email format: invalid-email
Inheriting from Specific Built-in Exceptions
Sometimes it’s useful to inherit from a more specific built-in exception:
# Inherit from ValueError for value-related errors
class InvalidAgeError(ValueError):
"""Raised when age is invalid"""
pass
# Inherit from TypeError for type-related errors
class InvalidTypeError(TypeError):
"""Raised when type is incorrect"""
pass
# Usage
try:
age = -5
if age < 0:
raise InvalidAgeError("Age cannot be negative")
except ValueError as e: # Catches InvalidAgeError too!
print(f"Value error: {e}")
# Output: Value error: Age cannot be negative
This approach is useful because:
- Code that catches
ValueErrorwill also catchInvalidAgeError - It maintains compatibility with existing error handling
- It signals the type of error to readers
Part 3: Custom Exception Attributes and Methods
Adding Custom Attributes
Store additional information in your exceptions:
class DatabaseError(Exception):
"""Raised when database operations fail"""
def __init__(self, message, error_code=None, query=None):
self.message = message
self.error_code = error_code
self.query = query
super().__init__(self.message)
# Usage
try:
raise DatabaseError(
"Connection failed",
error_code=1045,
query="SELECT * FROM users"
)
except DatabaseError as e:
print(f"Message: {e.message}")
print(f"Error code: {e.error_code}")
print(f"Query: {e.query}")
# Output:
# Message: Connection failed
# Error code: 1045
# Query: SELECT * FROM users
Adding Custom Methods
class APIError(Exception):
"""Raised when API calls fail"""
def __init__(self, message, status_code=None, response=None):
self.message = message
self.status_code = status_code
self.response = response
super().__init__(self.message)
def is_client_error(self):
"""Check if error is client-side (4xx)"""
return 400 <= self.status_code < 500
def is_server_error(self):
"""Check if error is server-side (5xx)"""
return 500 <= self.status_code < 600
def get_retry_after(self):
"""Extract retry-after header if present"""
if self.response and hasattr(self.response, 'headers'):
return self.response.headers.get('Retry-After')
return None
# Usage
try:
raise APIError(
"Rate limit exceeded",
status_code=429,
response={"Retry-After": "60"}
)
except APIError as e:
print(f"Error: {e.message}")
print(f"Is client error: {e.is_client_error()}")
print(f"Is server error: {e.is_server_error()}")
# Output:
# Error: Rate limit exceeded
# Is client error: True
# Is server error: False
Custom String Representation
class ValidationError(Exception):
"""Raised when validation fails"""
def __init__(self, field, value, reason):
self.field = field
self.value = value
self.reason = reason
super().__init__(f"Validation failed for {field}")
def __str__(self):
return f"Field '{self.field}' with value '{self.value}' failed: {self.reason}"
def __repr__(self):
return f"ValidationError(field={self.field!r}, value={self.value!r}, reason={self.reason!r})"
# Usage
try:
raise ValidationError("email", "invalid@", "Missing domain")
except ValidationError as e:
print(str(e))
print(repr(e))
# Output:
# Field 'email' with value 'invalid@' failed: Missing domain
# ValidationError(field='email', value='invalid@', reason='Missing domain')
Part 4: Designing Exception Hierarchies
Basic Hierarchy Structure
Create a base exception for your application, then specific exceptions for different error types:
# Base exception for your application
class ApplicationError(Exception):
"""Base exception for all application errors"""
pass
# Category-specific exceptions
class ValidationError(ApplicationError):
"""Raised when data validation fails"""
pass
class DatabaseError(ApplicationError):
"""Raised when database operations fail"""
pass
class APIError(ApplicationError):
"""Raised when API operations fail"""
pass
# Specific exceptions
class InvalidEmailError(ValidationError):
"""Raised when email format is invalid"""
pass
class InvalidPasswordError(ValidationError):
"""Raised when password doesn't meet requirements"""
pass
class ConnectionError(DatabaseError):
"""Raised when database connection fails"""
pass
class QueryError(DatabaseError):
"""Raised when database query fails"""
pass
class RateLimitError(APIError):
"""Raised when API rate limit is exceeded"""
pass
class TimeoutError(APIError):
"""Raised when API request times out"""
pass
Using the Hierarchy
# Catch all application errors
try:
perform_operation()
except ApplicationError as e:
logger.error(f"Application error: {e}")
# Catch all validation errors
try:
validate_user_input()
except ValidationError as e:
logger.warning(f"Validation error: {e}")
# Catch specific validation errors
try:
validate_email(email)
except InvalidEmailError as e:
print(f"Email error: {e}")
except InvalidPasswordError as e:
print(f"Password error: {e}")
# Catch database errors separately from API errors
try:
fetch_data()
except DatabaseError as e:
logger.error(f"Database error: {e}")
# Retry logic for database errors
except APIError as e:
logger.error(f"API error: {e}")
# Different retry logic for API errors
Multi-level Hierarchies
# Base exception
class ApplicationError(Exception):
pass
# First level - by component
class AuthenticationError(ApplicationError):
pass
class PaymentError(ApplicationError):
pass
# Second level - by specific issue
class LoginError(AuthenticationError):
pass
class TokenError(AuthenticationError):
pass
class PaymentProcessingError(PaymentError):
pass
class PaymentValidationError(PaymentError):
pass
# Third level - very specific
class InvalidCredentialsError(LoginError):
pass
class ExpiredTokenError(TokenError):
pass
class InsufficientFundsError(PaymentProcessingError):
pass
class InvalidCardError(PaymentValidationError):
pass
# Usage - catch at appropriate level
try:
process_payment()
except InsufficientFundsError as e:
# Handle specific case
print("Please add funds to your account")
except PaymentProcessingError as e:
# Handle any payment processing error
print("Payment processing failed")
except PaymentError as e:
# Handle any payment-related error
print("Payment operation failed")
except ApplicationError as e:
# Handle any application error
logger.error(f"Application error: {e}")
Part 5: Practical Examples
Example 1: User Authentication System
# Exception hierarchy
class AuthError(Exception):
"""Base exception for authentication errors"""
pass
class InvalidCredentialsError(AuthError):
"""Raised when username/password is incorrect"""
def __init__(self, username):
self.username = username
super().__init__(f"Invalid credentials for user: {username}")
class AccountLockedError(AuthError):
"""Raised when account is locked"""
def __init__(self, username, locked_until):
self.username = username
self.locked_until = locked_until
super().__init__(f"Account {username} is locked until {locked_until}")
class TokenExpiredError(AuthError):
"""Raised when authentication token has expired"""
def __init__(self, token_age):
self.token_age = token_age
super().__init__(f"Token expired after {token_age} seconds")
# Usage
def authenticate_user(username, password):
"""Authenticate a user"""
user = find_user(username)
if not user:
raise InvalidCredentialsError(username)
if user.is_locked():
raise AccountLockedError(username, user.locked_until)
if not verify_password(password, user.password_hash):
user.failed_attempts += 1
if user.failed_attempts >= 5:
user.lock_account()
raise InvalidCredentialsError(username)
return user.generate_token()
# Error handling
try:
token = authenticate_user("alice", "password123")
except InvalidCredentialsError as e:
print(f"Login failed for {e.username}")
except AccountLockedError as e:
print(f"Account locked until {e.locked_until}")
except AuthError as e:
print(f"Authentication error: {e}")
Example 2: Data Processing Pipeline
# Exception hierarchy
class ProcessingError(Exception):
"""Base exception for processing errors"""
pass
class ValidationError(ProcessingError):
"""Raised when data validation fails"""
def __init__(self, field, value, reason):
self.field = field
self.value = value
self.reason = reason
super().__init__(f"Validation failed for {field}: {reason}")
class TransformationError(ProcessingError):
"""Raised when data transformation fails"""
def __init__(self, step, data, reason):
self.step = step
self.data = data
self.reason = reason
super().__init__(f"Transformation failed at {step}: {reason}")
class StorageError(ProcessingError):
"""Raised when data storage fails"""
def __init__(self, operation, reason):
self.operation = operation
self.reason = reason
super().__init__(f"Storage operation {operation} failed: {reason}")
# Processing pipeline
def process_data(raw_data):
"""Process data through validation, transformation, and storage"""
try:
# Validation
validated = validate_data(raw_data)
# Transformation
transformed = transform_data(validated)
# Storage
store_data(transformed)
return transformed
except ValidationError as e:
logger.warning(f"Validation failed for field {e.field}")
raise
except TransformationError as e:
logger.error(f"Transformation failed at step {e.step}")
# Attempt recovery
return fallback_transform(e.data)
except StorageError as e:
logger.error(f"Storage failed: {e.reason}")
# Retry logic
retry_storage(transformed)
except ProcessingError as e:
logger.error(f"Processing error: {e}")
raise
def validate_data(data):
"""Validate data"""
if not data.get('email'):
raise ValidationError('email', data.get('email'), 'Email is required')
if not data.get('age') or data['age'] < 0:
raise ValidationError('age', data.get('age'), 'Age must be positive')
return data
def transform_data(data):
"""Transform data"""
try:
return {
'email': data['email'].lower(),
'age': int(data['age']),
'name': data['name'].title()
}
except (KeyError, ValueError) as e:
raise TransformationError('normalization', data, str(e))
def store_data(data):
"""Store data"""
try:
database.insert(data)
except Exception as e:
raise StorageError('insert', str(e))
Example 3: API Client with Retry Logic
# Exception hierarchy
class APIError(Exception):
"""Base exception for API errors"""
def __init__(self, message, status_code=None):
self.message = message
self.status_code = status_code
super().__init__(message)
class ClientError(APIError):
"""Raised for 4xx errors (client's fault)"""
pass
class ServerError(APIError):
"""Raised for 5xx errors (server's fault)"""
pass
class RateLimitError(ServerError):
"""Raised when rate limit is exceeded"""
def __init__(self, retry_after):
self.retry_after = retry_after
super().__init__(f"Rate limited. Retry after {retry_after} seconds", 429)
class TimeoutError(ServerError):
"""Raised when request times out"""
pass
class ConnectionError(ServerError):
"""Raised when connection fails"""
pass
# API client with retry logic
class APIClient:
def __init__(self, base_url, max_retries=3):
self.base_url = base_url
self.max_retries = max_retries
def request(self, method, endpoint, **kwargs):
"""Make API request with retry logic"""
for attempt in range(1, self.max_retries + 1):
try:
return self._make_request(method, endpoint, **kwargs)
except RateLimitError as e:
if attempt == self.max_retries:
raise
logger.warning(f"Rate limited. Retrying after {e.retry_after}s")
time.sleep(e.retry_after)
except (TimeoutError, ConnectionError) as e:
if attempt == self.max_retries:
raise
wait_time = 2 ** attempt # Exponential backoff
logger.warning(f"Attempt {attempt} failed. Retrying in {wait_time}s")
time.sleep(wait_time)
except ClientError as e:
# Don't retry client errors
raise
def _make_request(self, method, endpoint, **kwargs):
"""Make actual request"""
try:
response = requests.request(
method,
f"{self.base_url}{endpoint}",
timeout=5,
**kwargs
)
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 60))
raise RateLimitError(retry_after)
elif 400 <= response.status_code < 500:
raise ClientError(response.text, response.status_code)
elif 500 <= response.status_code < 600:
raise ServerError(response.text, response.status_code)
return response.json()
except requests.Timeout:
raise TimeoutError("Request timed out")
except requests.ConnectionError:
raise ConnectionError("Connection failed")
# Usage
client = APIClient("https://api.example.com")
try:
data = client.request("GET", "/users/123")
except RateLimitError as e:
print(f"Rate limited. Try again in {e.retry_after}s")
except ClientError as e:
print(f"Client error: {e.message}")
except ServerError as e:
print(f"Server error: {e.message}")
except APIError as e:
print(f"API error: {e.message}")
Part 6: Best Practices
1. Name Exceptions Clearly
# Good: Clear, descriptive names
class InvalidEmailFormatError(Exception):
pass
class DatabaseConnectionError(Exception):
pass
class InsufficientPermissionsError(Exception):
pass
# Avoid: Vague or misleading names
class BadError(Exception):
pass
class Error1(Exception):
pass
class WrongThing(Exception):
pass
2. Use Docstrings
class ValidationError(Exception):
"""
Raised when data validation fails.
Attributes:
field: The field that failed validation
value: The invalid value
reason: Why the validation failed
"""
def __init__(self, field, value, reason):
self.field = field
self.value = value
self.reason = reason
super().__init__(f"Validation failed for {field}: {reason}")
3. Provide Useful Error Messages
# Good: Specific, actionable information
raise InsufficientFundsError(
f"Cannot withdraw ${amount}. "
f"Current balance: ${balance}. "
f"Shortfall: ${amount - balance}"
)
# Avoid: Vague messages
raise InsufficientFundsError("Not enough money")
4. Preserve Exception Context
# Good: Use 'from' to preserve context
try:
database.connect()
except ConnectionError as e:
raise DatabaseError("Failed to connect to database") from e
# Avoid: Losing the original exception
try:
database.connect()
except ConnectionError:
raise DatabaseError("Failed to connect to database")
5. Don’t Catch Too Broadly
# Good: Catch specific exceptions
try:
process_data()
except ValidationError as e:
handle_validation_error(e)
except ProcessingError as e:
handle_processing_error(e)
# Avoid: Catching everything
try:
process_data()
except Exception:
pass # Hides bugs!
6. Use Exception Hierarchies for Flexible Handling
# Good: Catch at appropriate level
try:
authenticate_user()
except InvalidCredentialsError as e:
# Handle specific case
log_failed_login(e.username)
except AccountLockedError as e:
# Handle another specific case
notify_user_account_locked(e.username)
except AuthError as e:
# Handle any auth error
log_auth_error(e)
# Avoid: Catching too specifically or too broadly
try:
authenticate_user()
except Exception:
pass
Part 7: Common Pitfalls
Pitfall 1: Not Inheriting from Exception
# Bad: Doesn't inherit from Exception
class CustomError:
pass
# Good: Inherits from Exception
class CustomError(Exception):
pass
Pitfall 2: Losing Exception Information
# Bad: Original exception is lost
try:
risky_operation()
except Exception:
raise CustomError("Something went wrong")
# Good: Preserve the original exception
try:
risky_operation()
except Exception as e:
raise CustomError(f"Something went wrong: {e}") from e
Pitfall 3: Overly Complex Hierarchies
# Bad: Too many levels, hard to use
class AppError(Exception):
pass
class ComponentError(AppError):
pass
class SubComponentError(ComponentError):
pass
class SpecificError(SubComponentError):
pass
# Good: Simple, practical hierarchy
class AppError(Exception):
pass
class ValidationError(AppError):
pass
class InvalidEmailError(ValidationError):
pass
Pitfall 4: Not Documenting the Hierarchy
# Good: Document what exceptions can be raised
def process_payment(amount):
"""
Process a payment.
Args:
amount: Payment amount in dollars
Raises:
InvalidAmountError: If amount is invalid
InsufficientFundsError: If account has insufficient funds
PaymentProcessingError: If payment processing fails
"""
pass
# Avoid: No documentation
def process_payment(amount):
pass
Pitfall 5: Using Exceptions for Control Flow
# Bad: Using exceptions for normal control flow
try:
index = 0
while True:
print(items[index])
index += 1
except IndexError:
pass
# Good: Use normal control flow
for item in items:
print(item)
Conclusion
Custom exceptions and exception hierarchies are powerful tools for building robust, maintainable Python applications. They transform error handling from a generic, one-size-fits-all approach into a precise, domain-specific system that makes your code clearer and easier to debug.
Key takeaways:
- Create custom exceptions by inheriting from
Exceptionor specific built-in exceptions - Design hierarchies with a base exception and specific subclasses for different error types
- Add custom attributes and methods to provide rich error information
- Use hierarchies for flexible handling - catch at the appropriate level of specificity
- Name exceptions clearly and provide useful error messages
- Preserve exception context using the
fromkeyword - Document what exceptions your functions can raise
- Avoid common pitfalls like losing exception information or using exceptions for control flow
By implementing custom exceptions and well-designed hierarchies, you’ll write code that’s not just functional, but professional and maintainable. Your future selfโand your teammatesโwill thank you when debugging becomes easier and error handling becomes clearer.
Happy coding!
Comments