Skip to content

Exception Handling Advanced

Handle errors gracefully with advanced exception handling techniques.

Exception handling is crucial for writing robust Python applications. While basic try/except blocks are essential, advanced techniques like custom exceptions, exception chaining, and exception groups make your error handling more precise and informative.

Creating custom exceptions helps make your code more readable and allows for specific error handling:

custom_exceptions.py
class ValidationError(Exception):
"""Base exception for validation errors"""
pass
class InvalidEmailError(ValidationError):
"""Raised when email format is invalid"""
pass
class InvalidAgeError(ValidationError):
"""Raised when age is invalid"""
pass
def validate_user(email: str, age: int):
"""Validate user data"""
if "@" not in email:
raise InvalidEmailError(f"Invalid email format: {email}")
if age < 0 or age > 150:
raise InvalidAgeError(f"Invalid age: {age}")
# Usage
try:
validate_user("invalid-email", 25)
except InvalidEmailError as e:
print(f"Email error: {e}")
except InvalidAgeError as e:
print(f"Age error: {e}")
except ValidationError as e:
print(f"Validation error: {e}") # Catches any ValidationError

Add context to your exceptions:

exceptions_with_data.py
class PaymentError(Exception):
"""Base exception for payment errors"""
def __init__(self, message: str, amount: float, transaction_id: str = None):
super().__init__(message)
self.amount = amount
self.transaction_id = transaction_id
class InsufficientFundsError(PaymentError):
"""Raised when account has insufficient funds"""
pass
class PaymentProcessingError(PaymentError):
"""Raised when payment processing fails"""
pass
def process_payment(amount: float, balance: float):
"""Process a payment"""
if amount > balance:
raise InsufficientFundsError(
f"Insufficient funds. Required: {amount}, Available: {balance}",
amount=amount,
transaction_id="TXN-123"
)
if amount < 0:
raise PaymentProcessingError(
"Invalid payment amount",
amount=amount
)
return {"status": "success", "amount": amount}
# Usage
try:
process_payment(1000.0, 500.0)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"Amount: {e.amount}")
print(f"Transaction ID: {e.transaction_id}")
except PaymentProcessingError as e:
print(f"Processing error: {e}")

Exception chaining preserves the original exception context:

When an exception occurs while handling another exception:

implicit_chaining.py
def process_data(filename: str):
"""Process data from file"""
try:
with open(filename, 'r') as f:
data = f.read()
# Simulate processing error
result = int(data) / 0 # This will raise ZeroDivisionError
except FileNotFoundError:
# This exception occurs while handling FileNotFoundError
# Python automatically chains them
raise ValueError("Could not process file") # Implicitly chains
# Usage
try:
process_data("nonexistent.txt")
except ValueError as e:
print(f"ValueError: {e}")
print(f"Caused by: {e.__cause__}") # The original FileNotFoundError

Explicit Exception Chaining with raise from

Section titled “Explicit Exception Chaining with raise from”

Use raise from to explicitly chain exceptions:

explicit_chaining.py
class DatabaseError(Exception):
"""Database operation error"""
pass
class ConnectionError(DatabaseError):
"""Database connection error"""
pass
def connect_to_database():
"""Connect to database"""
try:
# Simulate connection error
raise ConnectionError("Could not connect to database")
except ConnectionError as e:
# Chain with more context
raise DatabaseError("Database operation failed") from e
# Usage
try:
connect_to_database()
except DatabaseError as e:
print(f"Database error: {e}")
print(f"Caused by: {e.__cause__}") # The ConnectionError
print(f"Original traceback preserved!")

Use raise from None to suppress the original exception:

suppress_context.py
def process_user_input(value: str):
"""Process user input"""
try:
return int(value)
except ValueError:
# Suppress the original ValueError, raise new one
raise TypeError(f"Expected integer, got {type(value).__name__}") from None
# Usage
try:
process_user_input("abc")
except TypeError as e:
print(f"Type error: {e}")
# Original ValueError is suppressed

Exception groups allow handling multiple exceptions simultaneously:

