Your banking app needs to signal "insufficient funds". Using generic ValueError loses meaning. A custom InsufficientFundsError carries the balance and requested amount - meaningful error information for the specific domain.

Simple custom exception

Create your own exception type.

basic_custom.py
# Define basic custom exception

def main():
    # Simple custom exception
    class InsufficientFundsError(Exception):
        """Raised when account balance is insufficient"""
        pass
    
    def withdraw(balance, amount):
        if amount > balance:
            raise InsufficientFundsError("Not enough funds")
        return balance - amount
    
    print("Bank withdrawal:\n")
    
    # Successful withdrawal
    try:
        new_balance = withdraw(100, 30)
        print(f"  Withdrew $30: Balance ${new_balance}")
    except InsufficientFundsError as e:
        print(f"  Error: {e}")
    
    # Failed withdrawal
    try:
        new_balance = withdraw(100, 150)
        print(f"  Withdrew $150: Balance ${new_balance}")
    except InsufficientFundsError as e:
        print(f"  Error: {e}")
    
    
    # Multiple custom exceptions
    class InvalidUsernameError(Exception):
        pass
    
    class InvalidEmailError(Exception):
        pass
    
    class InvalidAgeError(Exception):
        pass
    
    def validate_user(username, email, age):
        if len(username) < 3:
            raise InvalidUsernameError("Username too short")
        if "@" not in email:
            raise InvalidEmailError("Invalid email format")
        if age < 18:
            raise InvalidAgeError("Must be 18 or older")
        return True
    
    print("\nUser validation:")
    
    users = [
        ("alice", "alice@example.com", 25),
        ("ab", "alice@example.com", 25),
        ("alice", "invalid", 25),
        ("alice", "alice@example.com", 15)
    ]
    
    for username, email, age in users:
        try:
            validate_user(username, email, age)
            print(f"  {username}: Valid")
        except (InvalidUsernameError, InvalidEmailError, InvalidAgeError) as e:
            print(f"  {username}: {type(e).__name__} - {e}")
    
    # Custom exception with default message
    class ConfigurationError(Exception):
        """Raised when configuration is invalid"""
        def __init__(self, message="Invalid configuration"):
            super().__init__(message)
    
    try:
        raise ConfigurationError()
    except ConfigurationError as e:
        print(f"\nDefault message: {e}")
    
    try:
        raise ConfigurationError("Missing API key")
    except ConfigurationError as e:
        print(f"Custom message: {e}")

if __name__ == "__main__":
    main()

Inherit from Exception. Add pass for minimal exception.

custom exception Your own exception class. Meaningful name, specific to your domain.

Exception with data

Include relevant context in the exception.

with_data.py
# Custom exception with additional data

def main():
    # Custom exception with fields
    class ValidationError(Exception):
        def __init__(self, message, field_name, invalid_value):
            super().__init__(message)
            self.field_name = field_name
            self.invalid_value = invalid_value
    
    def validate_age(age):
        if not isinstance(age, int):
            raise ValidationError(
                "Age must be an integer",
                field_name="age",
                invalid_value=age
            )
        if age < 0:
            raise ValidationError(
                "Age cannot be negative",
                field_name="age",
                invalid_value=age
            )
        if age > 150:
            raise ValidationError(
                "Age is unrealistic",
                field_name="age",
                invalid_value=age
            )
        return True
    
    print("Age validation with context:\n")
    
    test_ages = [25, "invalid", -5, 200]
    
    for age in test_ages:
        try:
            validate_age(age)
            print(f"  {age}: Valid")
        except ValidationError as e:
            print(f"  Field: {e.field_name}")
            print(f"  Value: {e.invalid_value}")
            print(f"  Error: {e}")
            print()
    
    
    # Exception with error code
    class DatabaseError(Exception):
        def __init__(self, message, query, error_code):
            super().__init__(message)
            self.query = query
            self.error_code = error_code
    
    def execute_query(query):
        if "DROP" in query:
            raise DatabaseError(
                "DROP not allowed",
                query=query,
                error_code=403
            )
        if "invalid" in query:
            raise DatabaseError(
                "Syntax error",
                query=query,
                error_code=400
            )
        return "SUCCESS"
    
    print("\nDatabase queries:")
    
    queries = [
        "SELECT * FROM users",
        "DROP TABLE users",
        "invalid syntax"
    ]
    
    for query in queries:
        try:
            result = execute_query(query)
            print(f"  '{query}': {result}")
        except DatabaseError as e:
            print(f"  Error code: {e.error_code}")
            print(f"  Query: {e.query}")
            print(f"  Message: {e}")
            print()
    
    # Exception with multiple context fields
    class PaymentError(Exception):
        def __init__(self, message, amount, account_id, transaction_id):
            super().__init__(message)
            self.amount = amount
            self.account_id = account_id
            self.transaction_id = transaction_id
        
        def __str__(self):
            return (f"{super().__str__()} "
                   f"[Txn:{self.transaction_id}, "
                   f"Account:{self.account_id}, "
                   f"Amount:${self.amount}]")
    
    try:
        raise PaymentError(
            "Insufficient funds",
            amount=100.00,
            account_id="ACC123",
            transaction_id="TXN456"
        )
    except PaymentError as e:
        print("Payment failure:")
        print(f"  {e}")
        print(f"  Amount: ${e.amount}")
        print(f"  Account: {e.account_id}")
        print(f"  Transaction: {e.transaction_id}")

