Raising errors on purpose
Catching an exception handles a problem somebody else's code reported. Raising one is how your code reports a problem it cannot sensibly solve.
def split_bill(total, people):
if people < 1:
raise ValueError("people must be at least 1")
return total / people
print(split_bill(1000, 4))
print(split_bill(1000, 0))
ValueError: people must be at least 1
The first call printed 250.0. The second stopped the program with a message naming exactly what was wrong.
Why raise instead of returning None
The tempting alternative is to hand back a harmless-looking value:
def split_bill(total, people):
if people < 1:
return None
return total / people
share = split_bill(1000, 0)
print(share)
None
Nothing crashed, which sounds better and is worse. That None now travels:
the next line multiplies it, or prints it on a receipt, or stores it in a
database. When something finally breaks it will be far from the actual mistake,
and the traceback will point at innocent code.
An exception stops at the point of the problem and names it. The cost of fixing a bug goes up with the distance between where it happened and where you noticed, and raising keeps that distance at zero.
Choosing the right exception
Use the built-in that fits. It costs nothing and every Python programmer already knows what it means.
def register(name, age):
if not isinstance(age, int):
raise TypeError(f"age must be an int, got {type(age).__name__}")
if age < 0:
raise ValueError(f"age cannot be negative, got {age}")
return f"{name} registered"
print(register("Ayesha", 20))
print(register("Bilal", -5))
ValueError: age cannot be negative, got -5
The distinction is worth learning because it is consistent across the whole language:
TypeError— the wrong kind of thing. A string where a number belongs.ValueError— the right kind, an unacceptable value. A negative age.KeyError/IndexError— asked for something that is not there.NotImplementedError— this method is meant to be overridden.
Include the offending value in the message. "age cannot be negative" sends
the reader hunting; "age cannot be negative, got -5" often ends the
investigation on the spot.
Custom exceptions
When a caller might reasonably want to catch your specific problem, give it its own class.
class InsufficientStock(Exception):
"""Raised when an order asks for more than the shop holds."""
def take(stock, item, quantity):
if stock.get(item, 0) < quantity:
raise InsufficientStock(f"only {stock.get(item, 0)} {item} left")
stock[item] = stock[item] - quantity
return stock
shelf = {"chai": 3}
try:
take(shelf, "chai", 10)
except InsufficientStock as error:
print(f"Cannot complete order: {error}")
Cannot complete order: only 3 chai left
That is the whole definition — a class inheriting from Exception, with a
docstring and no body. The value is at the call site: a caller can catch
InsufficientStock specifically and offer the customer something else, while
letting every other error through.
For a larger project, one base class per area lets callers choose how broadly to catch:
class ShopError(Exception):
"""Base for anything the shop can refuse."""
class InsufficientStock(ShopError):
pass
class ClosedForPrayer(ShopError):
pass
try:
raise ClosedForPrayer("back in 20 minutes")
except ShopError as error:
print(f"Shop says: {error}")
Shop says: back in 20 minutes
Catching ShopError catches both. Inherit from Exception, never from
BaseException — that one also covers KeyboardInterrupt, and catching it
means a user cannot press Ctrl-C to stop your program.
Re-raising
Sometimes you want to note an error and let it continue upward.
def load(value):
try:
return int(value)
except ValueError:
print("load failed, passing it on")
raise
try:
load("abc")
except ValueError as error:
print(f"caller handled: {error}")
load failed, passing it on caller handled: invalid literal for int() with base 10: 'abc'
A bare raise inside an except re-raises the original exception with its
original traceback. Writing raise ValueError(...) instead would throw away
where it came from.
To add context while keeping the cause, use from:
class ConfigError(Exception):
pass
try:
try:
int("abc")
except ValueError as error:
raise ConfigError("max_seats is not a number") from error
except ConfigError as error:
print(error)
print(type(error.__cause__).__name__)
max_seats is not a number ValueError
The caller gets a message in the vocabulary of your program, and the original cause is still attached for whoever debugs it.
assert is for programmer mistakes
assert checks something you believe is always true, and raises
AssertionError when it is not.
def average(numbers):
assert len(numbers) > 0, "average() needs at least one number"
return sum(numbers) / len(numbers)
print(average([2, 4, 6]))
print(average([]))
AssertionError: average() needs at least one number
The syntax is assert condition, message. Note that both are separated by a
comma and not wrapped in brackets:
numbers = []
assert (len(numbers) > 0, "this never fails")
print("still running")
still running
That asserted a two-item tuple, which is always truthy, so the check does nothing at all. It is a common enough mistake that Python now warns about it — and it still passes silently in plenty of codebases.
The reason assert is not for validation
Here is the part that matters, and it is why assert should never guard
anything a user can cause.
Python removes every assert when run with the -O flag. Optimised mode
strips them entirely — the check does not merely pass, it is not there.
python -O program.py # every assert vanishes
So this is a security bug waiting to happen:
def withdraw(account, amount):
assert amount <= account.balance # gone under -O
account.balance -= amount
Under -O the account goes negative and nothing complains.
The rule:
assert— for things that should be impossible if your code is correct. Internal consistency, sanity checks during development, and test assertions.raise— for anything caused by input, files, the network or a caller. Validation, in other words.
Rewrite the withdrawal properly:
def withdraw(balance, amount):
if amount > balance:
raise ValueError(f"cannot withdraw {amount} from {balance}")
return balance - amount
print(withdraw(1000, 250))
print(withdraw(1000, 5000))
ValueError: cannot withdraw 5000 from 1000
That check is part of the program's behaviour, so it is written as behaviour.
Fail early
The best place to raise is the earliest point the problem is knowable — usually the top of a function, before any work has been done.
def book_seats(total_seats, booked, wanted):
"""Return the new booked count, or raise if the request is impossible."""
if wanted < 1:
raise ValueError(f"wanted must be at least 1, got {wanted}")
if booked + wanted > total_seats:
raise ValueError(f"only {total_seats - booked} seats left")
return booked + wanted
print(book_seats(52, 47, 3))
50
Every objection is raised before anything is changed, so a failure leaves the data exactly as it was. That property — validate first, mutate second — is worth more than any amount of error handling further down.
Next: debugging, for the errors nobody raised on purpose.
Prefer learning with a teacher?
WasiLearn Academy runs small-group classes covering this material, online and in Karachi.
See the classes