assert disappears under -O¶
Level: 201 · for Python programmers, especially ones coming from C or ABAP
One line: assert is a debugging aid the compiler deletes when the interpreter runs with -O or PYTHONOPTIMIZE, so a check written as assert amount > 0 is not there in an optimised run and the withdrawal of -5 goes through — while an if and a raise are never optional, unittest's assertEqual is a method and survives, and assert (cond, 'msg') is a tuple that is always true.
python3 -c "assert 1 == 2, 'math is broken'; print('reached the end')"
python3 -O -c "assert 1 == 2, 'math is broken'; print('reached the end')"
assert cond, msg compiles to if __debug__: if not cond: raise AssertionError(msg), and __debug__ is a constant the compiler knows: True normally, False when the interpreter was started with -O. Under -O the whole statement is left out of the bytecode, the condition included, so an assert with a side effect loses the side effect too. That makes it a statement of belief, checked while you develop and test, and it makes it the wrong tool for anything that must hold in production: input validation, a permission check, a precondition on money.
Where -O comes from is the part people do not expect. The flag itself, the PYTHONOPTIMIZE variable, a wrapper script, a container's entry point, a .pyc compiled by someone else. The run below starts child interpreters both ways and shows what each one did with the same line. Its children run with -I unless a row says otherwise, which is how this library keeps a machine's settings out of an answer key, and -I turns out to be one of the rows.
The run¶
Verified output of assert_disappears_under_dash_o_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THE SAME LINE, WITH AND WITHOUT -O
python3 exit 1 stdout '' stderr AssertionError the assert fired and the program died
python3 -O exit 0 stdout 'reached the end' stderr - the assert is not there
PYTHONOPTIMIZE=1 exit 0 stdout 'reached the end' stderr - the environment can switch it on for you
PYTHONOPTIMIZE=1 -I exit 1 stdout '' stderr AssertionError unless -I: isolated mode ignores PYTHON* variables
2. WHAT -O REMOVES: assert, AND EVERY `if __debug__:` BLOCK
python3 exit 0 stdout 'True\ndebug block' stderr -
python3 -O exit 0 stdout 'False' stderr -
3. -OO ALSO REMOVES DOCSTRINGS
python3 -O exit 0 stdout 'the doc' stderr - still there
python3 -OO exit 0 stdout 'None' stderr - gone: help() and doctest see None
4. THE ASSERT THAT IS ALWAYS TRUE
python3 exit 0 stdout 'passed' stderr SyntaxWarning a non-empty tuple is true; the compiler warns
5. AN assert IS NOT VALIDATION
assert, python3 exit 1 stdout '' stderr AssertionError
assert, python3 -O exit 0 stdout '-5' stderr - the check went with the flag: -5 withdrawn
raise, python3 exit 1 stdout '' stderr ValueError
raise, python3 -O exit 1 stdout '' stderr ValueError an if and a raise are not optional
6. unittest's assertEqual IS A METHOD, NOT THE STATEMENT
python3 -O exit 0 stdout 'assertEqual still raises' stderr - so a test suite survives -O; its bare asserts do not
What the run shows¶
Section 1: the same line, with and without -O. Plain python3 raised AssertionError and exited with status 1; nothing after the assert ran. Under -O the same program printed reached the end and exited 0, because the statement was not there. PYTHONOPTIMIZE=1 in the environment does the same as the flag, which is how a deployment can switch every assert off without a line of code changing. The fourth row is the exception to that: with -I, isolated mode, the interpreter ignores every PYTHON* variable, so the same environment left the assert in place.
Section 2: what -O removes. __debug__ prints True and then False, and the if __debug__: block went with it: the optimised run printed only False. Anything you put under that test is development-only code, deleted by the same switch.
Section 3: -OO also removes docstrings. f.__doc__ is the string under -O and None under -OO, so help(), doctest and anything that reads a docstring at run time see nothing. That is the whole of what the two levels do: -O drops asserts and __debug__ blocks, -OO drops docstrings as well. Neither makes anything faster in a way you could measure on this page.
Section 4: the assert that is always true. assert (1 == 2, 'oops') printed passed, because the parentheses make a two-element tuple, and a non-empty tuple is true. The compiler has warned about this shape for years, as the SyntaxWarning in the last column shows, and A warning is not an exception is why a warning on stderr is easy to never see.
Section 5: an assert is not validation. withdraw(-5) guarded by an assert raised under plain python3 and returned -5 under -O: the check went with the flag. Guarded by an if and a raise ValueError, it raised under both, because an if is not optional. The rule that follows is short. An assert states something you believe is already true about your own code; a raise enforces something about the world.
Section 6: unittest's assertEqual is a method, not the statement. It raised AssertionError under -O, because it is an ordinary method that calls raise, so a unittest suite survives optimised mode while any bare assert statement inside its tests does not. pytest, which is not in the standard library, rewrites the assert statements in test files into calls for the same reason, and warns when it notices -O.
If you are coming from C¶
Python copied this from C. assert() from <assert.h> is removed by defining NDEBUG, -O is Python's -DNDEBUG, and the same two rules follow: no side effects inside an assert, and no validation of input with one. The C library's Building chapter ↗ is where the build flags that make that decision live.
If you are coming from Rust¶
Rust spells the two behaviours as two macros. assert! is always compiled in, release build or not; debug_assert! is the one the release profile removes. Python has one keyword and a flag, so the reader of an assert cannot tell from the source which kind it was meant to be. The Rust library's What a test asserts ↗ is the testing side.
If you are coming from ABAP¶
ABAP's ASSERT is always active, unless it is tied to a checkpoint group with ASSERT ID grp CONDITION …, in which case whether it runs is decided in transaction SAAB, per system and per user, by whoever administers the checkpoint groups. That is this page's -O as a runtime setting someone else controls: the same program asserts in one system and not in the next, and the source does not say which. (Not machine-checked — CI cannot run ABAP.)
Try it¶
- List the asserts in a codebase you maintain:
grep -rn '^\s*assert ' --include='*.py'. Sort them into beliefs about your own code and checks on data that came from outside. Section 5 says the second kind should be anifand araise. - Run your test suite once under
python3 -O -m unittest(or your runner of choice) and read what passes that should not have. - Search for the tuple shape,
grep -rn 'assert (' --include='*.py', then runpython3 -W error -c 'import yourmodule'to make theSyntaxWarningfatal. - Read your Dockerfile, service unit and CI configuration for
PYTHONOPTIMIZEor a-O. If either is there, section 1 says everyassertin the deployed code is gone. - Find an
assertwhose condition has a side effect,assert queue.pop() == expected, say. Under-Othepopnever happens, and the program is different, not just less checked.
See also¶
- A recorded output is not a test and
patchwhere it is looked up: the rest of this chapter -cis not the prompt: the opening block's quoting, and what the shell does to it first- A warning is not an exception: section 4's
SyntaxWarning, and why nobody saw it - The
assertstatement ↗ in the language reference, and-O↗,-I↗ andPYTHONOPTIMIZE↗ among the command-line options - What a test asserts ↗ in the Rust library