Skip to content

Python Execution Model Advanced

🐍 Core Python Track · Level 5
⏱️ ~4 days 📚 Prerequisite: Bytecode

How Python executes your code

A core question explored in Python Execution Model: How Python executes your code.

Source Code (.py)
    │
    ▼  [Parsing]
Abstract Syntax Tree (AST)
    │
    ▼  [Compilation]
Bytecode (code object)
    │
    ▼  [Execution]
CPython Virtual Machine (ceval.c)
    │
    ▼  [Result]
Output / Side Effects

Each step is accessible from Python:

import ast, dis, types

source = """
x = 10
y = x + 5
print(y)
"""

# 1. Parse to AST
tree = ast.parse(source)
print(ast.dump(tree, indent=2))

# 2. Compile to code object
code = compile(source, "<demo>", "exec")
print(type(code))   # <class 'code'>

# 3. Disassemble bytecode
dis.dis(code)

# 4. Execute
exec(code)   # prints: 15

Code Objects

The compiled, immutable representation of a function's body (bytecode + metadata).

Every function, module, class and comprehension has a code object:

def example(a, b):
    """Add two numbers."""
    c = a + b
    return c

co = example.__code__

print(co.co_name)        # 'example'
print(co.co_filename)    # '<stdin>' or the file path
print(co.co_firstlineno) # line number of 'def'
print(co.co_varnames)    # ('a', 'b', 'c')
print(co.co_consts)      # (None, 'Add two numbers.')  — docstring is a const
print(co.co_argcount)    # 2
print(co.co_stacksize)   # max stack depth needed
print(co.co_nlocals)     # 3
print(co.co_flags)       # bit flags (optimized, generator, coroutine, etc.)

# co_code contains the raw bytecode bytes
print(co.co_code)        # b'd\x01S\x00' (opaque bytes)

Code object attributes reference

Attribute Content
co_name Function/class name
co_varnames Local variable names (includes args)
co_cellvars Variables captured by nested functions
co_freevars Variables from enclosing scope
co_consts Literal constants (numbers, strings, None, nested code objects)
co_names Global and attribute names used
co_stacksize Maximum operand stack depth
co_flags Bitmap — CO_OPTIMIZED, CO_GENERATOR, CO_COROUTINE, etc.

Frame Objects

Each function call creates a frame holding its locals, and the current instruction. Frames are what you inspect in a debugger and traceback — and what generators suspend and resume.

Every function call creates a frame. Frames are the runtime representation of code execution:

import sys

def inner():
    frame = sys._getframe(0)   # current frame
    print(f"Function: {frame.f_code.co_name}")
    print(f"Line: {frame.f_lineno}")
    print(f"Locals: {frame.f_locals}")

    caller = frame.f_back       # caller's frame
    print(f"Caller: {caller.f_code.co_name}")
    print(f"Caller locals: {caller.f_locals}")

def outer():
    x = 42
    inner()

outer()
# Output:
# Function: inner
# Line: 5
# Locals: {'frame': <frame ...>}
# Caller: outer
# Caller locals: {'x': 42}

Frame attributes

Attribute Content
f_code Code object being executed
f_locals Local variables dict
f_globals Global variables dict
f_builtins Built-in variables dict
f_back Caller's frame (or None for top-level)
f_lineno Current line number
f_lasti Index of last bytecode instruction

The Call Stack

The stack of frames tracking active calls — what you see in a traceback.

import traceback

def c():
    traceback.print_stack()

def b():
    c()

def a():
    b()

a()
# Output:
#   File "demo.py", line 12, in <module> — a()
#   File "demo.py", line 10, in a        — b()
#   File "demo.py", line 7, in b         — c()
#   File "demo.py", line 4, in c         — traceback.print_stack()

Walking the stack programmatically:

def walk_stack():
    frame = sys._getframe(0)
    while frame:
        print(f"  {frame.f_code.co_name} @ line {frame.f_lineno}")
        frame = frame.f_back

LEGB Scope Resolution in Detail

Exactly how Python resolves a name through Local, Enclosing, Global, Built-in.

builtin_x = "B"   # would be in builtins, but for illustration

