Error Handling & Custom Exceptions
Master professional error handling with custom exceptions, exception hierarchies, context managers, and production-ready patterns.
Prerequisites
This lesson builds on the basic error handling from Python Foundations (try-except basics). You should be comfortable with classes and OOP concepts before proceeding.
Why Custom Exceptions?
While Python provides many built-in exceptions like ValueError and TypeError, custom exceptions allow you to create meaningful error types specific to your application domain. They make your code more maintainable, testable, and easier to debug.
Benefits
- Domain-specific errors: Express business logic failures clearly
- Better debugging: Meaningful exception names tell you what went wrong
- Selective catching: Catch specific errors without catching everything
- Additional context: Include custom attributes and messages
When to Use
- Application-specific failures (e.g.,
PaymentFailedError) - Library/API development to provide clear error contracts
- Validation errors with additional context
- Workflow or state machine errors
Creating Custom Exception Classes
Basic Custom Exception
The simplest custom exception just inherits from Exception. This is sufficient when you only need a unique exception type.
# Define a basic custom exception
class InvalidAgeError(Exception):
"""Raised when age is invalid"""
# Using the custom exception
def set_age(age):
if age < 0 or age > 150:
raise InvalidAgeError(f"Age {age} is not valid")
return age
# Test it
try:
set_age(-5)
except InvalidAgeError as e:
print(f"Error: {e}")
# Error: Age -5 is not validNaming Convention: Exception class names should end with "Error" or "Exception" to make their purpose immediately clear.
Custom Exceptions with Attributes
Add custom attributes to provide additional context about the error. This makes debugging much easier and allows programmatic access to error details.
class InsufficientFundsError(Exception):
"""Raised when account has insufficient funds"""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
self.shortage = amount - balance
message = f"Insufficient funds: need ${amount}, have ${balance}"
super().__init__(message)
# Using the exception
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount# Catching and using the exception
try:
new_balance = withdraw(50, 100)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"You're short by: ${e.shortage}")
# Error: Insufficient funds: need $100, have $50
# You're short by: $50Adding Custom Methods
Custom exceptions can include methods to provide formatted output or additional functionality.
class ValidationError(Exception):
"""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}")
def get_details(self):
"""Return formatted error details"""
return {
'field': self.field,
'invalid_value': self.value,
'reason': self.reason
}
def user_friendly_message(self):
"""Return a message suitable for end users"""
return f"{self.field.title()}: {self.reason}"# Using the exception with methods
def validate_email(email):
if '@' not in email:
raise ValidationError('email', email, 'must contain @ symbol')
return True
try:
validate_email('invalid-email')
except ValidationError as e:
print(e.user_friendly_message())
print(e.get_details())
# Email: must contain @ symbol
# {'field': 'email', 'invalid_value': 'invalid-email', 'reason': 'must contain @ symbol'}Exception Hierarchies
Creating a hierarchy of exceptions allows you to catch errors at different levels of specificity. This is especially useful in libraries and large applications.
Building an Exception Hierarchy
# Base exception for your application
class AppError(Exception):
"""Base exception for all application errors"""
# Database-related errors
class DatabaseError(AppError):
"""Base exception for database errors"""
class ConnectionError(DatabaseError):
"""Database connection failed"""
class QueryError(DatabaseError):
"""Database query failed"""
# Authentication-related errors
class AuthError(AppError):
"""Base exception for authentication errors"""
pass
class InvalidCredentialsError(AuthError):
"""Username or password incorrect"""
pass
class TokenExpiredError(AuthError):
"""Authentication token has expired"""
passThe hierarchy structure:
Exception (built-in)
└── AppError (your base)
├── DatabaseError
│ ├── ConnectionError
│ └── QueryError
└── AuthError
├── InvalidCredentialsError
└── TokenExpiredErrorCatching Exceptions by Hierarchy
With a hierarchy, you can catch specific exceptions or broad categories depending on your needs.
def connect_to_database():
raise ConnectionError("Could not connect to database server")
def authenticate_user(username, password):
raise InvalidCredentialsError("Invalid password")
# Catch specific exception
try:
connect_to_database()
except ConnectionError as e:
print(f"Connection issue: {e}")
# Connection issue: Could not connect to database server# Catch all database errors
try:
connect_to_database()
except DatabaseError as e:
print(f"Database error: {e}")
# Database error: Could not connect to database server
# Catch all application errors
try:
authenticate_user('john', 'wrong_password')
except AppError as e:
print(f"Application error: {e}")
# Application error: Invalid passwordReal-World Example: API Client
class APIError(Exception):
"""Base exception for API errors"""
def __init__(self, message, status_code=None):
super().__init__(message)
self.status_code = status_code
class ClientError(APIError):
"""4xx client errors"""
class NotFoundError(ClientError):
"""404 Not Found"""
def __init__(self, resource):
super().__init__(f"Resource not found: {resource}", 404)
self.resource = resource
class UnauthorizedError(ClientError):
"""401 Unauthorized"""
def __init__(self):
super().__init__("Authentication required", 401)
class ServerError(APIError):
"""5xx server errors"""
passdef make_api_request(endpoint, authenticated=False):
if endpoint == "/user/123":
raise NotFoundError("/user/123")
if not authenticated:
raise UnauthorizedError()
# Different handling strategies
try:
make_api_request("/user/123")
except NotFoundError as e:
print(f"Specific: {e.resource} does not exist")
except ClientError as e:
print(f"Client error ({e.status_code}): {e}")
except APIError as e:
print(f"General API error: {e}")
# Specific: /user/123 does not existContext Managers for Cleanup
Context managers ensure that resources are properly cleaned up, even when exceptions occur. The with statement guarantees cleanup code runs.
Review: The with Statement
# Without context manager - risky!
file = open('data.txt', 'r')
try:
content = file.read()
# If an exception occurs here, file might not close
process(content)
finally:
file.close()
# With context manager - guaranteed cleanup
with open('data.txt', 'r') as file:
content = file.read()
process(content)
# File automatically closes, even if exception occursCreating Custom Context Managers (Class-Based)
Implement __enter__ and __exit__ methods to create a context manager.
class DatabaseConnection:
"""Context manager for database connections"""
def __init__(self, host, database):
self.host = host
self.database = database
self.connection = None
def __enter__(self):
"""Called when entering 'with' block"""
print(f"Opening connection to {self.database}")
# In real code: self.connection = connect(self.host, self.database)
self.connection = f"Connected to {self.database}"
return self.connection
def __exit__(self, exc_type, exc_value, traceback):
"""Called when exiting 'with' block"""
print(f"Closing connection to {self.database}")
# In real code: self.connection.close()
# Return False to propagate exceptions
# Return True to suppress exceptions
return False# Using the context manager
with DatabaseConnection('localhost', 'mydb') as conn:
print(f"Using: {conn}")
# Do work with connection
# Output:
# Opening connection to mydb
# Using: Connected to mydb
# Closing connection to mydbCreating Context Managers with @contextmanager
The @contextmanager decorator provides a simpler way to create context managers using generator functions.
from contextlib import contextmanager
@contextmanager
def temporary_setting(name, value):
"""Temporarily change a setting, then restore it"""
# Setup (before yield = __enter__)
old_value = get_setting(name)
set_setting(name, value)
print(f"Setting {name} = {value}")
try:
yield # This is where the 'with' block executes
finally:
# Cleanup (after yield = __exit__)
set_setting(name, old_value)
print(f"Restored {name} = {old_value}")
# Mock functions for example
settings = {'debug': False}
def get_setting(name): return settings.get(name)
def set_setting(name, value): settings[name] = value# Using the decorator-based context manager
with temporary_setting('debug', True):
print(f"Debug is: {get_setting('debug')}")
# Do work with debug enabled
print(f"After context: {get_setting('debug')}")
# Output:
# Setting debug = True
# Debug is: True
# Restored debug = False
# After context: FalseException Handling in Context Managers
Context managers can handle exceptions that occur within the with block.
class TransactionManager:
"""Manages database transactions with rollback on error"""
def __enter__(self):
print("BEGIN TRANSACTION")
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
# No exception occurred
print("COMMIT TRANSACTION")
else:
# Exception occurred
print(f"ROLLBACK TRANSACTION (error: {exc_value})")
# Return True to suppress the exception
# Return False to let it propagate
return False # Let exception propagate# Successful transaction
with TransactionManager():
print("Inserting data...")
print("Updating records...")
# Output:
# BEGIN TRANSACTION
# Inserting data...
# Updating records...
# COMMIT TRANSACTION
# Failed transaction
try:
with TransactionManager():
print("Inserting data...")
raise ValueError("Something went wrong!")
except ValueError:
print("Exception handled outside context")
# Output:
# BEGIN TRANSACTION
# Inserting data...
# ROLLBACK TRANSACTION (error: Something went wrong!)
# Exception handled outside contextBest Practices for Error Handling
Do's and Don'ts
DO
- Be specific: Catch specific exceptions, not broad ones
except ValueError as e: - Provide context: Include helpful error messages
raise ValueError(f"Age must be 0-150, got {age}") - Chain exceptions: Preserve original error context
raise NewError("message") from original_error - Document exceptions: Note which exceptions functions raise
"""Raises: ValueError if age invalid"""
DON'T
- Catch everything: Don't use bare
except:except: # ❌ Too broad! - Swallow exceptions: Don't catch and ignore
except Exception: pass # ❌ Silent failure - Catch too much: Only catch what you can handle
except Exception as e: # ❌ Too broad - Lose information: Don't throw away the original error
raise NewError() # ❌ Lost original context
Exception Chaining: Preserving Context
Use raise ... from ... to preserve the original exception while raising a new one.
def load_config(filename):
try:
with open(filename) as f:
return json.load(f)
except FileNotFoundError as e:
# Chain exceptions to preserve context
raise ConfigurationError(
f"Config file {filename} not found"
) from e
except json.JSONDecodeError as e:
raise ConfigurationError(
f"Invalid JSON in {filename}"
) from e
class ConfigurationError(Exception):
pass# The chained exception shows both errors
try:
import json
load_config('missing.json')
except ConfigurationError as e:
print(f"Error: {e}")
print(f"Original cause: {e.__cause__}")
# Error: Config file missing.json not found
# Original cause: [Errno 2] No such file or directory: 'missing.json'Fail Fast vs. Defensive Programming
Fail Fast
Validate inputs and raise exceptions immediately when something is wrong.
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / bEAFP (Easier to Ask Forgiveness)
Try the operation and handle exceptions if they occur. Pythonic approach.
def get_value(data, key):
try:
return data[key]
except KeyError:
return NonePractical Examples
Example 1: Database Connection Manager
from contextlib import contextmanager
class DatabaseError(Exception):
"""Base exception for database operations"""
pass
class ConnectionError(DatabaseError):
"""Failed to connect to database"""
pass
class QueryError(DatabaseError):
"""Failed to execute query"""
def __init__(self, query, original_error):
self.query = query
self.original_error = original_error
super().__init__(f"Query failed: {query}")
@contextmanager
def database_session(connection_string):
"""Context manager for database sessions"""
connection = None
try:
# Simulate connection
print(f"Connecting to database...")
connection = f"DB Connection: {connection_string}"
yield connection
except Exception as e:
print(f"Rolling back transaction due to error")
raise
else:
print(f"Committing transaction")
finally:
if connection:
print(f"Closing connection")
connection = None# Using the database session manager
try:
with database_session("localhost:5432/mydb") as db:
print(f"Using: {db}")
print("Executing queries...")
# Simulate work
except DatabaseError as e:
print(f"Database error: {e}")
# Output:
# Connecting to database...
# Using: DB Connection: localhost:5432/mydb
# Executing queries...
# Committing transaction
# Closing connectionExample 2: API Client with Custom Exceptions
class APIClient:
"""HTTP API client with comprehensive error handling"""
def __init__(self, base_url, api_key):
self.base_url = base_url
self.api_key = api_key
def get_user(self, user_id):
"""Fetch user by ID"""
try:
# Simulate API call
if user_id < 1:
raise ValueError("Invalid user ID")
if user_id == 999:
# Simulate 404
raise NotFoundError(f"user/{user_id}")
# Simulate success
return {"id": user_id, "name": "John Doe"}
except ValueError as e:
raise ClientError(f"Invalid request: {e}", 400) from e
def create_user(self, user_data):
"""Create a new user"""
required_fields = ['name', 'email']
# Validate input
for field in required_fields:
if field not in user_data:
raise ValidationError(
field,
None,
f"{field} is required"
)
return {"id": 123, **user_data}# Using the API client with proper error handling
client = APIClient("https://api.example.com", "secret-key")
# Handle specific errors
try:
user = client.get_user(999)
except NotFoundError as e:
print(f"User not found: {e.resource}")
except ClientError as e:
print(f"Client error ({e.status_code}): {e}")
except APIError as e:
print(f"API error: {e}")
# Output:
# User not found: user/999
# Validation errors
try:
client.create_user({"name": "Alice"})
except ValidationError as e:
print(e.user_friendly_message())
# Email: email is requiredExample 3: File Processor with Cleanup
class FileProcessingError(Exception):
"""Errors during file processing"""
def __init__(self, filename, reason):
self.filename = filename
self.reason = reason
super().__init__(f"Failed to process {filename}: {reason}")
class FileProcessor:
"""Process files with automatic cleanup"""
def __init__(self, input_file, output_file):
self.input_file = input_file
self.output_file = output_file
self.input_handle = None
self.output_handle = None
def __enter__(self):
try:
# Open files
print(f"Opening {self.input_file} for reading")
# In real code: self.input_handle = open(self.input_file, 'r')
self.input_handle = True # Simulated
print(f"Opening {self.output_file} for writing")
# In real code: self.output_handle = open(self.output_file, 'w')
self.output_handle = True # Simulated
return self
except IOError as e:
raise FileProcessingError(
self.input_file,
f"Cannot open file: {e}"
) from e
def __exit__(self, exc_type, exc_value, traceback):
# Always close files, even if exception occurred
if self.input_handle:
print(f"Closing {self.input_file}")
# self.input_handle.close()
if self.output_handle:
if exc_type is not None:
print(f"Error occurred, removing {self.output_file}")
# Remove partial output file
print(f"Closing {self.output_file}")
# self.output_handle.close()
return False # Propagate exceptions
def process(self):
"""Process the file"""
print("Processing file...")
# In real code: read, transform, write
return "Processed successfully"# Using the file processor
try:
with FileProcessor('input.txt', 'output.txt') as processor:
result = processor.process()
print(result)
except FileProcessingError as e:
print(f"Processing failed: {e.reason}")
# Output:
# Opening input.txt for reading
# Opening output.txt for writing
# Processing file...
# Processed successfully
# Closing input.txt
# Closing output.txtKey Takeaways
- Custom exceptions make your code more maintainable by expressing domain-specific failures clearly
- Exception hierarchies allow you to catch errors at different levels of specificity
- Add custom attributes to exceptions to provide additional context for debugging
- Context managers guarantee cleanup code runs, even when exceptions occur
- Use
@contextmanagerdecorator for simpler context manager creation - Chain exceptions with
raise ... from ...to preserve error context - Be specific when catching exceptions - avoid broad catches like
except Exception - Never swallow exceptions silently - always log or handle them appropriately
- Document exceptions in docstrings so users know what to expect
What's Next?
You've mastered custom exceptions! Now let's learn powerful text processing with regular expressions.
- Regex Patterns - Match and extract text patterns efficiently
- Validation - Validate emails, URLs, and user input
- Text Processing - Parse and transform text data with regex