exception_groups.py
# Python 3.11+ feature
def validate_user_data(email: str, age: int, username: str):
"""Validate user data - may raise multiple errors"""
errors = []
if "@" not in email:
errors.append(ValueError(f"Invalid email: {email}"))
if age < 0:
errors.append(ValueError(f"Invalid age: {age}"))
if len(username) < 3:
errors.append(ValueError(f"Username too short: {username}"))
if errors:
raise ExceptionGroup("Validation errors", errors)
# Usage
try:
validate_user_data("invalid", -5, "ab")
except* ValueError as eg: # Catches all ValueError exceptions in group
for error in eg.exceptions:
print(f"Error: {error}")
api_errors.py
class APIError(Exception):
"""Base exception for API errors"""
def __init__(self, message: str, status_code: int, response_data: dict = None):
super().__init__(message)
self.status_code = status_code
self.response_data = response_data or {}
class AuthenticationError(APIError):
"""Raised when authentication fails"""
def __init__(self, message: str = "Authentication failed", response_data: dict = None):
super().__init__(message, status_code=401, response_data=response_data)
class NotFoundError(APIError):
"""Raised when resource not found"""
def __init__(self, resource: str, response_data: dict = None):
message = f"Resource not found: {resource}"
super().__init__(message, status_code=404, response_data=response_data)
class RateLimitError(APIError):
"""Raised when rate limit exceeded"""
def __init__(self, retry_after: int, response_data: dict = None):
message = f"Rate limit exceeded. Retry after {retry_after} seconds"
super().__init__(message, status_code=429, response_data=response_data)
self.retry_after = retry_after
def make_api_request(url: str):
"""Make API request"""
import random
# Simulate different error scenarios
error_type = random.choice(["auth", "not_found", "rate_limit", "success"])
if error_type == "auth":
raise AuthenticationError(response_data={"error": "Invalid token"})
elif error_type == "not_found":
raise NotFoundError("user/123", response_data={"error": "User not found"})
elif error_type == "rate_limit":
raise RateLimitError(retry_after=60, response_data={"error": "Too many requests"})
else:
return {"status": "success", "data": {"id": 123}}
# Usage
try:
response = make_api_request("https://api.example.com/users")
print(f"Success: {response}")
except AuthenticationError as e:
print(f"Auth error ({e.status_code}): {e}")
# Retry with new token
except NotFoundError as e:
print(f"Not found ({e.status_code}): {e}")
# Handle missing resource
except RateLimitError as e:
print(f"Rate limit ({e.status_code}): Retry after {e.retry_after}s")
# Wait and retry
except APIError as e:
print(f"API error ({e.status_code}): {e}")
# Handle other API errors
database_errors.py
class DatabaseError(Exception):
"""Base exception for database errors"""
pass
class ConnectionError(DatabaseError):
"""Database connection error"""
def __init__(self, message: str, host: str, port: int):
super().__init__(message)
self.host = host
self.port = port
class QueryError(DatabaseError):
"""Database query error"""
def __init__(self, message: str, query: str):
super().__init__(message)
self.query = query
class TransactionError(DatabaseError):
"""Database transaction error"""
def __init__(self, message: str, transaction_id: str):
super().__init__(message)
self.transaction_id = transaction_id
def execute_query(query: str):
"""Execute database query"""
try:
# Simulate connection error
if "SELECT" not in query.upper():
raise ConnectionError("Connection lost", host="localhost", port=5432)
# Simulate query error
if "DROP" in query.upper():
raise QueryError("Invalid query", query=query)
return {"rows": [{"id": 1, "name": "Alice"}]}
except ConnectionError as e:
# Chain with more context
raise DatabaseError(f"Database operation failed: {query}") from e
except QueryError as e:
raise DatabaseError(f"Query execution failed") from e
# Usage
try:
result = execute_query("DROP TABLE users")
except DatabaseError as e:
print(f"Database error: {e}")
if e.__cause__:
print(f"Caused by: {e.__cause__}")
if isinstance(e.__cause__, QueryError):
print(f"Problematic query: {e.__cause__.query}")
validation_errors.py
class ValidationError(Exception):
"""Base validation error"""
def __init__(self, message: str, field: str):
super().__init__(message)
self.field = field
class RequiredFieldError(ValidationError):
"""Raised when required field is missing"""
pass
class InvalidFormatError(ValidationError):
"""Raised when field format is invalid"""
pass
class OutOfRangeError(ValidationError):
"""Raised when field value is out of range"""
pass
def validate_user(data: dict):
"""Validate user data - collect all errors"""
errors = []
# Validate email
if "email" not in data:
errors.append(RequiredFieldError("Email is required", field="email"))
elif "@" not in data["email"]:
errors.append(InvalidFormatError("Invalid email format", field="email"))
# Validate age
if "age" not in data:
errors.append(RequiredFieldError("Age is required", field="age"))
elif not isinstance(data["age"], int):
errors.append(InvalidFormatError("Age must be an integer", field="age"))
elif data["age"] < 0 or data["age"] > 150:
errors.append(OutOfRangeError("Age must be between 0 and 150", field="age"))
# Validate username
if "username" not in data:
errors.append(RequiredFieldError("Username is required", field="username"))
elif len(data.get("username", "")) < 3:
errors.append(InvalidFormatError("Username must be at least 3 characters", field="username"))
if errors:
# Python 3.11+ exception groups
try:
raise ExceptionGroup("Validation failed", errors)
except NameError:
# Fallback for older Python versions
error_messages = [f"{e.field}: {e}" for e in errors]
raise ValidationError(f"Multiple validation errors: {', '.join(error_messages)}", field="multiple")
# Usage
try:
validate_user({"email": "invalid", "age": -5})
except ExceptionGroup as eg:
for error in eg.exceptions:
print(f"{error.field}: {error}")
except ValidationError as e:
print(f"Validation error: {e}")
specific_exceptions.py
# ❌ Bad - too broad
try:
process_data()
except Exception as e: # Catches everything!
print("Error occurred")
# ✅ Good - specific exceptions
try:
process_data()
except ValueError as e:
print(f"Invalid value: {e}")
except FileNotFoundError as e:
print(f"File not found: {e}")
except Exception as e:
print(f"Unexpected error: {e}") # Only for truly unexpected errors
suppressing_exceptions.py
# ❌ Bad - silently suppresses errors
try:
process_data()
except Exception:
pass # Error is lost!
# ✅ Good - log or handle appropriately
import logging
logger = logging.getLogger(__name__)
try:
process_data()
except ValueError as e:
logger.error(f"Invalid data: {e}")
raise # Re-raise if needed
except Exception as e:
logger.exception("Unexpected error occurred")
raise # Re-raise unexpected errors
finally_cleanup.py
def process_file(filename: str):
"""Process file with proper cleanup"""
file = None
try:
file = open(filename, 'r')
data = file.read()
process(data)
except FileNotFoundError:
print(f"File not found: {filename}")
raise
except Exception as e:
print(f"Error processing file: {e}")
raise
finally:
if file:
file.close() # Always close, even if error occurs
print("Cleanup complete")

