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
- Python does not execute your text. It first turns it into a list of short commands, and executes those.
- The translation happens once, when the file is loaded — not on every call.
- One line of code becomes six commands. In memory they take up as much room as sixteen: ten cells are left empty on purpose, as scratch space for speed.
- Everything else grows from that translation: why a traceback points at the
exact spot, and why
UnboundLocalErrorhappens where the variable "obviously exists".
Nobody executes your text
There is a common picture: Python reads the file line by line and does what is written. That is not Python — that is a very simple calculator.
In reality there is a translator between your file and the work. It reads the text once, whole, takes it apart, and produces a list of short commands, each doing exactly one simple thing: "push the value of this variable", "get this attribute from this object", "add the top two values".
From then on it is the commands that run. Nobody reads the text again.
Six stations on the conveyor
The translator does not work in one go — it works in steps, and each step turns the material into something else.
- Text. Just a string of characters.
- Words. The text is cut into pieces:
def,f,(,a,). For each one, where it stood — line and column — is remembered. That will matter right at the end. - Tree. The list of words becomes a structure: here is a function, it has a name, a list of arguments and a body, and the body returns a sum.
- List of names. A separate pass decides, for every name, whether it belongs here or came from outside. Here, not at run time.
- Tidying up. Everything that can be worked out in advance is worked out in advance; everything unreachable is thrown away.
- Commands. Whatever is left is what runs.
Switch the tabs on the diagram below — it holds the real output of each step for one small function.
Tidying up is fairly generous
Some things can be computed without running the program at all. Python does compute them.
def g():
return 2 + 3 * 4There is no multiplication in the finished commands — there is the number 14 right away. The multiplication happened once, during translation.
The same fate meets:
- code after
return— it is unreachable anyway; - an
if False:branch, along with everything inside it; - adjacent quoted strings:
'a' 'b' 'c'becomes the single string'abc'.
Hence a practical conclusion: hoisting 2 + 3 * 4 into a variable "so it is not
recomputed every time" is wasted effort. It was never being recomputed.
Source text
open the filedef f(a): return a.x + 1
One line. From here on its length stops mattering: what gets counted are tokens, nodes and instructions.
Six commands, sixteen cells
Here is the number that surprises people. For one line,
def f(a): return a.x + 1you get six commands. And they take up as much room as sixteen.
Ten cells are empty. They are scratch space: while the program runs, Python watches what actually happens — what type the object is, where its attribute lives — and writes down what it saw in those cells. Next time the same work can be skipped.
def f(a): return a.x + 1Each 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.
- 0RESUME
- 2LOAD_FAST
- 4LOAD_ATTR
- 6·
- 8·
- 10·
- 12·
- 14·
- 16·
- 18·
- 20·
- 22·
- 24LOAD_CONST
- 26BINARY_OP
- 28·
- 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.
The command that asks for the most scratch space is "get an attribute" — nine cells. That is not an accident: attribute access is the most frequent operation in Python, so it is the first thing worth making faster.
Where the coordinates of words end up
Remember that at the second step, where every word stood was recorded? That is not bookkeeping for its own sake.
def boom(a, b, c, d):
return a.x + b.y * c.z - d.wIf d turns out not to be what was expected, Python shows:
return a.x + b.y * c.z - d.w
^^^
AttributeError: 'NoneType' object has no attribute 'w'
The carets sit exactly under d.w — not under the whole line, even though there
are four attribute accesses in it. The coordinates written down at the very
beginning made it to the end and surfaced at the moment of the error.
It is not free: the files of ready-made commands for the standard library grew 22% because of this. Python's developers decided a precise caret was worth it.
Why a variable can be "already local"
That step with the list of names explains a famous puzzle:
x = 10
def f():
print(x) # UnboundLocalError
x = 5It looks as if x = 5 has not been reached yet, so print(x) should pick up
the outer x. But the decision about local versus outer is made not at run
time — it is made at the fourth step of the conveyor, during translation, when
the whole function is visible at once.
If there is an assignment anywhere in the body, the name is local for the entire function, including the lines above the assignment. By run time there is nobody left to argue with.
Three things worth remembering
Translation happens once. When the file is loaded, not on every call.
Some of the work is done in advance. Arithmetic on constants, joining string literals, dropping unreachable code — all before the start.
The empty cells are not waste, they are a workbench. They are exactly where the interpreter puts what it noticed, so that the second time round it can work faster.
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.
- Source text — a string of bytes.
- Tokens — words with coordinates.
- Tree — structure instead of sequence.
- Symbol table — who is local here and who is global.
- Graph and optimisations — whatever can be dropped is dropped.
- 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 filedef f(a): return a.x + 1
One line. From here on its length stops mattering: what gets counted are tokens, nodes and instructions.
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:
>>> 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:
x = 10
def f():
print(x) # UnboundLocalError
x = 5>>> 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 Falsex 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 call | opcodes | |
|---|---|---|
len(L) — global name | 14.83 | LOAD_GLOBAL LOAD_FAST CALL |
f(L) where f = len — local | 13.47 | LOAD_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.
>>> def f(a): return a.x + 1
>>> len(list(dis.get_instructions(f)))
6
>>> len(f.__code__.co_code) // 2
16Six 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 + 1Each 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.
- 0RESUME
- 2LOAD_FAST
- 4LOAD_ATTR
- 6·
- 8·
- 10·
- 12·
- 14·
- 16·
- 18·
- 20·
- 22·
- 24LOAD_CONST
- 26BINARY_OP
- 28·
- 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:
>>> 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:
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:
| source | what is left |
|---|---|
return 2 + 3 * 4 | co_consts (None, 14), opcodes RESUME RETURN_CONST |
return 1 followed by return 2 | co_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 x | LOAD_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.3 | 3.13.7 | 3.14.0rc2 | |
|---|---|---|---|
len(co_code) | 32 | 32 | 40 |
| code units | 16 | 16 | 20 |
sys.getsizeof(code) | 224 | 232 | 248 |
co_consts | (None, 1) | (None, 1) | (1,) |
| opcodes in the language | 140 | 150 | 238 |
opcode.HAVE_ARGUMENT | 90 | 44 | 43 |
| opcodes taking an argument | 101 | 107 | 195 |
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.
| Version | Change | Ordering status |
|---|---|---|
| 3.11 | PEP 657: co_positions(), a caret under the sub-expression in tracebacks | |
| 3.12 | PEP 709: comprehensions inlined, no separate code object; PEP 701: f-strings got tokens of their own | |
| 3.13 | Opcode numbering rearranged (HAVE_ARGUMENT 90 → 44); carets appeared on the call line too | |
| 3.14 | LOAD_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
“dis shows what a function is made of.”
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.
“Python compiles a function on its first call.”
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.
“Python's compiler optimises nothing.”
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.
“HAVE_ARGUMENT fell from 90 to 44, so fewer instructions take an argument.”
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.
“UnboundLocalError happens because the assignment has not been reached yet.”
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.
“Putting len in a local variable before a loop is a notable optimisation.”
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.
“A comprehension is a hidden nested function.”
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
dis shows 6 instructions for a function. How many bytes does its co_code take on CPython 3.13?
Sources & further reading
11 SOURCES
- 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/
- 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/
- 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/
- 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/
- 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/
- 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
- 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
- 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
- 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
- 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
- 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