Compiler Optimization Passes

Between the moment CPython has a complete picture of a function’s code and the moment it freezes that code into a code object, it runs a fixed sequence of compile-time optimization passes over a control-flow graph (CFG) — the program reorganized into basic blocks (straight-line runs of instructions) connected by edges (jumps). These passes fold constant expressions, delete unreachable code, collapse chains of jumps, strip redundant NOPs, push cold blocks to the end, and fuse common opcode pairs into super-instructions. Crucially this all happens exactly once, at compile time, before any code runs — it is a different layer entirely from the runtime specializing adaptive interpreter (PEP 659) and the experimental tier-2 trace optimizer that rewrite bytecode while the program executes. As of CPython 3.14.5 the bulk of this machinery lives in Python/flowgraph.c (flowgraph.c, v3.14.5), with a thin earlier layer in Python/ast_preprocess.c and the assert/docstring handling split across Python/codegen.c and Python/ast_preprocess.c.

This note is about the static optimizer — the work done once, at compile time, on the CFG. It is deliberately separated from its runtime counterparts:

  • The specializing adaptive interpreter rewrites generic bytecodes into type-specialized ones (e.g. BINARY_OPBINARY_OP_ADD_INT) as a function runs, based on the types it actually observes, backed by inline caches. That is “tier 1” runtime adaptation; it never touches the constant-folding the CFG optimizer already did.
  • The tier-2 micro-op optimizer and the experimental JIT go further still, stitching hot specialized traces together and compiling them to machine code.

Everything below the CFG optimizer is runtime; everything in this note is compile-time. The two never overlap: the CFG optimizer has long since finished and the bytecode is frozen before the interpreter executes its first instruction.

Where in the Pipeline This Happens

CPython’s compilation pipeline turns source text into a code object through a fixed series of stages, documented at the top of compile.c (compile.c, v3.14.5):

  1. Check from __future__ statements.
  2. Build the symbol table (classify every name as local, global, cell, free).
  3. Walk the AST in codegen.c to emit a pseudo-instruction sequence — bytecode-like, but with some still-abstract operations and symbolic jump labels rather than offsets.
  4. Build a CFG from that instruction sequence and run optimizations on it. ← this note
  5. Assemble the optimized CFG into the final bytecode and wrap it in a PyCodeObject.

Before step 3 even begins, there is an AST-preprocessing pass (_PyAST_Preprocess in ast_preprocess.c) that does a small amount of optimization directly on the tree — not arithmetic folding (that moved out years ago; see the history section), but three specific tree rewrites covered below: __debug__ substitution, docstring removal under -OO, and printf-style %-format rewriting. So there are genuinely two layers of compile-time optimization: a thin AST-tree layer and the much larger CFG layer. Most people mean the CFG layer when they say “the optimizer,” and that is where this note spends most of its words.

flowchart TD
    src["Source text"] --> ast["AST<br/>(parser builds tree)"]
    ast --> pre["AST preprocess<br/>(ast_preprocess.c)<br/>__debug__ -> bool<br/>-OO: drop docstring<br/>percent-format rewrite"]
    pre --> cg["codegen.c<br/>walk AST -> pseudo-instructions<br/>(-O: assert emits nothing)"]
    cg --> cfg["Build CFG<br/>basic blocks + edges"]
    cfg --> opt["optimize_cfg + friends<br/>(flowgraph.c)<br/>fold consts / dead code /<br/>jump-thread / NOP strip /<br/>cold-block push / super-instrs"]
    opt --> asm["assemble.c<br/>resolve labels -> offsets<br/>build exception + location tables"]
    asm --> code["PyCodeObject<br/>(frozen bytecode)"]
    code -.->|at runtime, separate layer| spec["Specializing interpreter<br/>+ tier-2 / JIT"]

    style opt fill:#d4edda,stroke:#28a745
    style pre fill:#fff3cd,stroke:#ffc107
    style spec fill:#f8d7da,stroke:#dc3545

Diagram: the two compile-time optimization layers (yellow = AST preprocess, green = CFG optimizer) sit entirely before the code object is frozen. The runtime specializing interpreter (red) is a wholly separate stage that begins only when the frozen bytecode executes. The insight to carry away: constant folding you see in dis output is green work done once — not something the interpreter discovers at runtime.

A Short History: From Peephole to CFG

