skip to content

Which expressions does CPython's compiler fold into co_consts before the code runs?

level: seniorimportance: should knowfreq 22%

answer

  1. The compiler precomputes what it provably can
  2. Only literals of immutable types qualify
  3. A name in the expression stops it dead
  4. Big results are deliberately left alone
  5. Check by printing the constants tuple

basics

~20 s

Only expressions built entirely from literals of immutable types, and only up to a size cap: 60 * 60 * 24 becomes the constant 86400. Anything touching a name, an attribute or a call is left to run time, so hoisting those yourself is what actually pays.

solid answer

~50 s

CPython's compiler folds constant expressions while building the code object and stores the results in `co_consts`. It folds arithmetic and comparisons on numeric and string literals, unary minus, tuples of constants, and a set literal on the right of `in` (which becomes a `frozenset` constant). It refuses anything that could have a run-time meaning — a name, an attribute lookup, a call, a list or dict display — and it refuses results above a size cap, so a string repetition producing more than 4096 characters is left unfolded to keep the compiled output small. The practical consequence for a hot loop is the inverse of what people assume: writing `60 * 60 * 24` costs nothing, while `config.timeout * 60` costs a lookup on every iteration. Folding is a CPython implementation detail, not a language guarantee, and `compile(src, '<s>', 'exec').co_consts` settles any argument about it.

code

python · 3 lines
python
print(compile("t = 60 * 60 * 24", "<s>", "exec").co_consts)
print(compile("t = seconds * 60", "<s>", "exec").co_consts)
print(compile("s = 'x' * 4097", "<s>", "exec").co_consts)

go deeper

for a junior

Know that 60 * 60 * 24 is computed once by the compiler, not on every loop iteration, and that this only works because both operands are literals.

for a middle

Explain the boundary: literals of immutable types fold, while names, attributes, calls and mutable displays do not, and the results land in the code object's co_consts.

for a senior

Apply it under profiling — recognise that the folded arithmetic in a hot loop is already free and that the real cost is repeated name and attribute lookups, then verify with co_consts rather than from memory.

for a principal

Set the expectation that optimisations resting on an implementation detail are measured, commented and confined to a proven hot path, never spread through a codebase as folklore.

## What folding is Constant folding is an optimisation the compiler performs while turning the syntax tree into bytecode: an expression whose value is fully determined at compile time is evaluated *there*, and only the result is stored, in the code object's `co_consts` tuple. Nothing is computed at run time; the code simply loads the constant. ```python print(compile("t = 60 * 60 * 24", "<s>", "exec").co_consts) # (60, None, 86400) print(compile("t = seconds * 60", "<s>", "exec").co_consts) # (60, None) ``` In the first case the whole expression collapsed to `86400`. In the second, `seconds` is a name whose value the compiler cannot know, so nothing is folded and the multiplication happens on every execution. ## What gets folded * Arithmetic and bitwise operations on numeric literals: `60 * 60 * 24`, `1 << 20`. * Unary operators: `-5` is stored as the constant `-5`. * String and bytes operations on literals: concatenation, and repetition within the size cap. Adjacent string literals (`"a" "b"`) are joined even earlier, by the parser. * Comparisons and membership tests over literals. * Tuple displays of constants — `(1, 2, 3)` is one constant, not three loads and a build. * A set literal on the right of `in`: `x in {1, 2, 3}` stores a `frozenset` constant, which is both faster and a nice demonstration that the compiler will change the *type* when it is provably safe. ## What does not get folded * Anything mentioning a **name**: `seconds * 60`, `SCALE * 2`. The compiler has no idea what the name will be bound to. * Anything reaching through an **attribute**: a module constant accessed as `mod.value` is a run-time lookup, so the surrounding arithmetic stays at run time too. * Any **call**, even of a builtin — a builtin can be shadowed before the code runs. * **Mutable displays**: a list or dict literal must produce a fresh object per execution, so it can never be a shared constant. The compiler may still pre-build the *elements* as a constant tuple and construct the list from it. * Results that exceed a **size cap**. A string repetition whose result is longer than 4096 characters, or a very large integer power, is left alone so that compiled output does not balloon. ```python folded = compile("s = 'x' * 4096", "<s>", "exec").co_consts print(len(folded[-1])) # 4096 - folded print(compile("s = 'x' * 4097", "<s>", "exec").co_consts) # ('x', 4097, None) - not folded ``` ## The senior point: optimise the right half Take a route-optimisation batch job whose inner loop runs tens of millions of times and whose regression run has crept out to a 27-minute suite. The instinct is to hand-hoist arithmetic — replacing `60 * 60 * 24` with a precomputed `SECONDS_PER_DAY` at the top of the module. That change makes the code *slower*: the literal expression was already folded into a single constant load, while the module-level name is a global lookup on every iteration, recorded in `co_names` rather than baked into `co_consts`. The version that pays is the opposite move — bind the things the compiler *cannot* fold into locals just before the loop: a method you call repeatedly, a module attribute, a configuration value. That converts repeated name and attribute lookups into slot reads, which is a real saving precisely because folding never touches them. ## A neighbouring compile-time transformation Folding is not the only thing settled before the code runs. `compile()` takes an `optimize` argument, and the interpreter's `-O` switch sets it for the whole process: at level 1 the compiler removes `assert` statements outright and folds the builtin `__debug__` to the constant `False`; at level 2 it also drops docstrings out of `co_consts`. At the default level `__debug__` folds to `True`. It is worth knowing alongside folding for the same reason — it is a compile-time rewrite you can see in `co_consts` — and because it explains why code that leans on `assert` for real validation silently stops validating when someone runs it optimised. ## Treat it as an implementation detail Folding is not part of the language definition. CPython has moved the work around across versions — it used to live in a peephole pass over bytecode and now happens earlier, on the syntax tree — the caps have been tuned, and another implementation may fold more or less. So do not write code whose *correctness* depends on it, and do not argue about it from memory: compile the snippet and print `co_consts`. That habit also answers the adjacent question of whether an expensive-looking literal in a hot path is worth extracting, in about ten seconds, with evidence.

  • Why is a list literal never stored as a constant?
    A list is mutable, so every execution must produce a fresh object — sharing one would let a caller mutate a value baked into the code. The compiler may still fold the *elements* into a constant tuple and build the list from that, which is why `co_consts` can contain a tuple for a line that only ever writes a list literal.
  • Does replacing a folded literal with a module-level constant make a hot loop faster?
    No, it usually makes it slower. The literal expression was already collapsed into a single constant load; a module-level name is a global lookup through the module dict and then builtins on every iteration. If you want the readability of a name and the speed of a constant, bind it to a local just before the loop.
  • How do you settle an argument about whether an expression is folded?
    Compile it and look: `compile(src, '<s>', 'exec').co_consts` shows exactly what the compiler baked in on the interpreter you are running. That is worth more than any remembered rule, because the caps and the pass that does the work have changed between CPython releases.

saying these in an interview costs you the question

  • Claims the compiler folds expressions containing names
  • Thinks a list literal can become a constant
  • Assumes folding is guaranteed by the language
  • Expects builtin calls to be evaluated at compile time
  • Hoists folded literals into globals as an optimisation
  • Unaware any size cap on folded results exists

context