if __name__ == "__main__":
    main()

Add __init__ with parameters. Store data as attributes.

Exception hierarchy

Organize related exceptions into a hierarchy.

hierarchy.py
# Exception hierarchy

def main():
    # Create exception hierarchy
    class AppError(Exception):
        """Base exception for this application"""
        pass
    
    class ValidationError(AppError):
        """Validation failed"""
        pass
    
    class AuthenticationError(AppError):
        """Authentication failed"""
        pass
    
    class AuthorizationError(AppError):
        """Authorization failed"""
        pass
    
    class InvalidCredentialsError(AuthenticationError):
        """Specific auth error"""
        pass
    
    def process_request(username, password, action):
        # Authentication
        if not username or not password:
            raise InvalidCredentialsError("Missing credentials")
        if password != "secret":
            raise AuthenticationError("Wrong password")
        
        # Authorization
        if action == "delete" and username != "admin":
            raise AuthorizationError(f"{username} cannot delete")
        
        # Validation
        if not action:
            raise ValidationError("Action required")
        
        return "SUCCESS"
    
    print("Request processing:\n")
    
    requests = [
        ("alice", "secret", "read"),
        ("", "secret", "read"),
        ("bob", "wrong", "read"),
        ("alice", "secret", "delete"),
        ("admin", "secret", "delete")
    ]
    
    for user, pwd, action in requests:
        try:
            result = process_request(user, pwd, action)
            print(f"  {user} - {action}: {result}")
        except InvalidCredentialsError as e:
            print(f"  {user} - {action}: Invalid credentials")
        except AuthenticationError as e:
            print(f"  {user} - {action}: Auth failed")
        except AuthorizationError as e:
            print(f"  {user} - {action}: Not authorized")
        except ValidationError as e:
            print(f"  {user} - {action}: Validation error")
    
    
    # Catch at different levels
    print("\nCatching at different levels:")
    
    try:
        raise InvalidCredentialsError("Bad login")
    except AuthenticationError as e:
        # Catches InvalidCredentialsError (subclass)
        print(f"  Caught as AuthenticationError: {type(e).__name__}")
    
    try:
        raise AuthorizationError("No permission")
    except AppError as e:
        # Catches any AppError subclass
        print(f"  Caught as AppError: {type(e).__name__}")
    
    # Module-specific hierarchy
    class DataError(Exception):
        """Base for data-related errors"""
        pass
    
    class ParseError(DataError):
        """Failed to parse data"""
        pass
    
    class FormatError(DataError):
        """Invalid data format"""
        pass
    
    class EncodingError(DataError):
        """Encoding problem"""
        pass
    
    def process_data(data, format_type):
        if format_type not in ["json", "xml", "csv"]:
            raise FormatError(f"Unsupported format: {format_type}")
        if not data:
            raise ParseError("Empty data")
        if not data.startswith("{"):
            raise EncodingError("Invalid encoding")
        return "PARSED"
    
    print("\nData processing:")
    
    datasets = [
        ('{"name": "Alice"}', "json"),
        ("", "json"),
        ("not json", "json"),
        ('{"name": "Bob"}', "yaml")
    ]
    
    for data, fmt in datasets:
        try:
            result = process_data(data, fmt)
            print(f"  {fmt}: {result}")
        except ParseError as e:
            print(f"  {fmt}: Parse error - {e}")
        except FormatError as e:
            print(f"  {fmt}: Format error - {e}")
        except EncodingError as e:
            print(f"  {fmt}: Encoding error - {e}")
        except DataError as e:
            # Catch any other DataError
            print(f"  {fmt}: Data error - {e}")