Through CPython 3.9 and earlier, the bytecode optimizer was a literal peephole optimizer: the function PyCode_Optimize in Python/peephole.c slid a small window over the already-assembled, flat byte string of bytecode and rewrote local patterns. (Verified against the v3.9.20 peephole.c, where PyCode_Optimize is defined at line 230.) Working on flat bytecode is awkward — a “peephole” cannot easily reason about whether a jump target is reachable, because control flow is implicit in jump offsets that the optimizer would have to recompute by hand after every edit.

The cutover to the CFG-based optimizer happened in CPython 3.10, not 3.11: Python/peephole.c is gone from the v3.10.x tree (it returns HTTP 404 at the v3.10.15 tag), and v3.10.15 compile.c instead defines optimize_cfg (line 7773) and optimize_basic_block (line 7341) operating on a control-flow graph; PyCode_Optimize survives there only as a do-nothing stub (line 7928) with the comment “Optimization is now done in optimize_cfg.” In the modern v3.14.5 tree this optimizer was further relocated into Python/flowgraph.c and renamed _PyCfg_OptimizeCodeUnit.

Resolved (2026-06-01)

The pre-CFG peephole optimizer was PyCode_Optimize in Python/peephole.c, present through 3.9 and replaced by the CFG optimizer in 3.10 (confirmed by the file’s absence at v3.10.15 and the optimize_cfg functions appearing in 3.10’s compile.c). Earlier framing of “3.10 and earlier” / “3.11 cutover” was off by one release.

What is verified against primary sources is the modern shape. The 3.14 InternalDocs/compiler.md (compiler.md, v3.14.5) states plainly that step 4 builds a CFG and that _PyCfg_OptimizeCodeUnit() “applies various peephole optimizations” on that graph — the word “peephole” survived, but the optimizer now operates on a real control-flow graph of basic blocks, not a flat byte string. Operating on the graph is what makes reachability analysis (dead-code elimination) and jump threading across blocks tractable, because the edges are explicit data structures the optimizer can follow and recount.

A second, smaller migration is also load-bearing for this note: constant folding itself moved out of the AST optimizer and into the CFG optimizer. In 3.14 the AST-stage astfold_expr for a BinOp node merely recurses into its operands and then calls fold_binop — and that fold_binop (ast_preprocess.c lines 368–391) only handles one case: printf-style %-formatting of a string by a tuple ("%s" % (x,)). It does not fold 2 + 3. All numeric, tuple, and unary constant folding is done later, on the CFG (fold_const_binop, fold_const_unaryop, fold_tuple_of_constants in flowgraph.c). So the common claim “constant folding happens in the AST optimizer” is false for 3.14 — it happens in the CFG optimizer.

A third piece of archaeology worth pinning: the RETURN_CONST opcode. It was added in 3.12 (“Add the RETURN_CONST instruction” — 3.12 What’s New) to fuse a LOAD_CONST immediately followed by RETURN_VALUE into one instruction. By 3.14 it no longer exists — it is absent from dis.opmap on a live 3.14.5 interpreter, and the new LOAD_SMALL_INT opcode (“Added in version 3.14”, dis docs) plus a borrowed-reference LOAD_FAST_BORROW (“Added in version 3.14”) changed how returns are spelled. A function that returns a small literal now disassembles to LOAD_SMALL_INT n / RETURN_VALUE, not RETURN_CONST. The removal landed early in the 3.14 development cycle: RETURN_CONST is still defined in Include/opcode_ids.h at the v3.14.0a1 tag (id 103, alongside INSTRUMENTED_RETURN_CONST) but is gone by v3.14.0a2 — confirmed by diffing the header across those two tags. In the released v3.14.5 opcode_ids.h there is no RETURN_CONST; RETURN_VALUE is id 35.

The Top-Level Pass Order

The whole CFG optimization is orchestrated by _PyCfg_OptimizeCodeUnit() (flowgraph.c, line 3659). It runs in this exact order:

/* preprocessing */
translate_jump_labels_to_targets(...)   /* symbolic labels -> block pointers */
mark_except_handlers(...)
label_exception_targets(...)
/* the core optimizer */
optimize_cfg(g, consts, const_cache, consts_index, firstlineno);
/* post-optimizer cleanup + lowering */
remove_unused_consts(...)                /* drop co_consts entries no longer referenced */
add_checks_for_loads_of_uninitialized_variables(...)
insert_superinstructions(g);             /* fuse LOAD_FAST/LOAD_FAST etc. */
push_cold_blocks_to_end(g);              /* basic-block reordering */
resolve_line_numbers(g, firstlineno);

Inside that, optimize_cfg() itself (flowgraph.c, lines 2552–2569) is the heart, and its order matters because passes feed each other:

RETURN_IF_ERROR(check_cfg(g));                                  /* structural sanity */
RETURN_IF_ERROR(inline_small_or_no_lineno_blocks(...));         /* merge trivial blocks */
RETURN_IF_ERROR(remove_unreachable(g->g_entryblock));           /* dead-code elimination #1 */
RETURN_IF_ERROR(resolve_line_numbers(g, firstlineno));
RETURN_IF_ERROR(optimize_load_const(...));                      /* const-load shaping */
for (basicblock *b = ...; b != NULL; b = b->b_next) {
    RETURN_IF_ERROR(optimize_basic_block(...));                 /* fold / jump-thread, per block */
}
RETURN_IF_ERROR(remove_redundant_nops_and_pairs(...));          /* NOP cleanup */
RETURN_IF_ERROR(remove_unreachable(g->g_entryblock));           /* dead-code elimination #2 */
RETURN_IF_ERROR(remove_redundant_nops_and_jumps(g));            /* final jump/NOP cleanup */

Two observations. First, remove_unreachable runs twice — once before the per-block work and once after — because folding and jump-threading create new dead code (a jump can be threaded past a block, orphaning it). Second, the per-block optimizer overwhelmingly works by rewriting instructions into NOP in place rather than deleting them immediately; the actual array compaction is deferred to the remove_redundant_nops passes. This NOP-then-sweep design keeps instruction indices stable while a pass is mid-flight.

Constant Folding on the CFG

Constant folding evaluates an expression whose operands are all known constants at compile time, so the runtime just loads the answer. The CFG optimizer does this in three functions, dispatched from the big switch in optimize_basic_block (flowgraph.c, lines 2311+).

Binary operations — fold_const_binop

When the optimizer sees a BINARY_OP instruction (line 2854 region of the switch), it calls fold_const_binop (flowgraph.c, line 1846). The mechanism, step by step:

  1. get_const_loading_instrs(bb, i-1, operands_instrs, 2) (line 1858) walks backwards from the instruction before the BINARY_OP, skipping NOPs, trying to collect exactly two constant-loading instructions. If the two things being added are not both constants, it returns false and folding is abandoned — nothing happens.
  2. get_const_value(...) materializes the two operand objects. A LOAD_CONST reads co_consts[oparg]; a LOAD_SMALL_INT reconstructs the integer with PyLong_FromLong(oparg) (line 1309).
  3. eval_const_binop(lhs, op, rhs) (line 1791) actually runs the operation — it is a switch over the numeric-protocol op codes calling the real C functions: NB_ADDPyNumber_Add, NB_MULTIPLY → a guarded multiply, NB_POWER → a guarded power, and so on. So 2 + 3 is computed by the same PyNumber_Add the interpreter would use.
  4. If evaluation raised (e.g. 1/0), the error is swallowed (PyErr_Clear(), line 1885) and folding is abandoned, unless it was a KeyboardInterrupt. The point is that a ZeroDivisionError in 1/0 must surface at runtime, not abort compilation — so the optimizer leaves the division in the bytecode to fail later.
  5. On success, nop_out(operands_instrs, 2) turns the two LOAD_* instructions into NOPs, and instr_make_load_const rewrites the BINARY_OP itself into a load of the folded constant.

Verified live on 3.14.5:

def f():
    x = 2 + 3 * 4      # source
    return x
 
  LOAD_SMALL_INT   14   # <- 2 + 3*4 folded to 14 at compile time
  STORE_FAST        0 (x)
  LOAD_FAST_BORROW  0 (x)
  RETURN_VALUE

There is no BINARY_OP left; the multiply and the add were both done by the compiler. Note also LOAD_SMALL_INT 14 rather than LOAD_CONST — because 14 fits the small-int fast path (see below).

The complexity guards — why 2 ** 1000 is not folded

Folding "a" * 1_000_000 or 2 ** 100000 at compile time would bloat the code object with a giant constant and slow compilation, defeating the purpose. So the “safe” arithmetic helpers refuse to fold results that would be too large. The constants (flowgraph.c, lines 1690–1693):

#define MAX_INT_SIZE           128  /* bits */
#define MAX_COLLECTION_SIZE    256  /* items */
#define MAX_STR_SIZE          4096  /* characters */
#define MAX_TOTAL_ITEMS       1024  /* including nested collections */

