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.

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
  results

The 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_VALUE

The 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  x
def 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 index

Local 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)                      # 1024
Never pass text that came from a user to eval or exec. It executes arbitrary code with your program's permissions. The security note covers this and the safe alternatives such as ast.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 goes

What this explains

BehaviourBecause
A syntax error on the last line stops the first line runningThe whole file is compiled before anything executes
A NameError appears only when the line runsNames are resolved at execution, not compile time
Locals are faster than globalsLOAD_FAST is an index, LOAD_GLOBAL is a lookup
A generator keeps its variablesIts frame is not discarded at yield
A traceback shows nested callsEach frame links to its caller
2 + 3 costs nothing at runtimeConstant folding at compile time

Common mistakes

  • Writing code that depends on specific bytecode instructions.
  • Using eval or exec on untrusted input.
  • Believing __pycache__ makes a program faster to run, rather than faster to import.
  • Micro-optimising by reading dis output before profiling.
  • Editing a .pyc file.
  • Assuming a .pyc hides your source; it is trivially decompiled.

Best practices

  • Use dis to understand behaviour, not to optimise blindly.
  • Use ast when you need to inspect code without running it.
  • Bind hot loop lookups to locals only after profiling shows it matters.
  • Use ast.literal_eval instead of eval for data.
  • Add __pycache__ to .gitignore.

Practice

  1. Disassemble a + b * 2 and (a + b) * 2 and describe the difference in the stack operations.
  2. Show that 60 * 60 * 24 is folded at compile time but [1, 2] * 2 is not.
  3. Measure the difference between an attribute lookup in a loop and a bound local.
  4. Print the current call stack from three levels deep using inspect.
  5. Parse a small function with ast and 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Shallow and Deep Copy

Assignment shares, a shallow copy duplicates one level, and a deep copy duplicates everything. Choosing the wrong one is one of the most common source...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.