DEV Community

Mahiro Hirakawa
Mahiro Hirakawa

Posted on Edited on

My safety check was an assert. Five lines made the module refuse to load without it.

assert isinstance(flag, bool) reads like a check. Under python -O it is not there at all, and nothing at the call site says so. The usual advice is to stop writing safety checks as asserts. There is a better answer: let the module refuse to be imported in the mode where its checks evaporate.

if not __debug__:
    raise RuntimeError("this module's checks are asserts; refusing to run with -O")
Enter fullscreen mode Exit fullscreen mode
$ python useguard.py
guard loaded, gate(True) = True
$ python -O useguard.py; echo $?
RuntimeError: this module's checks are asserts; refusing to run with -O
1
Enter fullscreen mode Exit fullscreen mode

Five lines, and the failure moves from the approval to the import. A deployment that will not start is a different kind of problem from a gate that quietly stopped gating.

What the runtime will and will not tell you

Two different questions get confused here, and they have different answers.

What has this image been imported under? The optimization level is stamped into the bytecode cache filename, so the artifacts accumulate:

$ python use.py && python -O use.py && python -OO use.py
$ ls __pycache__
m.cpython-314.opt-1.pyc
m.cpython-314.opt-2.pyc
m.cpython-314.pyc
Enter fullscreen mode Exit fullscreen mode

That is real evidence and it survives the process that made it. It is also easy to lose:

$ rm -rf __pycache__ && PYTHONDONTWRITEBYTECODE=1 python -O use.py
$ ls __pycache__
(no such directory)
Enter fullscreen mode Exit fullscreen mode

A read-only image layer does the same thing. So the artifact answers a forensic question, and answers it only sometimes.

What is this process running under right now? That one is reliable:

$ python probe.py
sys.flags.optimize = 0 | __debug__ = True
$ python -O probe.py
sys.flags.optimize = 1 | __debug__ = False
Enter fullscreen mode Exit fullscreen mode
question source can it be missing?
what has been imported __pycache__/*.opt-N.pyc yes: PYTHONDONTWRITEBYTECODE, or a read-only layer
what is running now sys.flags.optimize, __debug__ no

The guard uses the second one — but not the way this table implies, and the difference matters.

Correction (2026-09-10). @vinhnguyenthanhdn pointed out that __debug__ is folded to a constant when a module is compiled, so the guard is not consulting the running process at all. It is asking about its own bytecode. Reproduced here on 3.12.10, sourceless (compile, copy the pyc beside nothing, delete the source):

$ # pyc compiled optimize=1, imported by plain python
process __debug__ = True  | sys.flags.optimize = 0
  guard: RAISED

$ # pyc compiled optimize=0, imported by python -O
process __debug__ = False | sys.flags.optimize = 1
  guard: silent
  assert inside the module: FIRED

$ # control, same module with its source present
plain:  guard silent,  assert fires
-O:     guard RAISED
Enter fullscreen mode Exit fullscreen mode

Two consequences.

The row above that reads "what is running now" is true of sys.flags.optimize and false of the __debug__ the guard sees. Two different questions under one name, which is the same shape of confusion this post is about.

And the invariant I claimed was too wide. I wrote that the module refuses the mode that deletes its checks, which sounds like a statement about the process. The second row is the case that disproves it: the process is at -O, the guard says nothing, and the assert fires anyway, because that module's checks were never stripped. The guard was right to stay quiet.

The true statement is per-module, not per-process: a module never runs with its own checks stripped. Narrower than "nothing runs unchecked under -O", and stronger for a sourceless distribution, where the process flag tells you nothing about what is inside the pyc you shipped.

Why "just don't use asserts" is the weaker rule

It is advice to every future author of every line in the module, and review is what enforces it. The import guard is one line. The interpreter enforces that one, including over asserts nobody has written yet.

It also fails in the right direction. Someone deploying with -O for speed gets a crash with a sentence explaining the conflict, at startup, in their own terminal. The alternative is that the same person deploys successfully and finds out later, from an action nobody approved.

What it does not do

It does not make asserts a good way to express a safety invariant. If the check matters, if not isinstance(...): raise is still better, and the guard is what you put on top of the module while the asserts are still in it.

It also stops at the module boundary. A module with the guard is safe under -O; a module in the same process without it is not, and nothing coordinates the two.

Both of those cost more than five lines. This costs five.


Measured on CPython 3.14.4 (Windows) and cross-checked against 3.14.6 on macOS by @vinhnguyenthanhdn, who pointed out the __pycache__ artifact in the first place. The three-file listing and the PYTHONDONTWRITEBYTECODE result are reproduced from runs on both.

Top comments (2)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen •

The guard is stronger than your table says, and for a reason the table does not have a row for: __debug__ is folded at compile time, so the guard is a property of the bytecode rather than of the process. Both directions on 3.14.6, shipping only a sourceless .pyc with no source next to it:

pyc compiled optimize=1, imported by plain python  ->  process __debug__ True,   guard RAISES
pyc compiled optimize=0, imported by python -O     ->  process __debug__ False,  guard silent, assert still fires
Enter fullscreen mode Exit fullscreen mode

So the guard fires exactly when the asserts in that same bytecode are gone, and stays quiet whenever they are present, which is a tighter coupling than consulting the live process would give you. It does cost you one row though: for a sourceless distribution, "what is running now" is not the thing the guard reads, so setting PYTHONOPTIMIZE at deploy time against pre-compiled files does not trip it, and it also should not, because the asserts it defends were settled by the same compile.

Collapse
 
mahirhir profile image
Mahiro Hirakawa •

Both of your rows reproduce, and the correction is bigger than a missing row. My table is wrong about what the guard reads.

3.12.10 here, a different minor from your 3.14.6. Sourceless: compile, copy the pyc beside nothing, delete the source.

$ # A: pyc compiled optimize=1, imported by plain python
process __debug__ = True  | sys.flags.optimize = 0
  guard: RAISED -> this modules checks are asserts; refusing to run with -O

$ # B: pyc compiled optimize=0, imported by python -O
process __debug__ = False | sys.flags.optimize = 1
  guard: silent
  assert inside the module: FIRED -> approval must be a real bool

$ # control, same module with its source present
plain:  guard silent,  assert fires
-O:     guard RAISED
Enter fullscreen mode Exit fullscreen mode

The control is there because A and B on their own could be a broken guard rather than a compile-time one; with the source present it behaves the ordinary way in both directions, so the difference is the sourceless pyc and not the guard.

Where my table is wrong

I wrote that sys.flags.optimize and __debug__ answer "what is running now" and cannot be missing. The second half is true of sys.flags.optimize. It is not true of the __debug__ the guard reads, because that one is not read at all — it is folded to a constant when the module is compiled, so the guard is asking about its own bytecode. Two different questions wearing one name, which is exactly the shape of thing the post was supposed to be about.

And the invariant is better than the one I claimed

I described this as "the module refuses the mode that deletes its checks", which reads as a statement about the process. Your row B is the case that shows it is not: the process is at -O, the guard says nothing, and the assert fires anyway, because that module's checks were never stripped. The guard was right to stay quiet.

So the honest statement is per-module rather than per-process: a module never runs with its own checks stripped. That is narrower than "nothing runs unchecked under -O" and it is the thing that is actually true. It is also the stronger guarantee for a sourceless distribution, where the process flag tells you nothing about what is in the pyc you shipped.

I will correct the article on both points and cite you.