4. Provide Context with Exception Chaining

Section titled “4. Provide Context with Exception Chaining”
exception_context.py
# ❌ Bad - loses original error context
def process_data():
try:
result = complex_operation()
except ValueError:
raise RuntimeError("Processing failed") # Original error lost
# ✅ Good - preserves context
def process_data():
try:
result = complex_operation()
except ValueError as e:
raise RuntimeError("Processing failed") from e # Preserves context
meaningful_messages.py
# ❌ Bad - vague error message
class BadError(Exception):
pass
raise BadError("Error") # What error?
# ✅ Good - descriptive error message
class GoodError(Exception):
def __init__(self, field: str, value: any, reason: str):
message = f"Invalid {field}: {value}. Reason: {reason}"
super().__init__(message)
self.field = field
self.value = value
self.reason = reason
raise GoodError("email", "invalid@", "Missing domain")
retry_pattern.py
import time
import random
class RetryableError(Exception):
"""Error that can be retried"""
pass
def retry_with_backoff(func, max_retries: int = 3, base_delay: float = 1.0):
"""Retry function with exponential backoff"""
for attempt in range(max_retries):
try:
return func()
except RetryableError as e:
if attempt == max_retries - 1:
raise # Last attempt, raise the error
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
print(f"Attempt {attempt + 1} failed. Retrying in {delay:.2f}s...")
time.sleep(delay)
raise Exception("Max retries exceeded")
def unreliable_operation():
"""Operation that might fail"""
if random.random() < 0.7:
raise RetryableError("Temporary failure")
return "Success"
# Usage
result = retry_with_backoff(unreliable_operation)
print(result)
error_context.py
from contextlib import contextmanager
@contextmanager
def error_context(operation: str):
"""Context manager that adds error context"""
try:
yield
except Exception as e:
raise RuntimeError(f"Error during {operation}") from e
# Usage
with error_context("data processing"):
process_data() # Any error will include "data processing" context
exception_logging.py
import logging
from functools import wraps
logger = logging.getLogger(__name__)
def log_exceptions(func):
"""Decorator that logs exceptions"""
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
logger.exception(f"Exception in {func.__name__}: {e}")
raise
return wrapper
@log_exceptions
def risky_operation():
"""Operation that might raise exceptions"""
raise ValueError("Something went wrong")
# Usage
try:
risky_operation()
except ValueError:
pass # Exception was logged
too_broad.py
# ❌ Bad - catches everything
try:
process_data()
except Exception: # Too broad!
pass
# ✅ Good - catch specific exceptions
try:
process_data()
except ValueError:
handle_value_error()
except FileNotFoundError:
handle_file_error()
ignoring_exceptions.py
# ❌ Bad - ignores errors
try:
critical_operation()
except Exception:
pass # Error is ignored!
# ✅ Good - handle or log
try:
critical_operation()
except Exception as e:
logger.error(f"Critical operation failed: {e}")
raise # Re-raise if critical
losing_context.py
# ❌ Bad - loses original exception
try:
operation()
except ValueError:
raise RuntimeError("Failed") # Original ValueError lost
# ✅ Good - preserve context
try:
operation()
except ValueError as e:
raise RuntimeError("Failed") from e # Preserves ValueError
  1. Use specific exceptions - Catch what you expect, handle specifically
  2. Preserve context - Use raise from to chain exceptions
  3. Log exceptions - Always log errors for debugging
  4. Clean up resources - Use finally for cleanup
  5. Create custom exceptions - For domain-specific errors
  6. Provide context - Include relevant data in exceptions
  7. Don’t suppress silently - Always handle or re-raise
  8. Document exceptions - Document what exceptions functions can raise
  • Custom exceptions - Create domain-specific error types
  • Exception chaining - Use raise from to preserve context
  • Exception groups - Handle multiple exceptions (Python 3.11+)
  • Be specific - Catch specific exception types
  • Preserve context - Don’t lose original exception information
  • Log errors - Always log exceptions for debugging
  • Clean up - Use finally for resource cleanup
  • Meaningful messages - Provide descriptive error messages

Remember: Handle errors gracefully - use custom exceptions, preserve context, and always clean up resources! 🛡️