Python Basics — Custom Exceptions

3 minute read

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 (not Exception): InsufficientFundsError, not InsufficientFundsException
  • Use descriptive names: InvalidEmailError, not EmailError
  • 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 Error suffix
  • Always call super().__init__(message) in your __init__

Next up: Debugging Techniques →

Updated: