Deep Engineering
Search
Advanced·Published·3.12 · 3.13 · 3.14·20 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.

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.

Five of the six steps are observable from Python itself — there is a module that shows each. The sixth, the control-flow graph, is observable through nothing at all, and that is worth saying plainly rather than drawing an "approximate diagram".

Source text

open the file
def f(a): return a.x + 1

One line. From here on its length stops mattering: what gets counted are tokens, nodes and instructions.

CPython 3.13.7

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.

def f(a): return a.x + 1

Each cell is one code unit, two bytes: opcode and argument. The grey cells are CACHE — room for the inline cache belonging to the preceding instruction. dis does not show them.

  1. 0RESUME
  2. 2LOAD_FAST
  3. 4LOAD_ATTR
  4. 6·
  5. 8·
  6. 10·
  7. 12·
  8. 14·
  9. 16·
  10. 18·
  11. 20·
  12. 22·
  13. 24LOAD_CONST
  14. 26BINARY_OP
  15. 28·
  16. 30RETURN_VALUE
instructions in dis
6
cache slots
10
code units in total
16
len(co_code)
32 B

The nine slots after LOAD_ATTR are its inline cache: type version, descriptor, value offset. One slot after BINARY_OP. Ten cells out of sixteen hold no code at all.

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

Step five is the only one invisible from Python entirely. No module, no flag: the control-flow graph lives inside compile.c, between _PyAST_Compile (line 442 at tag v3.13.7) and optimize_and_assemble (line 7689), and is never exposed.

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'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 (bench/bytecode_bench.py) run on one function.

3.12.33.13.73.14.0rc2
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.

What is honestly unexplained. On 3.14 co_consts became (1,) instead of (None, 1), and the disassembler shows LOAD_SMALL_INT where LOAD_CONST used to be. The fact is measured; I have not read the reason in flowgraph.c, so there is no explanation here. If you meet an article that explains it confidently in two sentences, it is worth checking where that came from.

About the documentation. CPython's compiler does have internal docs — InternalDocs/compiler.md, code_objects.md, parser.md. Checked by request against 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

What to do about it

Do not count instructions from dis output. It shows six where memory holds sixteen cells. For "how much does this take" there is len(co_code), and for "how much in total" 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 for what comes next. Those ten empty cells are not overhead, they are a workbench. In the next article the interpreter starts filling them in.

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