What happens to assert statements and __debug__ when Python runs with -O?
answer
- A flag changes which statements exist
- The compiler, not the runtime, decides
- __debug__ is a folded compile-time constant
- Whole statement never reaches the bytecode
- -O strips asserts, -OO also docstrings
basics
~20 sThe -O flag sets the builtin __debug__ to False and makes the compiler omit every assert statement from the bytecode entirely. A check written as an assert then guards nothing, so an assert must never carry input validation or a security decision.
solid answer
~40 s`__debug__` is a compile-time constant, not a normal name: it is True by default and False when the interpreter starts with `-O`, `-OO` or `PYTHONOPTIMIZE` set. The compiler folds it, so `assert cond, msg` — which is just `if __debug__ and not cond: raise AssertionError(msg)` — and any `if __debug__:` block are simply not emitted under `-O`. Nothing at runtime can turn them back on; you cannot rebind `__debug__`. The practical consequence is that an assert is a *development* invariant only. Anything that must hold in production — validating untrusted input, checking an authorization result, verifying an ordering guarantee your algorithm depends on — has to be an explicit `if` that raises a real exception. `-OO` does everything `-O` does and additionally discards docstrings, which also breaks any code that reads `__doc__`.
code
python · 10 linesimport subprocess, sys
SRC = "print(__debug__); assert False, 'guard'; print('reached the end')"
for flags in ([], ["-O"]):
done = subprocess.run(
[sys.executable, *flags, "-c", SRC],
capture_output=True, text=True,
)
print(flags, "->", done.stdout.strip().replace("\n", " | "), "| rc:", done.returncode)go deeper
Recall the headline fact: running Python with -O deletes assert statements, so an assert cannot be trusted to run. Use asserts in tests, and raise a real exception when you need a check that always happens.
Explain the compilation: assert expands under if __debug__, debug is folded at compile time, so the whole statement is absent from the bytecode. Mention PYTHONOPTIMIZE, -OO stripping docstrings, and the always-true tuple assert.
Show that you would audit for asserts on input and authorization paths, log the optimization level at startup, and push back on -O as a performance change since it disables asserts inside every dependency too.
Own the policy: which invariants are allowed to be development-only, how build and runtime flags are pinned so a deploy cannot silently change semantics, and how that guarantee is verified rather than assumed.
## The mechanism `assert expr, message` is not a function call; it is a statement with a special compilation rule. The compiler expands it to roughly: ```python if __debug__: if not expr: raise AssertionError(message) ``` `__debug__` is a builtin, but an unusual one: the compiler treats it as a literal constant and refuses to let you rebind it (`__debug__ = False` is a `SyntaxError`). Its value is decided once, at interpreter startup, by the optimization level: `True` normally, `False` when the interpreter was launched with `-O` or `-OO`, or with the environment variable `PYTHONOPTIMIZE` set to a non-empty, non-zero value. Because the constant is folded at compile time, the entire assert — the condition, the message expression, the raise — is **not emitted into the bytecode at all** under `-O`. The same is true of any `if __debug__:` block you write yourself. This has a caching consequence worth knowing: cached bytecode is written to distinct files per optimization level (the `.pyc` names carry an `opt-1` / `opt-2` tag), so a module compiled under `-O` and the same module compiled normally coexist without clobbering each other. ## Why this makes assert unsafe as a guard The danger is that the code *reads* like a check. Consider a museum-catalogue importer whose merge step depends on records arriving in ascending accession-number order: ```python def merge(records): assert all(a.accession <= b.accession for a, b in zip(records, records[1:])) ... ``` Run normally, a scrambled feed raises `AssertionError` and the import stops. Run with `-O` — which someone eventually adds to the container entrypoint because they read that it makes Python faster — the ordering assumption is no longer checked at all, and the merge silently produces a corrupted catalogue whose damage is discovered much later. Nothing changed in the source; the guard evaporated. The security-relevant version of the same mistake is worse: `assert user.is_admin`, `assert len(payload) < MAX`, `assert token_is_valid(t)`. Under `-O` those become no-ops and the code proceeds as if the check had passed. That is a live authorization bypass produced by an interpreter flag. ## Where assert is still right Asserts are the correct tool where losing them is acceptable by construction: * **Tests.** Test suites are not run under `-O`, and the statement's ability to introspect and report the failing expression is exactly what a test wants. * **Internal invariants** that document a programmer's assumption about code you control — "this branch is unreachable", "the cache was populated by the caller". If it can only be violated by a bug in your own module, an assert is fine. * **Type-narrowing hints** for a static checker, where the runtime effect is incidental. Anything reachable by untrusted or merely *external* input is not in that list. ## The two adjacent traps **The tuple assert.** `assert (cond, "message")` — with parentheses around both parts — asserts a two-element tuple, which is always truthy, so it can never fail. Modern CPython emits a `SyntaxWarning` for the literal form, but the warning goes to stderr at compile time and is easy to miss in a build log. It is worth grepping for. **Assuming `-O` is a performance feature.** It removes asserts and docstrings; it does not enable any meaningful optimization in CPython. Turning it on buys almost nothing and silently disables every assert in the process, including those in third-party libraries you depend on, which may be relying on them for internal consistency. If somebody proposes `-O` for speed, the correct answer is that the speed is not there and the risk is. ## Detecting the state you are in Because `__debug__` is just a name at runtime, a service can log its own optimization level at startup — printing `__debug__` is enough — and refuse to boot in a mode it was not tested in. `sys.flags` also exposes the level. That turns "somebody added `-O` to the entrypoint" from an invisible behaviour change into a startup line you can alert on.
- Where is an assert still the right tool?In tests, and for invariants about code you fully control — an unreachable branch, a precondition another method in the same class guarantees. The test is whether losing the check under -O is acceptable. Anything driven by external input, or that makes an authorization or resource-limit decision, must be an explicit if that raises a real exception.
- How would you find out whether production is running optimized?Print or log the builtin `__debug__` at startup: it is False under -O, -OO or PYTHONOPTIMIZE. `sys.flags` also carries the optimization level. Emitting it as a startup line means a change to the entrypoint shows up in logs instead of silently deleting every assert in the process.
- What does -OO do that -O does not?-OO additionally discards docstrings from the compiled bytecode, so `__doc__` becomes None everywhere. That breaks help output, doctest collection and any library that introspects docstrings — for example a CLI or a schema builder that derives descriptions from them. It also caches to a separate opt-2 tagged bytecode file.
An assert is a scaffolding tie, not a load-bearing beam: it holds the structure honest while you build, and the -O flag is the crew taking the scaffolding away.
saying these in an interview costs you the question
- Uses assert to validate untrusted input or authorization
- Thinks -O speeds up loops in CPython
- Writes assert (cond, 'message') with enclosing parentheses
- Believes __debug__ can be reassigned at runtime
- Assumes -OO only removes docstrings, not asserts
- Says the assert still runs, only the message is dropped