const_folding_safe_multiply (line 1695) checks, for two integers, that vbits + wbits <= MAX_INT_SIZE (128 bits) before multiplying; for int * str/int * tuple it bounds the resulting length against MAX_STR_SIZE / MAX_COLLECTION_SIZE. const_folding_safe_power (line 1740) bounds vbits * wbits similarly. If a guard trips, the helper returns NULL, folding is abandoned, and the operation stays in the bytecode. Verified live:

def f(): return 2 ** 1000     # 1001 bits > 128 -> NOT folded
  LOAD_SMALL_INT   2
  LOAD_CONST       1 (1000)
  BINARY_OP        8 (**)     # the ** survives to runtime
  RETURN_VALUE
 
def g(): return 2 ** 60       # 61 bits <= 128 -> folded
  LOAD_CONST       1 (1152921504606846976)
  RETURN_VALUE
 
def k(): return "x" * 100000  # 100000 chars > 4096 -> NOT folded
  LOAD_CONST       0 ('x')
  LOAD_CONST       1 (100000)
  BINARY_OP        5 (*)
  RETURN_VALUE

This is a genuinely useful thing to know: a constant expression in source is not guaranteed to be a constant in bytecode. 2 ** 1000 evaluates at runtime, every call.

Unary operations — fold_const_unaryop

fold_const_unaryop (flowgraph.c, line 1936) handles UNARY_NEGATIVE, UNARY_INVERT, UNARY_NOT, and the CALL_INTRINSIC_1 / INTRINSIC_UNARY_POSITIVE form of unary +. The interesting edge case is in eval_const_unaryop (line 1894): UNARY_INVERT on a bool is deliberately not folded (if (PyBool_Check(operand)) return NULL;, line 1910) because ~True is in the middle of a deprecation and the compiler must not bake in a value that may change behavior. Everything else folds, including the combination with tuple folding:

def g(): return -1, not True, ~5
  LOAD_CONST   1 ((-1, False, -6))   # all three unary ops AND the tuple folded
  RETURN_VALUE

Constant-tuple folding — fold_tuple_of_constants

This is the pass cross-linked from CPython Tuple Internals, which documents that (1, 2, 3) is stored as a single pre-built constant. The mechanism (flowgraph.c, line 1454): when optimize_basic_block sees a BUILD_TUPLE n instruction, it calls fold_tuple_of_constants, which uses get_const_loading_instrs(bb, i-1, ..., n) to check that the n instructions feeding the BUILD_TUPLE are all constant loads. If so, it builds a real PyTuple (PyTuple_New + PyTuple_SET_ITEM in the loop, lines 1475–1489), nop_outs the element loads, and rewrites the BUILD_TUPLE into instr_make_load_const of the finished tuple. Verified live:

def g(): return (1, 2, 3)
  LOAD_CONST   1 ((1, 2, 3))   # one constant, not three loads + BUILD_TUPLE
  RETURN_VALUE

There is a sibling pass, fold_constant_intrinsic_list_to_tuple (line 1509), that recognizes the BUILD_LIST 0 / repeated LOAD_CONST + LIST_APPEND / CALL_INTRINSIC_1 LIST_TO_TUPLE pattern (which is how (*a_const_list,)-style tuple construction lowers) and collapses it to one LOAD_CONST of the finished tuple. Note that list and set displays are not turned into constants the way tuples are — a [1, 2, 3] must build a fresh mutable list every time, so the most the optimizer does (in optimize_lists_and_sets, line 1598) is convert the elements into a single constant tuple that a BUILD_LIST/BUILD_SET then unpacks, not the whole list.

LOAD_SMALL_INT and the const-cache

maybe_instr_make_load_smallint (flowgraph.c, line 1408) is the small-int optimization: if a folded constant is an exact int in [0, 255], the load is emitted as LOAD_SMALL_INT val rather than LOAD_CONST, avoiding a co_consts table slot entirely. This dovetails with Small Integer and String Caching — those small ints are immortal singletons (assert(_Py_IsImmortal(newconst)), line 1418). Separately, add_const/instr_make_load_const run every new folded constant through _PyCompile_ConstCacheMergeOne and a consts_index hash table (lines 1322–1357), so that identical folded constants across the function share one co_consts entry rather than duplicating.

Dead-Code Elimination

