Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 8 of 9

Built-in exceptions cover general problems. For failures specific to your program — "insufficient funds", "invalid config" — a custom exception says exactly what went wrong.

Since exceptions are classes, defining one is just inheritance (Module 5):

class InsufficientFundsError(Exception):
    """Raised when an account lacks the funds for a withdrawal."""

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(f"Balance {balance} is less than {amount}")
    return balance - amount

try:
    withdraw(100, 500)
except InsufficientFundsError as e:
    print(f"Caught: {e}")

That's the whole recipe: inherit from Exception, give it a docstring, done. By convention the name ends in Error.

Why not just use ValueError?

Because callers can then catch your failure specifically, without accidentally catching unrelated ones:

class InsufficientFundsError(Exception):
    pass

def process(balance, amount, note):
    if amount > balance:
        raise InsufficientFundsError("Not enough money")
    return int(note)

try:
    process(1000, 10, "not-a-number")
except InsufficientFundsError:
    print("Handled: money problem")
except ValueError:
    print("Handled: bad note format")

If InsufficientFundsError were just a ValueError, both failures would land in the same handler and you couldn't tell them apart.

A base class per project

Give your program one base exception, then subclass it. Callers can catch everything from your code with a single except, or drill down when they need to:

class BankError(Exception):
    """Base class for all banking errors."""

class InsufficientFundsError(BankError):
    """Not enough money."""

class AccountNotFoundError(BankError):
    """No such account."""

def lookup(accounts, name):
    if name not in accounts:
        raise AccountNotFoundError(f"No account for {name!r}")
    return accounts[name]

accounts = {"Ada": 100}

try:
    lookup(accounts, "Grace")
except BankError as e:
    print(f"{type(e).__name__}: {e}")

Catching BankError catches both subclasses, because isinstance respects inheritance — exactly the rule from lesson 5.6.

Adding data to an exception

An exception is a normal class, so it can carry extra attributes. Give it an __init__, and call super().__init__() so the message still works:

class InsufficientFundsError(Exception):
    def __init__(self, balance, requested):
        self.balance = balance
        self.requested = requested
        self.shortfall = requested - balance
        super().__init__(f"Need {self.shortfall} more")

try:
    raise InsufficientFundsError(100, 500)
except InsufficientFundsError as e:
    print(e)
    print(e.shortfall)
    print(e.balance)

Now the handler can act on the numbers, not just print a message — offer an overdraft, log the shortfall, whatever the situation needs.

Keep it simple

Don't create a class per error message. A handful of meaningful categories beats twenty near-identical classes. If callers would handle two errors the same way, they should probably be the same exception with a different message.

Try it yourself

Build a small exception hierarchy and catch it both ways.

Checkpoint

  • A custom exception is a class inheriting from Exception; name it SomethingError.
  • Custom types let callers catch your failures precisely.
  • One base class per project, with subclasses, gives callers both broad and narrow handling.
  • Add attributes via __init__ (calling super().__init__(message)) so handlers can act on data.
  • Don't over-engineer — a few meaningful categories is plenty.

Questions about this lesson

Log in to ask a question.