Python Basics — Custom Exceptions
Custom Exceptions
Python’s built-in exceptions cover most situations, but for larger programs, you’ll want to create your own. Custom exceptions make your error handling more specific, readable, and professional.
Creating Exception Classes
Custom exceptions are regular Python classes that inherit from Exception:
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the account balance."""
pass
# Usage
raise InsufficientFundsError("Cannot withdraw $500: only $200 available")
That’s it! The simplest custom exception is just a class with pass.
Why Custom Exceptions?
# ❌ Generic — hard to handle specifically
raise ValueError("Insufficient funds")
raise ValueError("Invalid account number")
raise ValueError("Transfer limit exceeded")
# ✅ Specific — each can be caught independently
raise InsufficientFundsError("Cannot withdraw $500")
raise InvalidAccountError("Account 'XYZ' not found")
raise TransferLimitError("Daily limit of $10,000 exceeded")
Callers can now handle each error type differently:
try:
transfer(from_acc, to_acc, amount)
except InsufficientFundsError:
print("Add more funds to complete this transfer")
except InvalidAccountError:
print("Please check the account number")
except TransferLimitError:
print("Try a smaller amount or wait until tomorrow")
Adding Attributes to Exceptions
Store extra information in your exception:
class ValidationError(Exception):
"""Raised when data validation fails."""
def __init__(self, field, value, message):
self.field = field
self.value = value
self.message = message
super().__init__(f"{field}: {message} (got {value!r})")
# Usage
try:
age = -5
if age < 0:
raise ValidationError("age", age, "must be non-negative")
except ValidationError as e:
print(f"Field: {e.field}")
print(f"Value: {e.value}")
print(f"Message: {e.message}")
print(f"Full: {e}")
Output:
Field: age
Value: -5
Message: must be non-negative
Full: age: must be non-negative (got -5)
Exception Hierarchy Design
For larger projects, organize exceptions in a hierarchy:
# Base exception for your application
class AppError(Exception):
"""Base exception for MyApp."""
pass
# Category-level exceptions
class AuthError(AppError):
"""Authentication and authorization errors."""
pass
class DatabaseError(AppError):
"""Database-related errors."""
pass
# Specific exceptions
class InvalidCredentialsError(AuthError):
"""Wrong username or password."""
pass
class AccountLockedError(AuthError):
"""Account temporarily locked."""
pass
class ConnectionError(DatabaseError):
"""Cannot connect to database."""
pass
class QueryError(DatabaseError):
"""Invalid database query."""
pass
Why hierarchy matters:
# Catch all auth errors
try:
login(username, password)
except AuthError:
print("Authentication failed")
# Or catch a specific one
try:
login(username, password)
except AccountLockedError:
print("Account locked. Try again in 30 minutes.")
except InvalidCredentialsError:
print("Wrong username or password.")
# Catch ALL app errors
try:
run_app()
except AppError as e:
print(f"Application error: {e}")
Real-World Example: Validation System
class ValidationError(Exception):
"""Base validation error."""
def __init__(self, errors=None):
self.errors = errors or []
message = "; ".join(self.errors)
super().__init__(message)
class RequiredFieldError(ValidationError):
"""A required field is missing."""
def __init__(self, field):
super().__init__([f"'{field}' is required"])
self.field = field
class InvalidFormatError(ValidationError):
"""Field value has wrong format."""
def __init__(self, field, expected_format):
super().__init__([f"'{field}' must match format: {expected_format}"])
self.field = field
self.expected_format = expected_format
def validate_user(data):
"""Validate user registration data."""
errors = []
# Check required fields
for field in ["name", "email", "age"]:
if field not in data or not data[field]:
errors.append(f"'{field}' is required")
if errors:
raise ValidationError(errors)
# Check email format
if "@" not in data["email"]:
raise InvalidFormatError("email", "user@domain.com")
# Check age range
if not isinstance(data["age"], int) or data["age"] < 13:
raise ValidationError(["Age must be an integer >= 13"])
return True
# Test
test_cases = [
{"name": "Alice", "email": "alice@example.com", "age": 25},
{"name": "", "email": "bob@example.com", "age": 30},
{"name": "Carol", "email": "not-an-email", "age": 20},
{},
]
for data in test_cases:
try:
validate_user(data)
print(f"✅ Valid: {data.get('name', 'N/A')}")
except ValidationError as e:
print(f"❌ Invalid: {e}")
Output:
✅ Valid: Alice
❌ Invalid: 'name' is required
❌ Invalid: 'email' must match format: user@domain.com
❌ Invalid: 'name' is required; 'email' is required; 'age' is required
Naming Conventions
- Always end with
Error(notException):InsufficientFundsError, notInsufficientFundsException - Use descriptive names:
InvalidEmailError, notEmailError - Group related exceptions under a base class:
AppError,AuthError, etc.
Custom Exception Template
Here’s a reusable template:
class CustomError(Exception):
"""Describe when this exception is raised."""
def __init__(self, message="", **kwargs):
self.details = kwargs
super().__init__(message)
def __str__(self):
base = super().__str__()
if self.details:
details = ", ".join(f"{k}={v!r}" for k, v in self.details.items())
return f"{base} ({details})"
return base
# Usage
raise CustomError("Something failed", code=404, context="user lookup")
# CustomError: Something failed (code=404, context='user lookup')
Summary
- Custom exceptions inherit from
Exception(or a subclass) - Use them when built-in exceptions aren’t specific enough
- Add attributes to carry extra error context
- Design hierarchies for larger projects:
AppError→AuthError→InvalidCredentialsError - Name exceptions with the
Errorsuffix - Always call
super().__init__(message)in your__init__
Next up: Debugging Techniques →