Assertions and Exceptions are not the same

What’s wrong with the withdraw_money function in the code below?

# bank.py
from dataclasses import dataclass


@dataclass
class Client:

    balance: float


def withdraw_money(client: Client, amount: float):
    assert client.balance >= amount, f"Insufficient funds: {client.balance}"
    # Withdraw amount from the client's bank account.
    client.balance -= amount
    return client.balance


client = Client(balance=10.0)
new_balance = withdraw_money(client=client, amount=20.0)
print(f"The new balance is {new_balance}")

If you run it with python bank.py you’re given an error message:

$ python bank.py
Traceback (most recent call last):
  File "bank.py", line 20, in <module>
    new_balance = withdraw_money(client=client, amount=20.0)
  File "bank.py", line 14, in withdraw_money
    assert client.balance >= amount, f'Insufficient funds: {client.balance}'
AssertionError: Insufficient funds: 10.0

Which, at first sight, is correct: I am withdrawing 20.0 from a client account with a balance of 10.0, and the code is specifically written to exit if the withdrawal would put the client account into the red. So what’s the problem, then?

What’s an assertion?

Well, assertions are programmer aids that test for system invariants that must never occur in production. That means they are designed to catch and terminate the program if any of the programmer-specified assertions occur. They are often – in computer games and other large applications where performance is key – used in hot parts of the code where they often add a significant performance burden during development in return for exiting the application with additional debug information – something that is often much harder to get at easily in compiled languages – if an assertion error were to occur.

That’s why they’re stripped out of the code by the compiler during a release (or production) build. Now I’m sure you can see where I’m going with this…

A little-known feature of CPython (the official reference version of Python we all know and love) is the -O optimization flag you can pass to the python program on startup. It’s supposed to “optimize” the bytecode generated by Python but it adds little benefit, so most don’t use it, or even know about its existence.

So what happens if we rerun the script from before but with optimizations enabled:

$ python -O bank.py
The new balance is -10.0

Oops. The assertion’s elided from the bytecode and our code carried on without a care in the world.

Disassembling the optimized function

Disassembling the optimized withdraw_money function makes it obvious the assert statement is gone:

>>> import dis
>>> dis.dis(withdraw_money)
 18           0 LOAD_FAST                0 (client)
              2 DUP_TOP
              4 LOAD_ATTR                0 (balance)
              6 LOAD_FAST                1 (amount)
              8 INPLACE_SUBTRACT
             10 ROT_TWO
             12 STORE_ATTR               0 (balance)

 19          14 LOAD_FAST                0 (client)
             16 LOAD_ATTR                0 (balance)
             18 RETURN_VALUE

The column on the left indicate line numbers and correspond to the highlighted lines:

1def withdraw_money(client: Client, amount: float):
2    assert client.balance >= amount, f"Insufficient funds: {client.balance}"
3    # Withdraw amount from the client's bank account.
4    client.balance -= amount
5    return client.balance

Try comparing the disassembled output from dis.dis with the unoptimized version.

If you currently do this in your code, the solution is simple: raise an exception explicitly and capture it further up the call tree where you can act on the error directly. Don’t take that to mean that you should never use assertions — on the contrary, they are very useful. But they are programmer aids and not designed for general error handling.

Saying that, it’s easy to see why people get confused because assert itself raises an exception called AssertionError which does little to resolve the confusion between the two modes of control flow.

Summary

Assertions are programmer aids, not error handlers for your business logic

Asserts help programmers (and testers) spot invariants in your code that should never, ever happen once the code is stable and deployed. Use it to catch programming mistakes – ranging from the likely to improbable – liberally.

Assertions can be toggled on or off at will

So never use an assert when another method of capturing and raising an error is possible. Always write your code with the assumption that the assertions could disappear at any minute. And if you’re unsure, you can run your program with python -O or by setting PYTHONOPTIMIZE=1 to test if it still works.

Avoid side effects in assert statements

You should never cause side effects – meaning, something that modifies the state of something else, like creating a user in a database – in an assertion. It will result in serious, unintuitive errors in your code as the side-effected code ceases to exist if your code is run with assertions disabled.

Liked the Article?

Why not follow us …

Be Inspired Get Python tips sent to your inbox

We'll tell you about the latest courses and articles.

Absolutely no spam. We promise!