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 withpython -O
or by settingPYTHONOPTIMIZE=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.