When Assert Does Not Raise
The Definition of assert
#
The Python documentation is rather detailed, and the assert documentation is no exception. Specifically, it states that an assert
statement is equivalent to:
if __debug__:
if not expression: raise AssertionError
or the following if a second expression is given:
if __debug__:
if not expression1: raise AssertionError(expression2)
The very important part about assert
is that it relies on the __debug__
flag and its current state. If Python is not in debug mode, all assert statements are ignored. Therefore, you cannot rely on assert
to throw an error in all situations.
What is __debug__
?#
Per the Python documentation, __debug__
is true if Python was not started with a -O
option. There are two options, -O
and -OO
. These options do a few things. First, -O
will compile Python into bytecode, which will improve speed performance. -O
will also set __debug__
to False
, which causes all the assert statements to be dropped. The -OO
also drops docstrings.
Typically these optimization flags are used when something is being deployed to production or being distributed. This is because they will be slightly faster, and in the case of -OO
will remove docstrings, which may not be desired if distributing code. As a side node, distributing Python bytecode should be considered as distributing the code because even with -O
or -OO
the code can still be decompiled with tools like python-uncompyle6. I’d consider this security through obscurity, which is not a good idea.
The __debug__
flag can be very useful in code if you are looking for deeper optimizations. For example, putting dense logging statements inside an if __debug__:
will allow them to be fully optimized out, instead of checking the debug level of the logger, which is done at runtime.
We know if __debug__
is not set, asserts are ignored, but is there any other way to ignore fix this? Not to my knowledge, because the __debug__
documentation also notes that assignment to that value is forbidden, just like True
, False
, and None
. Trying to do so results in a SyntaxError
. Therefore, if you launch Python with -O
or -OO
, asserts will be removed, and there is no way to get them back.
What to do instead?#
Unless the assert
is purely for debug purposes, the most common method to handle this is to throw an AssertionError
. It is very easy to convert as follows:
assert len(list_input) != 0
into
if len(list_input) == 0:
raise AssertionError("Input list cannot be zero length")
You can choose a different error, like ValueError
, NameError
, IndexError
, or some other more specific exception type. Doing so may even help make it easier to debug with a more accurate exception.
Really Bad Assert Statements#
While in many situations, an assert
is catching something simple, like the length of a list. But if the assert
does any modifications, the behavior can change depending on if Python is running with -O
or -OO
.
For example, suppose that you want to check that a dictionary has a key, and that its value matches what is expected, but you don’t want it in the result. Maybe this dictionary represents if some result was valid.
input_message: dict[str, str] = {"sender": "Descartes", "message": "I think, therefore I am", "valid": "true"}
assert input_message.pop("valid") == "true"
After running this code, input_message
looks like:
>>> input_message
{'sender': 'Descartes', 'message': 'I think, therefore I am'}
I did all of this in my Python shell. This time, I’ll do the same thing, except when I launch Python, I’ll use python3 -O
. This is the resultant output:
>>> input_message: dict[str, str] = {"sender": "Descartes", "message": "I think, therefore I am", "valid": "true"}
>>> assert input_message.pop("valid") == "true"
>>> input_message
{'sender': 'Descartes', 'message': 'I think, therefore I am', 'valid': 'true'}
Since the -O
flag was used when launching Python, __debug__
is set to False
. Therefore, assert
is completely optimized out, and the pop
function is never called. So, by optimizing the code, the assert is not just removed, but also changes the behavior.
I’ve made this into a script called dictionary_pop.py
on GitHub. It can easily be invoked with python3 -O ./dictionary_pop.py
or python3 ./dictionary_pop.py
to show what happens when the assert
is/is not (respectively) optimized out.
And this is not a contrived example! The PyOpenCL library had a pull request for this exact scenario. The author hadn’t contemplated that the assert
may be optimized out.
Conclusion#
The only scenario where I can picture using a raw assert
is if performance is critical and the input is being generated solely from within the code (eg; no user inputs). In such a case, running in a non optimized mode for testing may enable internal bugs to be caught, while not needlessly doing the check in production. But if performance is so critical that this optimization matters, I would honestly ask why are you using Python.
My rule of thumb is that 99.9% of the time a bare assert
is wrong and should instead instead be a check with the appropriate thrown exception.