if __name__ == "__main__":
    main()

Base exception for module. Specialized subclasses for specific errors.

Override __str__

Customize exception's string representation.

override_str.py
# Override __str__ for custom formatting

def main():
    # Custom __str__ method
    class ProductNotFoundError(Exception):
        def __init__(self, product_id, category=None):
            self.product_id = product_id
            self.category = category
        
        def __str__(self):
            msg = f"Product {self.product_id} not found"
            if self.category:
                msg += f" in category '{self.category}'"
            return msg
    
    print("Product lookup:\n")
    
    try:
        raise ProductNotFoundError(123, "Electronics")
    except ProductNotFoundError as e:
        print(f"  Error: {e}")
        print(f"  Product ID: {e.product_id}")
        print(f"  Category: {e.category}")
    
    try:
        raise ProductNotFoundError(456)
    except ProductNotFoundError as e:
        print(f"\n  Error: {e}")
        print(f"  Product ID: {e.product_id}")
    
    
    # Rich error messages
    class OrderError(Exception):
        def __init__(self, order_id, items, total, reason):
            self.order_id = order_id
            self.items = items
            self.total = total
            self.reason = reason
        
        def __str__(self):
            return (f"Order #{self.order_id} failed: {self.reason}\n"
                   f"  Items: {self.items}\n"
                   f"  Total: ${self.total:.2f}")
    
    print("\nOrder processing:")
    
    try:
        raise OrderError(
            order_id=789,
            items=["Laptop", "Mouse"],
            total=1050.00,
            reason="Payment declined"
        )
    except OrderError as e:
        print(f"{e}")
    
    # Exception with repr
    class ApiError(Exception):
        def __init__(self, status_code, endpoint, message):
            self.status_code = status_code
            self.endpoint = endpoint
            self.message = message
        
        def __str__(self):
            return f"[{self.status_code}] {self.message}"
        
        def __repr__(self):
            return (f"ApiError(status_code={self.status_code}, "
                   f"endpoint='{self.endpoint}', "
                   f"message='{self.message}')")
    
    error = ApiError(404, "/api/users/123", "User not found")
    
    print("\nAPI error:")
    print(f"  str: {str(error)}")
    print(f"  repr: {repr(error)}")
    
    # Format with multiple fields
    class ValidationError(Exception):
        def __init__(self, errors):
            self.errors = errors
        
        def __str__(self):
            if len(self.errors) == 1:
                field, msg = next(iter(self.errors.items()))
                return f"Validation failed: {field} - {msg}"
            
            lines = ["Multiple validation errors:"]
            for field, msg in self.errors.items():
                lines.append(f"  - {field}: {msg}")
            return "\n".join(lines)
    
    print("\nValidation errors:")
    
    try:
        raise ValidationError({"age": "Must be positive"})
    except ValidationError as e:
        print(f"{e}\n")
    
    try:
        raise ValidationError({
            "username": "Too short",
            "email": "Invalid format",
            "age": "Must be 18+"
        })
    except ValidationError as e:
        print(f"{e}")
    
    # Exception with timestamp
    from datetime import datetime
    
    class TimestampedError(Exception):
        def __init__(self, message):
            self.message = message
            self.timestamp = datetime.now()
        
        def __str__(self):
            time_str = self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            return f"[{time_str}] {self.message}"
    
    print("\nTimestamped error:")
    
    try:
        raise TimestampedError("Connection lost")
    except TimestampedError as e:
        print(f"  {e}")
        print(f"  Time: {e.timestamp}")

if __name__ == "__main__":
    main()

def __str__(self): controls what print(exception) shows.

When to create custom exceptions

Guidelines for when it's worth it.

when_to_use.py
# When to create custom exceptions

