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 itSomethingError. - 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__(callingsuper().__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.