DocsHub
Error Handling

Custom Exceptions

Learn how to create your own exception classes in Python to make error handling cleaner and more meaningful.

Custom Exceptions

Python's built-in exceptions — ValueError, TypeError, FileNotFoundError — are general purpose. They describe what went wrong technically, but they do not say anything about your specific application.

When you build a real project, you want errors that make sense in your context:

raise ValueError("invalid value")       # generic — tells you nothing about your app
raise InsufficientFundsError(amount=50) # specific — tells you exactly what happened

Custom exceptions make your code more readable, easier to debug, and easier to handle precisely.


Creating a basic custom exception

All you need is a class that inherits from Exception:

class InsufficientFundsError(Exception):
    pass

That is a complete, working custom exception. You can raise and catch it just like any built-in:

class InsufficientFundsError(Exception):
    pass

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError("Not enough funds in your account.")
    return balance - amount

try:
    withdraw(100, 200)
except InsufficientFundsError as e:
    print(f"Transaction failed: {e}")

Output:

Transaction failed: Not enough funds in your account.

Adding useful information

A plain pass exception works but does not carry much context. Add an __init__ to store extra data that helps diagnose the problem:

class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        self.shortfall = amount - balance
        super().__init__(
            f"Cannot withdraw ${amount:.2f}. "
            f"Balance is ${balance:.2f}. "
            f"You are ${self.shortfall:.2f} short."
        )

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    new_balance = withdraw(100.00, 250.00)
except InsufficientFundsError as e:
    print(e)
    print(f"Shortfall: ${e.shortfall:.2f}")

Output:

Cannot withdraw $250.00. Balance is $100.00. You are $150.00 short.
Shortfall: $150.00

The exception carries structured data — not just a message string. You can access e.balance, e.amount, and e.shortfall in your handler and make decisions based on them.

Always call super().__init__(message) to pass the message to the parent Exception class. This ensures the message shows up correctly in tracebacks and when you print the exception.


Building an exception hierarchy

For a real application, you want a tree of exceptions — a base exception for your app, with specific exceptions underneath. This lets you catch broadly or narrowly depending on what you need.

Exception BankingError InsufficientFundsError AccountLockedError TransactionLimitError InvalidAccountError
# base exception for the entire banking module
class BankingError(Exception):
    """Base class for all banking exceptions."""
    pass

# specific exceptions
class InsufficientFundsError(BankingError):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        self.shortfall = amount - balance
        super().__init__(
            f"Cannot withdraw ${amount:.2f}. "
            f"Balance: ${balance:.2f}. "
            f"Shortfall: ${self.shortfall:.2f}."
        )

class AccountLockedError(BankingError):
    def __init__(self, account_id, reason):
        self.account_id = account_id
        self.reason = reason
        super().__init__(
            f"Account {account_id} is locked. Reason: {reason}"
        )

class TransactionLimitError(BankingError):
    def __init__(self, amount, limit):
        self.amount = amount
        self.limit = limit
        super().__init__(
            f"Transaction of ${amount:.2f} exceeds daily limit of ${limit:.2f}."
        )

class InvalidAccountError(BankingError):
    def __init__(self, account_id):
        self.account_id = account_id
        super().__init__(f"Account {account_id} does not exist.")

Now you can catch at different levels of specificity:

def process_transaction(account_id, amount):
    # simulate some checks
    if account_id not in accounts:
        raise InvalidAccountError(account_id)
    if accounts[account_id]["locked"]:
        raise AccountLockedError(account_id, "suspicious activity detected")
    if amount > 5000:
        raise TransactionLimitError(amount, limit=5000)
    if amount > accounts[account_id]["balance"]:
        raise InsufficientFundsError(accounts[account_id]["balance"], amount)

# catch a specific exception
try:
    process_transaction("ACC001", 10000)
except InsufficientFundsError as e:
    print(f"Funds error: {e}")
    print(f"You need ${e.shortfall:.2f} more.")

# catch any banking exception
try:
    process_transaction("ACC999", 100)
except BankingError as e:
    print(f"Banking error: {e}")

# catch everything from most specific to least specific
try:
    process_transaction("ACC001", 10000)
except InsufficientFundsError as e:
    print("Not enough funds.")
except AccountLockedError as e:
    print(f"Account locked: {e.reason}")
except TransactionLimitError as e:
    print(f"Over the limit by ${e.amount - e.limit:.2f}")
except BankingError as e:
    print(f"General banking error: {e}")

The base exception BankingError is the safety net. If a new specific exception is added later and your code does not handle it yet, catching BankingError ensures it does not slip through unhandled.


Adding a str method

You can control exactly how your exception prints by defining __str__:

class ValidationError(Exception):
    def __init__(self, field, value, message):
        self.field = field
        self.value = value
        self.message = message
        super().__init__(message)

    def __str__(self):
        return f"[ValidationError] Field '{self.field}' = '{self.value}': {self.message}"

try:
    raise ValidationError("email", "not-an-email", "must contain @ symbol")
except ValidationError as e:
    print(e)

Output:

[ValidationError] Field 'email' = 'not-an-email': must contain @ symbol

A real example — form validation

A complete validation system using custom exceptions:

class ValidationError(Exception):
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")

class MissingFieldError(ValidationError):
    def __init__(self, field):
        super().__init__(field, "this field is required")

class InvalidFormatError(ValidationError):
    def __init__(self, field, expected_format):
        self.expected_format = expected_format
        super().__init__(field, f"invalid format — expected {expected_format}")

class ValueOutOfRangeError(ValidationError):
    def __init__(self, field, value, min_val, max_val):
        self.value = value
        self.min_val = min_val
        self.max_val = max_val
        super().__init__(
            field,
            f"value {value} is out of range ({min_val}-{max_val})"
        )


def validate_user(data):
    errors = []

    # check required fields
    for field in ["name", "email", "age"]:
        if field not in data or not data[field]:
            errors.append(MissingFieldError(field))

    # check email format
    if "email" in data and data["email"]:
        if "@" not in data["email"] or "." not in data["email"]:
            errors.append(InvalidFormatError("email", "user@example.com"))

    # check age range
    if "age" in data and data["age"]:
        try:
            age = int(data["age"])
            if age < 18 or age > 120:
                errors.append(ValueOutOfRangeError("age", age, 18, 120))
        except ValueError:
            errors.append(InvalidFormatError("age", "a whole number"))

    return errors


# test it
user_data = {
    "name": "Ali",
    "email": "not-an-email",
    "age": "15"
}

errors = validate_user(user_data)

if errors:
    print("Validation failed:")
    for error in errors:
        print(f"  - {error}")
else:
    print("Validation passed.")

Output:

Validation failed:
  - email: invalid format — expected user@example.com
  - age: value 15 is out of range (18-120)

Each error is a structured object — you know the field, the type of problem, and the message. This is far more useful than a plain string.


When to create custom exceptions

SituationCreate custom exception?
Your error is specific to your domainYes
You want to carry extra data with the errorYes
You want callers to handle your errors separatelyYes
You are building a library or packageYes
A built-in exception describes it perfectlyNo
It is a one-off scriptProbably not

Summary

ConceptExample
Basic custom exceptionclass MyError(Exception): pass
With custom messagesuper().__init__("message")
With extra dataself.field = field in __init__
Exception hierarchyInherit from a base custom exception
Catch specificexcept InsufficientFundsError
Catch all customexcept BankingError
Access extra datae.shortfall, e.field

On this page