Deep Engineering
Advanced·Published·3.12 · 3.13 · 3.14·25 MIN

From source to bytecode: the six steps Python takes before the first instruction

One line of code becomes fourteen tokens, six instructions and sixteen cells. Ten of those sixteen hold no code at all — they are room for a cache that dis never shows you. Everything else grows out of that arithmetic: specialisation, the attribute cache, and the caret under the exact sub-expression in a traceback.

Full technical treatment

TL;DR

One line, def f(a): return a.x + 1, becomes 14 tokens, a tree of eight nodes, one symbol-table entry — and 6 instructions that occupy 16 code units.

Ten of those sixteen cells hold no code. They are room for the inline cache: nine slots belonging to LOAD_ATTR and one to BINARY_OP. dis does not show them.

On 3.14 the same function takes 40 bytes instead of 32 — and the difference is explained in full: BINARY_OP now has five cache slots instead of one.

Why bother?

Almost everything that happens later in the runtime rests on the code object. Until you can see that there are more instructions than lines, and more cells than instructions, there is nothing to hang a conversation about specialisation or the attribute cache on: it is an instruction that specialises, and the cache lives in exactly those cells.

There is a practical side too. This is where it becomes clear why UnboundLocalError happens on a line where the variable "obviously exists", why a traceback in 3.11+ points at the precise sub-expression, and why code that looks identical sometimes compiles to a different number of instructions.

The honest place to start is the warning that opens the dis documentation:

CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter.

—

Everything below is about CPython, and about specific versions. None of these numbers is part of the language.

The model to hold in your head

Python's compiler is a pipeline, not a function. The text passes through six stations, and at each one it stops being what it was.

  1. Source text — a string of bytes.
  2. Tokens — words with coordinates.
  3. Tree — structure instead of sequence.
  4. Symbol table — who is local here and who is global.
  5. Graph and optimisations — whatever can be dropped is dropped.
  6. Code object — the thing that runs.

Since 3.9, step 3 is done by a PEG parser rather than the old LL(1) one (PEP 617) — after that replacement the grammar stopped being a limit on the language.

The key property of the pipeline: the coordinates from step 2 survive to step 6. The line and column recorded during tokenisation end up in the code object and, years later, surface in a traceback as a caret under the right character.

Four of the six steps have a module that shows them: tokenize, ast, symtable, dis; the text is just a file. The fifth, the control-flow graph, is only observable indirectly from Python — and that is worth saying plainly rather than drawing an "approximate diagram".

The graph is the body of the function cut into stretches without branches, with arrows for the jumps between them. It is what the compiler reads to see what is unreachable and what can be folded. The graph itself is never exposed; the one crack is the ast.PyCF_OPTIMIZED_AST flag, added in 3.13, and it is discussed in the section on the two passes.

What is actually visible at each step

Tokens come from tokenize. Fourteen of them for one line, each with a pair of coordinates. Those coordinates are what PEP 657 is about.

The tree comes from ast.parse. Note the ctx=Load() on Name and Attribute: reading and writing are already different things here, long before any bytecode. Different opcodes will grow out of that difference later.

The symbol table comes from symtable — a module few people remember, though it shows the least obvious pass in the compiler. A separate walk over the tree, entirely before code generation, decides for every name whether it is local, global, or captured from an enclosing scope. For our function:

PYTHON
>>> st = symtable.symtable("def f(a): return a.x + 1", "<s>", "exec")
>>> f = st.get_children()[0]
>>> f.get_parameters()
('a',)
>>> [(s.get_name(), s.is_local(), s.is_parameter()) for s in f.get_symbols()]
[('a', True, True)]

This, not the runtime, is where LOAD_FAST instead of LOAD_GLOBAL comes from. And it is where UnboundLocalError comes from. That can be shown rather than told:

PYTHON
x = 10
 
def f():
    print(x)   # UnboundLocalError
    x = 5
PYTHON
>>> st = symtable.symtable(open("unbound.py").read(), "unbound.py", "exec")
>>> for s in st.get_children()[0].get_symbols():
...     print(s.get_name(), s.is_local(), s.is_global())
print False True
x     True  False

x is marked local at parse time, before a single line has run — and it is marked local for the whole function, print(x) above the assignment included. By runtime there is nobody left to argue with:

  File "unbound.py", line 4, in f
    print(x)
          ^
UnboundLocalError: cannot access local variable 'x' where it is not associated with a value

The same table also marks print as is_global. Hence the different opcodes — and the old advice to put a builtin in a local variable before a hot loop. The advice is checkable, so let us check it (3.13.7, minimum of seven runs of 2,000,000 calls):

ns per callopcodes
len(L) — global name14.83LOAD_GLOBAL LOAD_FAST CALL
f(L) where f = len — local13.47LOAD_FAST PUSH_NULL LOAD_FAST CALL

1.36 ns, about 10%. The difference is real and reproducible — but this is one of those cases where the right conclusion from a number is the opposite of the expected one: a trick passed around as a notable optimisation buys one and a half nanoseconds per call. Not worth spoiling readability for.

The code object: six instructions, sixteen cells

Now the central number of this article.

PYTHON
>>> def f(a): return a.x + 1
>>> len(list(dis.get_instructions(f)))
6
>>> len(f.__code__.co_code) // 2
16

Six and sixteen. The difference is neither a bug nor padding: ten cells are the inline cache, room an instruction reserves for its own notes about what it has already seen.

LOAD_ATTR reserves nine cells, BINARY_OP one. The whole table is visible without reading any C:

PYTHON
>>> import opcode
>>> {k: v for k, v in sorted(opcode._inline_cache_entries.items()) if v}
{'BINARY_OP': 1, 'BINARY_SUBSCR': 1, 'CALL': 3, 'COMPARE_OP': 1, ...
 'LOAD_ATTR': 9, 'LOAD_GLOBAL': 4, 'STORE_ATTR': 4, 'TO_BOOL': 3, ...}

The leading underscore is a warning: private, changes between versions. Which is exactly what happens — on 3.14 BINARY_OP is already 5.

Why this matters beyond arithmetic. Specialisation — the subject of the next article — works by having an instruction rewrite itself into a specialised variant and stash its observations in these very cells. Until you know the cells exist, specialisation looks like magic.

The coordinates that made it to the end

PEP 657 added four numbers per instruction to the code object: start and end line, start and end column. Visible through co_positions(), and it works like this:

PYTHON
class O: pass
 
def boom(a, b, c, d):
    return a.x + b.y * c.z - d.w
 
o = O(); o.x = 1; o.y = 2; o.z = 3
boom(o, o, o, None)

On 3.12.3:

  File "pep657.py", line 4, in boom
    return a.x + b.y * c.z - d.w
                             ^^^
AttributeError: 'NoneType' object has no attribute 'w'

The caret sits under d.w, not under the whole line — even though the line has four attribute accesses and three operations. Those are the coordinates recorded during tokenisation.

On 3.13.7 the same program also gets carets on the call line:

    boom(o, o, o, None)
    ~~~~^^^^^^^^^^^^^^^

The price is named in the PEP itself and not hidden: the standard library's pyc files grew 22%, from 28.4 to 34.7 MB. Traceback precision is paid for in disk and memory, and it is a deliberate trade.

What the optimiser does

The control-flow graph itself is not visible from Python: it lives inside compile.c, between _PyAST_Compile (line 442 at tag v3.13.7) and optimize_and_assemble (line 7689), and is never exposed. The one crack appeared in 3.13 — see the section on the two passes below.

Its consequences, though, are fully measurable. Everything below is a real co_consts and a real opcode sequence on 3.13.7:

sourcewhat is left
return 2 + 3 * 4co_consts (None, 14), opcodes RESUME RETURN_CONST
return 1 followed by return 2co_consts (None, 1) — the second return is gone
if False: return 'never' / return 'always'RESUME NOP RETURN_CONST, 'never' not among the constants
return 'a' 'b' 'c'co_consts (None, 'abc')
return (1, 2, 3)one constant tuple, not three LOAD_CONST
return not not xLOAD_FAST TO_BOOL RETURN_VALUE — one operation, not two

The NOP left where the if used to be is not junk. PEP 626 requires every executable instruction to carry a line number and requires lines that did execute not to vanish from the debugging information; the NOP keeps that bookkeeping straight.

Note what the table does not contain: not one optimisation that reorders computation or drops an attribute access. Python's compiler deliberately knows almost nothing about values — everything interesting happens later, in the interpreter.

What changed in 3.12, 3.13 and 3.14

The numbers come from one script run on one function.

3.12.33.13.73.14.7
len(co_code)323240
code units161620
sys.getsizeof(code)224232248
co_consts(None, 1)(None, 1)(1,)
opcodes in the language140150238
opcode.HAVE_ARGUMENT904443
opcodes taking an argument101107195

The trap is in the second-to-last row. HAVE_ARGUMENT fell from 90 to 44, which reads easily as "fewer instructions take an argument". The last row says the opposite: there are more of them, 101 → 107. HAVE_ARGUMENT is simply a boundary in the opcode numbering, and its move means the numbering was rearranged, not that anything got simpler. A number that means nothing on its own is a good reason to distrust one-line version comparisons.

About co_consts on 3.14. It became (1,) instead of (None, 1), and the disassembler shows LOAD_SMALL_INT where LOAD_CONST used to be. For a long time this paragraph admitted the reason had not been read. It is taken apart now — in the section on the two passes below — and it turns out to have not one cause but two at once.

About the documentation. CPython's compiler does have internal docs — InternalDocs/compiler.md, code_objects.md, parser.md. Checked by fetching both tags: on v3.14.0 all three return 200, on v3.13.7 they return 404. In 3.13 the InternalDocs/ directory holds exactly one file, string_interning.md. So an article about the 3.13 compiler has to read the code rather than a document, and any citation of InternalDocs/compiler.md for 3.13 is a citation of something that does not exist.

VersionChangeOrdering status
3.11PEP 657: co_positions(), a caret under the sub-expression in tracebacks
3.12PEP 709: comprehensions inlined, no separate code object; PEP 701: f-strings got tokens of their own
3.13Opcode numbering rearranged (HAVE_ARGUMENT 90 → 44); carets appeared on the call line too
3.14LOAD_FAST_BORROW and LOAD_SMALL_INT; BINARY_OP has five cache slots instead of one; InternalDocs about the compiler finally exist

There are two optimisers, and in 3.14 they swapped jobs

What was said above — that the fifth step is not visible from Python at all — stopped being accurate in 3.13, and the difference is worth knowing.

There are in fact two passes, and the compiler's internal documentation lists them as separate steps:

  1. Transform AST into an instruction sequence [Python/compile.c] 4. Construct a Control Flow Graph and apply optimizations to it [Python/flowgraph.c] 5. Emit bytecode based on the Control Flow Graph [Python/assemble.c]
— CPython — InternalDocs/compiler.md (tag v3.14.0)

Before step 3 there is one more pass — over the tree itself. On 3.13 it lives in Python/ast_opt.c; on 3.14 there is no file by that name at all, and its place is taken by Python/ast_preprocess.c, of which the same documentation says briefly: Preprocesses the AST before compiling. The rename is not cosmetic.

The two passes can be told apart by observation in exactly one way — the ast.PyCF_OPTIMIZED_AST flag added in 3.13. It returns the tree after the AST pass and before instruction generation (bench/source-to-bytecode/optimizer_passes.py).

3.13.7   the tree after the AST pass:
   constant arithmetic   Return(value=Constant(value=14))

3.14.7  the same tree:
   constant arithmetic   Return(value=BinOp(left=Constant(value=2), op=Add(),
                            right=BinOp(left=Constant(value=3), op=Mult(), right=Constant(value=4))))

On 3.13, 2 + 3 * 4 is folded before instructions are generated. On 3.14 the AST pass folded nothing, yet the bytecode still holds LOAD_SMALL_INT 14: the folding happened later, on the graph. The code confirms it — in v3.14.0 the fold_binop in ast_preprocess.c has a single branch left (% with a string on the left), while flowgraph.c gained fold_const_binop and fold_const_unaryop, of which v3.13.7 has not one occurrence there. The limits moved across verbatim, with the same names and values: MAX_INT_SIZE 128, MAX_COLLECTION_SIZE 256, MAX_STR_SIZE 4096, MAX_TOTAL_ITEMS 1024.

