WasiLearn Code
Python · When things go wrong

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)
output
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}")
output
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}")
output
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}")
output
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__)
output
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")
output
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))
output
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