remove_unreachable (flowgraph.c, line 996) is a straightforward graph reachability sweep. It zeroes every block’s predecessor count, then does a depth-first walk from the entry block following fall-through edges and jump/block_push targets, marking each reachable block b_visited and incrementing its predecessor count. Any block left with b_predecessors == 0 after the walk is unreachable, and its instructions are deleted by setting b_iused = 0 (lines 1034–1039). Because folding can turn a conditional into a constant-true/constant-false branch (and jump-threading can orphan a block), this runs both before and after the per-block optimizer. The if a: return 1 / return 2 example shows the two reachable arms surviving; an if False: arm would be folded away and then swept here.

Jump Threading and Jump Simplification

jump_thread (flowgraph.c, line 1264) collapses a jump-to-a-jump. If instruction inst jumps to a block whose first instruction target is itself a jump, then inst can be redirected to target’s ultimate destination, skipping the intermediate hop. The implementation turns inst into a NOP and appends a fresh jump straight to target->i_target (lines 1275–1279), with a guard (line 1271) against the degenerate inst->i_target == target->i_target case that would otherwise loop forever (bpo-45773). The dispatch in optimize_basic_block (lines 2358–2417) threads POP_JUMP_IF_FALSE/POP_JUMP_IF_TRUE/POP_JUMP_IF_NONE/POP_JUMP_IF_NOT_NONE/JUMP/JUMP_NO_INTERRUPT through trailing JUMPs, and additionally simplifies the pseudo-ops JUMP_IF_FALSE/JUMP_IF_TRUE: a JUMP_IF_FALSE whose target is a JUMP_IF_TRUE can skip to that block’s b_next (lines 2383–2389), because the two conditions are mutually exclusive. There is also remove_redundant_jumps (line 1159), which deletes a JUMP whose target is simply the next block (the fall-through makes the jump pointless).

Redundant-NOP Removal

Because nearly every pass creates NOPs (folding nops-out operand loads; jump-threading nops-out the original jump), a dedicated sweep removes the ones that are safe to remove. basicblock_remove_redundant_nops (flowgraph.c, line 1043) compacts a block’s instruction array, dropping a NOP only when removing it would not lose source-location information for tracing/pdb: a NOP is kept if it carries a line number that differs from both its neighbors (lines 1050–1089), because dropping it would make a line “disappear” from the line table. NOPs with no location, or whose line matches an adjacent instruction, are dropped. This is why dis output occasionally still shows a NOP — it is anchoring a line number, not wasting a cycle the optimizer missed. (And NOPs cost essentially nothing: the specializing interpreter quickens them away.) The remove_redundant_nops_and_pairs variant additionally cancels instruction pairs that undo each other.

Block Reordering and Super-Instructions

Two post-optimize_cfg passes (run from _PyCfg_OptimizeCodeUnit, not from optimize_cfg itself) finish the job:

  • push_cold_blocks_to_end (flowgraph.c, line 3404, invoked at line 3703) is the basic-block reordering pass. A “cold” block is one the heuristic deems not performance-critical — exception handlers and the unlikely arms of branches (b_cold/b_warm bits, line 71). Moving them to the end of the bytecode keeps the hot path contiguous, which improves the host CPU’s instruction-cache locality and branch prediction. This is the only reordering the compiler does; it is not part of the core optimize_cfg peephole loop.
  • insert_superinstructions (flowgraph.c, line 2587, invoked at line 3701) fuses adjacent common opcode pairs into single “super-instructions”: LOAD_FAST + LOAD_FASTLOAD_FAST_LOAD_FAST, STORE_FAST + LOAD_FASTSTORE_FAST_LOAD_FAST, STORE_FAST + STORE_FASTSTORE_FAST_STORE_FAST (lines 2595–2611). The pair is encoded with both operands packed into one oparg ((arg1 << 4) | arg2), so each operand must be < 16 (line 2580). This halves dispatch overhead for the most common local-variable access pattern.

Docstrings, Asserts, and the -O / -OO Flags

The optimization level is a separate, orthogonal control set by the -O/-OO command-line flags or the PYTHONOPTIMIZE environment variable, read into c->c_optimize from _Py_GetConfig()->optimization_level (compile.c, line 136). Per the command-line docs, -O sets level 1, -OO sets level 2, and PYTHONOPTIMIZE=n sets level n (so PYTHONOPTIMIZE=2 equals -OO). The level drives three behaviors, all handled outside the CFG optimizer:

