Skip to main content

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 except blocks

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 exceptionsUse built-in exceptions
Domain-specific errorsProgramming mistakes (wrong arg type)
Errors callers should handle differentlyErrors that always represent bugs
Errors that carry structured dataSimple validation (ValueError)
Building a library or packageOne-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

MistakeFix
Inheriting from BaseExceptionInherit from Exception instead
No message in super().__init__()Always call super().__init__(message)
Catching your own exception too broadlyCatch at the right level
Creating exceptions for every small thingOnly when callers handle them differently
Forgetting from e in chained raisesUse 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

What's Next

Lesson 5: Structured Logging with Context