Bytecode and the Execution Model
Compilation, the code object, the stack machine and the frame. A closer look at what the interpreter is actually doing while your program runs.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
From source to running program
source text
| tokenise characters -> tokens
v
tokens
| parse tokens -> abstract syntax tree
v
AST
| compile AST -> code object containing bytecode
v
code object
| evaluate the interpreter loop executes it inside a frame
v
resultsThe basics note introduced this pipeline. This note looks at the last two stages, because they explain several behaviours that otherwise look arbitrary.
The abstract syntax tree
import ast
tree = ast.parse("total = price * 2 + tax")
print(ast.dump(tree, indent=2)[:400])import ast
source = """
def add(a, b):
return a + b
"""
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
print("function:", node.name, "args:", [a.arg for a in node.args.args])
if isinstance(node, ast.BinOp):
print("operator:", type(node.op).__name__)The AST is how linters, formatters and type checkers read code without running it. It is available to you through a standard library module.
The code object
def net_price(amount, rate=0.18):
tax = amount * rate
return amount + tax
code = net_price.__code__
print(code.co_name) # net_price
print(code.co_varnames) # ('amount', 'rate', 'tax')
print(code.co_argcount) # 2
print(code.co_consts) # the constants used
print(code.co_names) # global names referenced
print(len(code.co_code), "bytes of bytecode")A function object is a thin wrapper: the code object holds the instructions, and the function holds the defaults, the closure and the name bindings. That separation is why the same code object can back several closures.
Reading bytecode
import dis
def total(a, b):
return a + b * 2
dis.dis(total) LOAD_FAST a
LOAD_FAST b
LOAD_CONST 2
BINARY_OP *
BINARY_OP +
RETURN_VALUEThe virtual machine is a stack machine. Each instruction pushes values onto a stack or pops them off. LOAD_FAST a pushes the local a; BINARY_OP * pops two values, multiplies them and pushes the result.
step stack
LOAD_FAST a [a]
LOAD_FAST b [a, b]
LOAD_CONST 2 [a, b, 2]
BINARY_OP * [a, b*2]
BINARY_OP + [a + b*2]
RETURN_VALUE []The exact instruction names change between Python versions. Bytecode is an internal detail with no compatibility guarantee, which is why .pyc files are version tagged. Read it to understand behaviour, never to depend on it.What the compiler already decides
import dis
dis.dis("x = 2 + 3") # constant folding: the result is precomputed LOAD_CONST 5 <- not 2, 3, BINARY_OP +
STORE_NAME xdef constants():
a = 60 * 60 * 24 # folded at compile time
b = "ab" * 3 # folded
c = [1, 2, 3] # built at runtime; a list is mutable
return a, b, c
print(constants.__code__.co_consts)Local variables are faster than globals
import dis
GLOBAL_VALUE = 1
def uses_global():
return GLOBAL_VALUE
def uses_local():
local_value = 1
return local_value
dis.dis(uses_global) # LOAD_GLOBAL - a dictionary lookup
print("---")
dis.dis(uses_local) # LOAD_FAST - an array indexLocal names are compiled to numbered slots, so loading one is an array index. A global requires a dictionary lookup in the module, then possibly in builtins. This is the mechanism behind the classic advice to bind a frequently used global or method to a local name inside a hot loop.
import time
data = list(range(1_000_000))
start = time.perf_counter()
result = []
for value in data:
result.append(value * 2) # attribute lookup each time
print(f"attribute lookup: {time.perf_counter() - start:.4f}s")
start = time.perf_counter()
result = []
append = result.append # bound once
for value in data:
append(value * 2)
print(f"bound local: {time.perf_counter() - start:.4f}s")Frames
import inspect
def inner():
frame = inspect.currentframe()
print("function:", frame.f_code.co_name)
print("locals: ", frame.f_locals)
print("caller: ", frame.f_back.f_code.co_name)
def outer():
x = 42
inner()
outer()Every call creates a frame: a small object holding the local variables, the value stack, the current instruction position and a link to the calling frame. The chain of frames is the call stack, and it is exactly what a traceback prints.
import traceback
def a():
b()
def b():
c()
def c():
for line in traceback.format_stack():
print(line.strip().splitlines()[0])
a()A generator keeps its frame alive between yield statements. That is precisely why local variables survive the pause - the frame was never discarded.
Compiling and executing at runtime
code = compile("result = sum(range(10))", "<generated>", "exec")
print(type(code))
namespace = {}
exec(code, namespace)
print(namespace["result"]) # 45
value = eval("2 ** 10")
print(value) # 1024Never pass text that came from a user toevalorexec. It executes arbitrary code with your program's permissions. The security note covers this and the safe alternatives such asast.literal_eval.
import ast
print(ast.literal_eval("[1, 2, {'a': 3}]")) # safe: literals only
# ast.literal_eval("__import__('os').system('ls')") # ValueError__pycache__ revisited
import py_compile
import sys
print(sys.implementation.cache_tag) # cpython-312
# py_compile.compile("helpers.py") # writes __pycache__/helpers.cpython-312.pyc- Only imported modules are cached; the script run directly is not.
- The cache is invalidated by the source timestamp and size.
- The version tag means two Python versions never share a cache.
- Caching skips parsing and compiling, not execution. Import still runs the module.
python -X importtime -c "import json" # shows where import time goesWhat this explains
| Behaviour | Because |
|---|---|
| A syntax error on the last line stops the first line running | The whole file is compiled before anything executes |
A NameError appears only when the line runs | Names are resolved at execution, not compile time |
| Locals are faster than globals | LOAD_FAST is an index, LOAD_GLOBAL is a lookup |
| A generator keeps its variables | Its frame is not discarded at yield |
| A traceback shows nested calls | Each frame links to its caller |
2 + 3 costs nothing at runtime | Constant folding at compile time |
Common mistakes
- Writing code that depends on specific bytecode instructions.
- Using
evalorexecon untrusted input. - Believing
__pycache__makes a program faster to run, rather than faster to import. - Micro-optimising by reading
disoutput before profiling. - Editing a
.pycfile. - Assuming a
.pychides your source; it is trivially decompiled.
Best practices
- Use
disto understand behaviour, not to optimise blindly. - Use
astwhen you need to inspect code without running it. - Bind hot loop lookups to locals only after profiling shows it matters.
- Use
ast.literal_evalinstead ofevalfor data. - Add
__pycache__to.gitignore.
Practice
- Disassemble
a + b * 2and(a + b) * 2and describe the difference in the stack operations. - Show that
60 * 60 * 24is folded at compile time but[1, 2] * 2is not. - Measure the difference between an attribute lookup in a loop and a bound local.
- Print the current call stack from three levels deep using
inspect. - Parse a small function with
astand list every name it references.
Conclusion
Python compiles to bytecode for a stack machine, and executes it inside frames that chain into the call stack. That model explains compile time versus runtime errors, why locals are fast, how generators keep their state, and what a traceback is actually showing you.