What the AST pass still does on 3.14. It did not disappear; it narrowed to transformations of the tree's shape: '%s' % (x,) still becomes a JoinedStr, a list in in still becomes a tuple. The removal of an unreachable block was the graph's work on all three versions. With not not x → TO_BOOL be more careful: on 3.12 it was not folded at all — the bytecode holds two UNARY_NOT — and TO_BOOL itself does not exist there, it arrived in 3.13.

And what this move finally explains. The version section above notes that in 3.14 co_consts became (1,) instead of (None, 1), and the cause was left unexplained. Now it is visible: on 3.14 the constant table retains values no instruction looks at.

3.14.7, orphaned constants:
   constant arithmetic   co_consts=(2,)               orphan: (2,)
   unary minus           co_consts=(5, -5)            orphan: (5,)
   string multiplication co_consts=('a', 'aaaa…')     orphan: ('a',)
   dead branch           co_consts=(False, 'always')  orphan: (False,)

3.12.3 and 3.13.7: not one

(Labels translated from the script's output; it prints nine orphan rows, four are shown.) That is exactly what to expect if the constant table is filled during instruction generation while the folding happens after it, on the graph: the folded operand is left in the table as an orphan. The other half of the explanation is LOAD_SMALL_INT, which does not need co_consts at all — hence (1,) rather than (None, 1).

The limits of folding were not moved by the change: 'a' * 4096 folds and 'a' * 4097 does not; 2 ** 64 yes, 2 ** 65 no; (1, 2) * 128 yes, (1, 2) * 129 no — identically on 3.12, 3.13 and 3.14.

What we did not establish: why the folding was moved. The fact was taken by running it and checked against the code on three tags; we did not look for the discussion where it was decided, and there is no "why" here.

What to do about it

Do not count instructions from dis output. It shows six where memory holds sixteen cells. For "how much space the bytecode itself takes" there is len(co_code), and for "how much the code object struct itself takes, without what it points at" there is sys.getsizeof.

Do not compare versions by a single number. HAVE_ARGUMENT is a live example of a value that halved without changing meaning.

Do not optimise what the compiler already folded. 2 + 3 * 4, adjacent string literals, tuples of constants, not not — each is already one constant or one operation. Hoisting them into a variable "for speed" is wasted work.

Remember that names are decided before execution. An assignment anywhere in a function body makes the name local for the entire function — a decision of the symbol table, not of the runtime. Hence UnboundLocalError on a line that comes before the assignment.

And the part that matters most. Those ten empty cells are not overhead, they are a workbench. What fills them is the specializing interpreter — the very mechanism the cells are reserved for.

What measured this

The numbers in this article come from this script. It opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Take to work

  • Do not confuse translation with calling: a file is translated into bytecode once, when it is loaded, not on every call of a function.
  • Do not read dis as what the function is made of: it prints opcodes, not what runs after adaptation.
  • Do not depend on opcode numbers: they change between versions, and comparing them across builds means nothing.
  • Read UnboundLocalError as a compiler decision rather than a matter of line order: the name became local when the function was compiled, because of an assignment anywhere in its body.

Common misconceptions

Claim

“dis shows what a function is made of.”

Actually

It shows instructions, not cells. For def f(a): return a.x + 1 it prints 6 instructions, while len(co_code) // 2 gives 16 code units: ten of them are room for the inline cache, nine belonging to LOAD_ATTR and one to BINARY_OP. That is easier to read, but you cannot work out “how much space this takes” from dis output.

Claim

“Python compiles a function on its first call.”

Actually

At module load, whole and once. def is an executable instruction that builds a function object from an ALREADY finished code object: the code itself sits in the enclosing code object's co_consts. One line checks it: [c for c in outer.__code__.co_consts if isinstance(c, types.CodeType)] returns the nested function's code object before outer has ever been called.

Claim

“Python's compiler optimises nothing.”

Actually

It optimises, but only what is visible without knowing values. Measured on 3.13.7: 2 + 3 * 4 becomes the constant 14, an unreachable return disappears from co_consts, an if False: branch is cut out (leaving a NOP for PEP 626 line numbering), 'a' 'b' 'c' is joined into 'abc', and not not x compiles to a single TO_BOOL. What it does NOT do is reorder computation or drop attribute accesses.

Claim

“HAVE_ARGUMENT fell from 90 to 44, so fewer instructions take an argument.”

Actually

The opposite: there are more of them, 101 → 107 → 195. HAVE_ARGUMENT is a boundary in the opcode numbering, not a count. Its move means the numbering was rearranged. A good example of a value you cannot compare versions by without knowing what it means.

Claim

“UnboundLocalError happens because the assignment has not been reached yet.”

Actually

Reached or not is irrelevant. The name was declared local while the symbol table was being built, before the first line ran, and it is local across the WHOLE function. Visible directly: symtable marks such a name is_local() == True for a function whose body contains an assignment — wherever that assignment stands.

Claim

“Putting len in a local variable before a loop is a notable optimisation.”

Actually

Measured on 3.13.7: 14.83 ns against 13.47 ns, a difference of 1.36 ns (about 10%). The trick works and reproduces, but it saves one and a half nanoseconds per call. Readability costs more.

Claim

“A comprehension is a hidden nested function.”

Actually

It was, until 3.12. PEP 709 inlines it: a function containing a comprehension has zero code objects in its co_consts, while an ordinary nested def gives one. The PEP claims 1.96× on a microbenchmark and 11% on pyperformance.

Check your understanding

Question 1 of 5

dis shows 6 instructions for a function. How many bytes does its co_code take on CPython 3.13?

Sources & further reading

11 SOURCES

  1. PEP 617 — New PEG parser for CPythonPEP. Final, Python 3.9. Replacing the LL(1) parser with a PEG one — the reason the grammar stopped being a limit on the language.https://peps.python.org/pep-0617/
  2. PEP 701 — Syntactic formalization of f-stringsPEP. Final, Python 3.12. f-strings stopped being handled by a separate mini-parser and got tokens of their own.https://peps.python.org/pep-0701/
  3. PEP 626 — Precise line numbers for debugging and other toolsPEP. Final, Python 3.10. Introduces co_lines() and the requirement that every executable instruction carries a line number.https://peps.python.org/pep-0626/
  4. PEP 657 — Include Fine Grained Error Locations in TracebacksPEP. Final, Python 3.11. co_positions() — four numbers per instruction: start and end line, start and end column. The price is stated outright: the standard library's pyc files grew 22%, from 28.4 to 34.7 MB.https://peps.python.org/pep-0657/
  5. PEP 709 — Inlined comprehensionsPEP. Final, Python 3.12. A comprehension stopped being a nested function. The claimed figures are 1.96× on a microbenchmark and 11% on pyperformance.https://peps.python.org/pep-0709/
  6. Python/compile.c — _PyAST_Compile and optimize_and_assembleCPython source code. Line 442 at tag v3.13.7 — the entry point of the whole compiler. Line 7689 — where the CFG is optimised and assembled into a code object. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/compile.c
  7. Include/cpython/code.h — _PyCode_DEFCPython source code. Line 74: the macro that declares the code object's fields, including co_code_adaptive — a variable-length array at the end of the struct. Line 141 is PyCodeObject itself. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Include/cpython/code.h
  8. Objects/codeobject.c — _PyCode_NewCPython source code. Line 692: the only place where a code object is actually created. CPython tag 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Objects/codeobject.c
  9. dis — Disassembler for Python bytecodeOfficial documentation. “CPython implementation detail: Bytecode is an implementation detail of the CPython interpreter” — the warning in the opening paragraph, and the honest place to start any conversation about bytecode.https://docs.python.org/3.13/library/dis.html
  10. Data model — code objectsOfficial documentation. The list of co_* fields reachable from Python, and an explicit note that some internals are not visible from the language.https://docs.python.org/3.13/reference/datamodel.html#code-objects
  11. symtable — access to the compiler's symbol tablesOfficial documentation. The only public way to watch the pass that decides local versus global — before a single opcode has been generated.https://docs.python.org/3.13/library/symtable.html