skip to content

What does Python's `assert` statement do, and what happens to it under `python -O`?

level: juniorimportance: must knowfreq 48%

answer

  1. A statement, not a function call
  2. The interpreter can remove it entirely
  3. A command-line flag changes the outcome
  4. __debug__ is False under -O
  5. Falsy condition raises AssertionError

basics

~20 s

assert expr, msg raises AssertionError with msg when expr is falsy and does nothing otherwise. Starting the interpreter with -O, or with PYTHONOPTIMIZE set, removes every assert statement at compile time, so the check never runs.

solid answer

~40 s

`assert` is a statement, not a function. The compiler expands `assert expr, msg` into `if __debug__: if not expr: raise AssertionError(msg)`. `__debug__` is a compile-time constant that is `True` for a normal run and `False` when the interpreter starts with `-O` or with `PYTHONOPTIMIZE` set to a non-empty value; in that mode the compiler emits **no bytecode at all** for the statement, so the condition is never evaluated and any side effect inside it disappears too. `sys.flags.optimize` reports the level (0, 1 for `-O`, 2 for `-OO`). Because that flag is a property of the deployment and not of your source, `assert` can only ever be a developer-facing invariant check. Anything the program depends on — argument validation, parsing untrusted data, permission checks — has to be an explicit `if not cond: raise ...`.

code

python · 15 lines
python
import sys


def check(count):
    assert count >= 0, f'negative count: {count}'
    return count


print('optimize level:', sys.flags.optimize, '__debug__:', __debug__)
try:
    check(-1)
except AssertionError as exc:
    print('raised:', exc)
else:
    print('assert did not run')

go deeper

for a junior

Be ready to state the two operands, the exception type, and the one-line consequence: a flag on the command line can delete the statement, so never let a program's correctness depend on an assertion.

for a middle

Explain the expansion into if __debug__: if not expr: raise AssertionError(msg), that the message is evaluated only on failure, and that -O removes the bytecode rather than skipping a runtime check.

for a senior

Show the operational angle: the optimize level arrives from a base image or an environment variable nobody reviewed, it silences every library's assertions as well as yours, and sys.flags.optimize logged at boot is what makes that visible.

for a principal

Own the policy. Decide whether the codebase ships with -O at all, given the negligible speed win against a blast radius covering every dependency, and write down the house rule that separates invariants from enforced contracts.

### `assert` is a statement, not a function `assert` belongs to Python's grammar in the same family as `raise`, `return` and `del`. It takes one or two comma-separated expressions: the condition, and an optional failure message. It has no argument list of its own, which is why writing it as if it were a call is a real bug rather than a style nit. The compiler expands `assert expr, msg` into roughly this: ```python if __debug__: if not expr: raise AssertionError(msg) ``` Three things follow straight from that expansion. The condition is put through the ordinary truthiness test, so any object works, not just a `bool`. The message operand is evaluated *only* when the condition is falsy, so an expensive f-string message costs nothing on the happy path. And every part of it sits under `__debug__`. ### `__debug__` is a compile-time constant `__debug__` is a builtin name the compiler treats specially. You cannot rebind it — `__debug__ = False` is a `SyntaxError: cannot assign to __debug__` — and its value is fixed when the interpreter starts. In a normal run it is `True`. Start the interpreter with `-O`, or with the environment variable `PYTHONOPTIMIZE` set to a non-empty value, and it is `False`. Because the compiler knows the constant statically, it then emits **no bytecode at all** for the assert statement: the condition is never evaluated, the message is never built, and no branch is taken at runtime. This is removal at compile time, not a runtime check that quietly returns. `sys.flags.optimize` reports the level a process is running at: `0` for a normal run, `1` for `-O`, `2` for `-OO`. Both `__debug__` and that flag are readable from inside the program, which is the only reliable way to find out what a deployed process actually did. Bytecode caching follows the same split: an optimized run reads and writes a separately tagged cache file, carrying an `opt-1` tag alongside the ordinary one, so an interpreter started with `-O` never picks up cached bytecode that still contains the assertions. ### Why this makes `assert` a developer-only tool The optimize level is a property of the *deployment*, not of the source. A base container image, a platform default, a CI job or an operator's habit can set `PYTHONOPTIMIZE` without anyone touching your code, and every assertion in the process — yours and every library's — disappears together. So the honest rule is: * **`assert` states something that is true unless your own code has a bug.** Internal invariants, post-conditions, "this cache and this recomputed value must agree", "this branch is unreachable". Losing the check under `-O` loses a diagnostic, not a guarantee. * **`raise` states something that may be false because the outside world is not what you hoped.** Argument validation on a public function, parsed input from a file or a request, permission checks, configuration sanity. Those must be `if not cond: raise SomeError(...)` with a domain-appropriate exception type. The failure mode of getting this backwards is nasty precisely because it is silent: the code behaves identically in development, and in production the check is simply not there. ### Side effects inside an assertion Because the whole statement can vanish, anything with a side effect inside the asserted expression vanishes with it. A line that both consumes an item and checks it will consume nothing under `-O`, and the bug shows up as missing work rather than as a failed assertion. Keep asserted expressions pure and cheap. ### `AssertionError` in an exception hierarchy `AssertionError` derives from `Exception`, so a broad `except Exception:` handler catches it exactly like anything else. That is how a service ends up swallowing its own invariant failures and logging them as a generic error. It is also why `AssertionError` is a poor exception type to expose at an API boundary: to a reader it says "this program has a bug", not "your input was invalid", and a caller has no way to distinguish the two. ### What interviewers are actually testing The question is a proxy for a design instinct. A candidate who says "`assert` raises `AssertionError` when the expression is falsy" has answered half of it. The other half — that a command-line flag or an environment variable can delete the statement before it ever runs, and that this is what disqualifies `assert` from doing any job the program depends on — is the part that separates someone who has only written asserts in a scratch script from someone who has reasoned about what runs in production.

  • Is the message expression in `assert expr, msg` evaluated when the assertion passes?
    No. The compiler puts the message construction inside the failure branch, so it is built only when the condition is falsy. An expensive f-string or a `repr()` of a large object in the message costs nothing on the happy path, which is why detailed assertion messages are cheap. The condition itself, of course, is evaluated every time — unless the interpreter was started with `-O`, in which case neither one is.
  • How can a running process tell whether it was started with assertions enabled?
    Read `sys.flags.optimize`, which is `0` for a normal run, `1` for `-O` and `2` for `-OO`, or read the `__debug__` constant, which is `True` only at level 0. Logging one of them at startup alongside the interpreter version turns an invisible deployment property into an obvious one. A service that genuinely relies on its assertions can check the flag at boot and refuse to start.
  • If a module was already compiled to bytecode without `-O`, does running with `-O` reuse that cached file and keep the assertions?
    No. An optimized run uses a separate cache tag, so an `opt-1`-tagged file sits next to the ordinary one and the interpreter only loads bytecode matching its own optimize level. You never get a mixed state where a stale cache smuggles assertions into an optimized process, and clearing the cache changes nothing about the behaviour.

An assertion is scaffolding on a building: invaluable while the work is going on, and -O is the crew that takes every pole down before the doors open. Nothing structural may rest on it.

saying these in an interview costs you the question

  • Thinks a failed assertion raises ValueError, not AssertionError
  • Uses assert to validate request payloads or user input
  • Believes -O only speeds code up and leaves assertions running
  • Puts a state-changing call inside the asserted expression
  • Cannot say what __debug__ is or when it is False
  • Thinks catching AssertionError makes the check dependable

context