def main():
    # Domain-specific errors
    class ShoppingCartError(Exception):
        """Base for shopping cart errors"""
        pass
    
    class ItemNotInCartError(ShoppingCartError):
        def __init__(self, item_id):
            self.item_id = item_id
            super().__init__(f"Item {item_id} not in cart")
    
    class CartLimitExceededError(ShoppingCartError):
        def __init__(self, limit, attempted):
            self.limit = limit
            self.attempted = attempted
            super().__init__(
                f"Cart limit {limit} exceeded: tried to add {attempted}"
            )
    
    class Cart:
        def __init__(self, max_items=5):
            self.items = []
            self.max_items = max_items
        
        def add(self, item):
            if len(self.items) >= self.max_items:
                raise CartLimitExceededError(
                    self.max_items,
                    len(self.items) + 1
                )
            self.items.append(item)
        
        def remove(self, item_id):
            for item in self.items:
                if item["id"] == item_id:
                    self.items.remove(item)
                    return
            raise ItemNotInCartError(item_id)
    
    print("Shopping cart with custom exceptions:\n")
    
    cart = Cart(max_items=3)
    
    # Add items
    try:
        cart.add({"id": 1, "name": "Laptop"})
        cart.add({"id": 2, "name": "Mouse"})
        cart.add({"id": 3, "name": "Keyboard"})
        print(f"  Added 3 items: {len(cart.items)} in cart")
    except CartLimitExceededError as e:
        print(f"  Error: {e}")
    
    # Try to exceed limit
    try:
        cart.add({"id": 4, "name": "Monitor"})
    except CartLimitExceededError as e:
        print(f"  Error: {e}")
        print(f"  Limit: {e.limit}, Attempted: {e.attempted}")
    
    # Remove item
    try:
        cart.remove(2)
        print(f"\n  Removed item 2: {len(cart.items)} in cart")
    except ItemNotInCartError as e:
        print(f"  Error: {e}")
    
    # Try to remove non-existent
    try:
        cart.remove(999)
    except ItemNotInCartError as e:
        print(f"  Error: {e}")
        print(f"  Item ID: {e.item_id}")
    
    
    # API-style exceptions
    class ApiException(Exception):
        def __init__(self, status_code, message, details=None):
            self.status_code = status_code
            self.message = message
            self.details = details
            super().__init__(message)
    
    class BadRequestError(ApiException):
        def __init__(self, message, details=None):
            super().__init__(400, message, details)
    
    class NotFoundError(ApiException):
        def __init__(self, resource, resource_id):
            super().__init__(
                404,
                f"{resource} not found",
                {"resource": resource, "id": resource_id}
            )
    
    class UnauthorizedError(ApiException):
        def __init__(self, message="Unauthorized"):
            super().__init__(401, message)
    
    def api_request(endpoint, user_id, auth_token):
        if not auth_token:
            raise UnauthorizedError("Missing auth token")
        
        if auth_token != "valid_token":
            raise UnauthorizedError("Invalid token")
        
        if not user_id:
            raise BadRequestError(
                "Missing required parameter",
                {"missing_field": "user_id"}
            )
        
        if user_id not in [1, 2, 3]:
            raise NotFoundError("User", user_id)
        
        return {"user_id": user_id, "data": "..."}
    
    print("\nAPI requests:")
    
    requests = [
        ("/users/1", 1, "valid_token"),
        ("/users/999", 999, "valid_token"),
        ("/users/1", None, "valid_token"),
        ("/users/1", 1, None),
        ("/users/1", 1, "bad_token")
    ]
    
    for endpoint, user_id, token in requests:
        try:
            result = api_request(endpoint, user_id, token)
            print(f"  {endpoint}: OK")
        except UnauthorizedError as e:
            print(f"  {endpoint}: {e.status_code} {e.message}")
        except NotFoundError as e:
            print(f"  {endpoint}: {e.status_code} {e.message}")
            print(f"    Details: {e.details}")
        except BadRequestError as e:
            print(f"  {endpoint}: {e.status_code} {e.message}")
            print(f"    Details: {e.details}")
        except ApiException as e:
            # Catch-all for other API errors
            print(f"  {endpoint}: {e.status_code} {e.message}")

if __name__ == "__main__":
    main()

Create custom exceptions when you need specific handling or extra data.

Exercise: practical.py

Build a web API with domain-specific exceptions