Custom Exceptions
What You'll Learn
How to define your own exception types, when custom exceptions add value, and how to structure an exception hierarchy for a real application.
Why Custom Exceptions?
Built-in exceptions are generic. Custom exceptions carry meaning:
# ❌ Generic — caller doesn't know what kind of ValueError this is
raise ValueError("User not found")
raise ValueError("Invalid email")
raise ValueError("Password too short")
# ✅ Custom — each error type is distinct and catchable separately
raise UserNotFoundError(user_id=42)
raise InvalidEmailError("not-an-email")
raise PasswordTooShortError(min_length=8, actual=5)
With custom exceptions, callers can:
- Catch and handle specific cases differently
- Get structured data from the exception
- Write more readable
exceptblocks
Defining a Simple Custom Exception
Every custom exception inherits from Exception (or a subclass):
class ConfigError(Exception):
"""Raised when the application config is invalid or missing."""
pass
class UserNotFoundError(Exception):
"""Raised when a user lookup returns no result."""
pass
class ValidationError(Exception):
"""Raised when input data fails validation."""
pass
Usage:
def find_user(user_id: int) -> dict:
user = db.get(user_id)
if user is None:
raise UserNotFoundError(f"No user with id={user_id}")
return user
try:
user = find_user(999)
except UserNotFoundError as e:
print(f"User not found: {e}")
Adding Structured Data to Exceptions
Pass relevant data in __init__ so callers can inspect it:
class ValidationError(Exception):
"""Raised when input fails validation."""
def __init__(self, field: str, message: str, value=None):
self.field = field
self.message = message
self.value = value
super().__init__(f"{field}: {message} (got {value!r})")
class RateLimitError(Exception):
"""Raised when an API rate limit is hit."""
def __init__(self, limit: int, retry_after: float):
self.limit = limit
self.retry_after = retry_after
super().__init__(
f"Rate limit of {limit} req/min exceeded — retry after {retry_after:.1f}s"
)
Usage:
try:
validate_email("not-an-email")
except ValidationError as e:
print(f"Field '{e.field}' failed: {e.message}")
# Caller can read e.field, e.message, e.value programmatically
try:
api.call()
except RateLimitError as e:
time.sleep(e.retry_after) # use the structured data
api.call()
Exception Hierarchy for an Application
Group related exceptions under a base class — callers can catch all or just specific ones:
# Base exception for this application
class AppError(Exception):
"""Base class for all application errors."""
pass
# --- User domain ---
class UserError(AppError):
"""Base class for user-related errors."""
pass
class UserNotFoundError(UserError):
def __init__(self, user_id: int):
self.user_id = user_id
super().__init__(f"User not found: id={user_id}")
class UserAlreadyExistsError(UserError):
def __init__(self, email: str):
self.email = email
super().__init__(f"User already exists: {email}")
# --- Config domain ---
class ConfigError(AppError):
"""Base class for configuration errors."""
pass
class MissingConfigError(ConfigError):
def __init__(self, key: str):
self.key = key
super().__init__(f"Required config key missing: {key}")
class InvalidConfigError(ConfigError):
def __init__(self, key: str, value, reason: str):
self.key = key
super().__init__(f"Invalid config {key}={value!r}: {reason}")
# --- Storage domain ---
class StorageError(AppError):
pass
class RecordNotFoundError(StorageError):
pass
class DuplicateRecordError(StorageError):
pass
Callers can catch at any level:
try:
user = get_user(42)
except UserNotFoundError:
# Handle specifically
return "404 Not Found"
except UserError:
# Handle any user error
return "400 Bad Request"
except AppError:
# Handle any app error
return "500 Internal Server Error"
Exception Chaining
Use raise ... from ... to preserve the original cause:
def load_user_file(path: str) -> dict:
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError as e:
raise UserNotFoundError(f"User file not found: {path}") from e
except json.JSONDecodeError as e:
raise ConfigError(f"Corrupt user file: {path}") from e
The original exception is available as __cause__ and shown in the traceback:
UserNotFoundError: User file not found: data/user_42.json
The above exception was the direct cause of the following exception:
FileNotFoundError: [Errno 2] No such file or directory: 'data/user_42.json'
When to Use Custom Exceptions
| Use custom exceptions | Use built-in exceptions |
|---|---|
| Domain-specific errors | Programming mistakes (wrong arg type) |
| Errors callers should handle differently | Errors that always represent bugs |
| Errors that carry structured data | Simple validation (ValueError) |
| Building a library or package | One-off scripts |
Practical Pattern: Validation Exception
from dataclasses import dataclass
@dataclass
class FieldError:
field: str
message: str
class FormValidationError(Exception):
"""Raised when form/input validation fails."""
def __init__(self, errors: list[FieldError]):
self.errors = errors
messages = "; ".join(f"{e.field}: {e.message}" for e in errors)
super().__init__(f"Validation failed: {messages}")
def validate_user_form(data: dict) -> None:
errors = []
if not data.get("name"):
errors.append(FieldError("name", "required"))
if not data.get("email") or "@" not in data["email"]:
errors.append(FieldError("email", "must be a valid email address"))
if len(data.get("password", "")) < 8:
errors.append(FieldError("password", "must be at least 8 characters"))
if errors:
raise FormValidationError(errors)
Common Mistakes
| Mistake | Fix |
|---|---|
Inheriting from BaseException | Inherit from Exception instead |
No message in super().__init__() | Always call super().__init__(message) |
| Catching your own exception too broadly | Catch at the right level |
| Creating exceptions for every small thing | Only when callers handle them differently |
Forgetting from e in chained raises | Use raise NewError(...) from e |
Quick Reference
# Simple custom exception
class MyError(Exception):
"""Description of when this is raised."""
pass
# With structured data
class MyError(Exception):
def __init__(self, field: str, value):
self.field = field
self.value = value
super().__init__(f"{field}={value!r} is invalid")
# Exception hierarchy
class AppError(Exception): pass
class UserError(AppError): pass
class UserNotFoundError(UserError):
def __init__(self, user_id: int):
self.user_id = user_id
super().__init__(f"User {user_id} not found")
# Chaining
raise NewError("context") from original_error
# Catching hierarchy
except UserNotFoundError: # specific
except UserError: # all user errors
except AppError: # all app errors