__debug__ becomes a constant. In astfold_expr (ast_preprocess.c, line 622), a Load of the name __debug__ is replaced by make_const(node, PyBool_FromLong(!state->optimize))True at level 0, False at level ≥ 1. This is the one genuine folding the AST stage still does for names. Once __debug__ is a literal False, the CFG dead-code pass deletes the now-unreachable if __debug__: body. Verified live:

def f():
    if __debug__:
        return "debug"
    return "prod"
 
# no -O  -> __debug__ is True; the "prod" branch is dead and removed:
  NOP
  LOAD_CONST  1 ('debug')
  RETURN_VALUE
 
# python -O  -> __debug__ is False; the "debug" branch is dead and removed:
  NOP
  LOAD_CONST  1 ('prod')
  RETURN_VALUE

assert is dropped under -O. Assertions are not removed by deleting bytecode after the fact; they are simply never emitted. codegen_assert (codegen.c, line 2931) checks if (OPTIMIZATION_LEVEL(c)) return SUCCESS; (line 2945) — at any level ≥ 1 it emits nothing at all (after still issuing the “assertion is always true” warning for a parenthesized-tuple test). At level 0 it emits the test, a LOAD_COMMON_CONSTANT AssertionError, the optional message, and RAISE_VARARGS. This is why production code under -O must never rely on assert for validation that must always run.

Docstrings are stripped under -OO. astfold_body (ast_preprocess.c, line 465) checks if (docstring && (state->optimize >= 2)) and calls remove_docstring (line 440), which replaces the leading string-expression statement with a pass (or removes it). After this, the function’s __doc__ is None. Verified live: the same function shows DOC: 'I am a docstring' under -O but DOC: None under -OO. -OO is therefore the only level that affects behavior you can observe through introspection — it shrinks .pyc files but breaks any code (e.g. doctest, some help systems) that reads __doc__.

Common Misunderstandings

  • “The optimizer constant-folds, so 1/0 in code is caught at compile time.” No. eval_const_binop deliberately swallows the exception and abandons folding (flowgraph.c, line 1885), leaving the division to raise at runtime. Folding never changes when an error appears.
  • (1, 2, 3) and [1, 2, 3] are both made into constants.” Only the tuple. The list must be freshly built each time because it is mutable; the optimizer can only fold the elements into a constant tuple that a BUILD_LIST unpacks. See CPython List Internals vs CPython Tuple Internals.
  • “Constant folding is the specializing interpreter at work.” No — folding is a compile-time CFG pass that runs once and is already baked into the bytecode you see in dis. The specializing interpreter is a separate runtime mechanism that rewrites opcodes based on observed types; it does not fold constants.
  • “Big constant expressions like 2 ** 1000 are precomputed.” Only if they fit the complexity guards (128 bits for ints, 4096 chars for strings, 256 items for collections). Larger ones survive as runtime operations.
  • -O makes my code meaningfully faster.” It almost never does — its only real effects are dropping asserts and __debug__-guarded code (and -OO strips docstrings). The big speedups come from the Faster CPython runtime work, not from -O.

Alternatives and Comparison

  • PyPy does almost none of this at its bytecode level; its wins come from a tracing JIT that optimizes hot loops at runtime, including aggressive constant propagation and allocation removal that CPython’s static CFG optimizer cannot attempt. See CPython vs PyPy and Alternative Implementations.
  • Statically compiled languages (cf. Go) run a far heavier optimizer (SSA-based, with inlining, escape analysis, and register allocation) ahead of time because they pay for it once and ship the result. CPython’s CFG optimizer is deliberately cheap — it must run every time a .py file is compiled (unless the result is cached; see Bytecode Caching and pyc Files), so it sticks to local, fast rewrites and leaves the expensive work to the runtime tiers.

Production Notes

The practical consequence developers actually hit: never use assert for runtime validation (input checking, invariants that must hold in production), because python -O removes them silently and the official docs warn against exactly this. Use explicit if ... raise instead. Similarly, do not ship -OO if anything reads __doc__doctest-based test suites and some documentation tooling break because docstrings are gone. When debugging a “why is this opcode still here” question, remember the NOP-for-line-numbers rule: a stray NOP in dis output is usually anchoring a source line for the debugger, not a missed optimization. And when reasoning about constant expressions in hot code, check the complexity guards — a 256 * SOME_CONST_TUPLE may or may not be precomputed depending on the resulting size.

See Also