x = "Global"       # G

def outer():
    x = "Enclosing"  # E

    def inner():
        x = "Local"    # L
        print(x)       # → "Local"

    def inner2():
        print(x)       # → "Enclosing" (no local x)

    def inner3():
        # nonlocal lets you WRITE to enclosing scope
        nonlocal x
        x = "Modified by inner3"

    inner()
    inner2()
    inner3()
    print(x)          # → "Modified by inner3"

outer()
print(x)             # → "Global" (outer's x is different from global x)

How the compiler decides scope at compile time

Python determines variable scope at compile time, not runtime:

x = 10

def broken():
    print(x)    # UnboundLocalError!
    x = 20      # This assignment makes x LOCAL for the entire function

try:
    broken()
except UnboundLocalError as ex:
    print(ex)   # cannot access local variable 'x' where it is not associated with a value

The bytecode compiler sees the assignment x = 20 and marks x as local for the entire function body, even before the assignment executes.

import dis
dis.dis(broken)
# LOAD_FAST  x   ← looks in locals (not LOAD_GLOBAL!)
# ...
# STORE_FAST x

global and nonlocal

Rebind a name in an outer scope instead of creating a new local. Use global for module-level state and nonlocal for closure counters/accumulators — sparingly.

counter = 0

def increment():
    global counter    # tells compiler: counter is GLOBAL
    counter += 1

increment()
increment()
print(counter)   # 2


def make_counter():
    count = 0
    def inc():
        nonlocal count   # tells compiler: count is from ENCLOSING scope
        count += 1
        return count
    return inc

c = make_counter()
print(c())   # 1
print(c())   # 2
print(c())   # 3

Cell Objects and Closures

The storage that lets inner functions capture enclosing variables.

When a function captures variables from an enclosing scope, Python uses cells:

def outer():
    x = 10
    def inner():
        return x    # captures x
    return inner

fn = outer()

# The closure is stored as __closure__
print(fn.__closure__)              # (<cell object>,)
print(fn.__closure__[0].cell_contents)   # 10

# The code object knows which variables are free/cell
print(fn.__code__.co_freevars)     # ('x',)

The Eval Loop (simplified)

The core loop that fetches and executes bytecode instructions.

CPython's main loop in Python/ceval.c is essentially:

def eval_frame(frame):
    code = frame.f_code
    stack = []

    while True:
        opcode = next_instruction(frame)

        if opcode == LOAD_CONST:
            stack.append(code.co_consts[arg])

        elif opcode == LOAD_FAST:
            stack.append(frame.f_locals[code.co_varnames[arg]])

        elif opcode == STORE_FAST:
            frame.f_locals[code.co_varnames[arg]] = stack.pop()

        elif opcode == BINARY_ADD:
            b = stack.pop()
            a = stack.pop()
            stack.append(a + b)

        elif opcode == RETURN_VALUE:
            return stack.pop()

        elif opcode == CALL_FUNCTION:
            args = [stack.pop() for _ in range(arg)]
            func = stack.pop()
            # Create new frame, recurse
            result = eval_frame(make_frame(func, args))
            stack.append(result)

Execution of Modules

What happens top to bottom the first time a module is imported.

When Python imports or runs a module:

  1. The module source is compiled to a code object
  2. A module object is created
  3. The code object is executed with the module's __dict__ as both globals and locals
  4. The resulting namespace becomes the module's attributes
# This is approximately what 'import foo' does:
import importlib.util

spec = importlib.util.spec_from_file_location("foo", "foo.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)   # executes foo.py in module's namespace

Practice Exercises

  1. Write a decorator that prints the call stack depth before each function call.
  2. Implement a simple tracer using sys.settrace that logs every function entry/exit.
  3. Walk the frame chain and print all local variables at each level.
  4. Demonstrate the difference between LOAD_FAST, LOAD_GLOBAL, LOAD_DEREF using dis.dis.
  5. Create a function and modify its __code__ to change a constant value without redefining it.
  6. Write a context manager that temporarily patches a global variable and restores it on exit, using frame inspection.

💬 Discussion

Have a question about this topic? Found an error? Share your thoughts below.