Filter concepts by levelShowing all levels.

Python · Execution model

Execution model

What a function call actually does — frames, namespaces, closures, and the late-bound name lookup underneath all three

  • Call stack of frameseach call pushes a frame; f_back chains to the caller
  • Closures use cells__closure__ — a shared box, not a private copy
  • Late bindinga name is resolved when the function RUNS, not when defined
  • Bytecode + CPythonsource compiles to bytecode, run on a stack-based VM

1.Function calls

Calling f(args) creates a new frame, binds each argument to its parameter name inside that frame, runs the function body, and destroys the frame when it returns — handing the return value back to the caller.

Key features

  • f(args) binds each argument to its parameter name in a brand-new, per-call frame
  • The function body runs using that frame's local namespace — see local/global namespaces
  • A function with no explicit return returns None implicitly
  • The frame is destroyed once the function returns — its local names cease to exist
  • Every call gets its OWN frame — recursive calls to the same function never share one

What a call to f(a, b) does, step by step

What a call to f(a, b) does, step by step
StepWhat happens
1. Evaluate argumentseach argument expression is evaluated in the CALLER's frame
2. Create a framea new, empty frame for this specific call
3. Bind parameterseach argument value is bound to its parameter name in the new frame
4. Run the bodystatements execute using that frame's local namespace
5. Returnthe frame is discarded; the return value goes back to the caller

Command

python
def f(a, b):
    return a + b

f(2, 3)   # creates a frame, binds a=2 b=3, runs the body, returns 5, discards the frame

Every call to f(args) creates a brand-new frame, binds arguments inside it, runs the body, then discards the frame. Recursive calls never share a frame.

2.Call stack and frames

A frame holds one function call's state — its locals, current line, and a link back to the caller's frame. The call stack is the chain of frames for every active call, growing per call, shrinking per return.

Key features

  • A frame stores: locals, the current line, and a reference to the caller's frame (f_back)
  • sys._getframe() returns the executing frame — a debugging tool, rarely used directly
  • Every call pushes a new frame; every return pops one — the stack tracks call depth
  • A traceback is the call stack printed out at the moment an exception occurred
  • sys.setrecursionlimit() controls how deep the stack can grow before RecursionError

What a frame object holds

What a frame object holds
AttributeHolds
f_localsthis call's local variables, as a dict-like view
f_globalsthe module-level namespace visible from this call
f_linenothe line currently executing in this frame
f_backa reference to the CALLER's frame — None for the outermost frame
f_codethe code object this frame is executing (see bytecode)
The call stack, three calls deep

level_three()

top of the stack — currently executing

level_two()

waiting for level_three() to return

level_one()

waiting for level_two() to return

<module>

the outermost frame — where the program started

  1. level_three() — top of the stack — currently executing
  2. level_two() — waiting for level_three() to return
  3. level_one() — waiting for level_two() to return
  4. <module> — the outermost frame — where the program started

Command

python
import sys
frame = sys._getframe()   # the current frame
frame.f_back                # the caller's frame — walk this chain to see the whole stack
frame.f_locals               # this frame's local variables

A frame holds one call's locals, line, and a link (f_back) to its caller. The stack grows per call, shrinks per return — a traceback is that chain.

3.Local and global namespaces

A namespace is a mapping from names to objects. globals() returns the REAL, live module namespace — editing it changes real globals. locals() returns a SNAPSHOT inside a function — editing it usually has no effect.

Key features

  • globals() returns the ACTUAL module namespace — a real, live dict you can safely modify
  • locals(), inside a function, returns a snapshot — modifying it rarely affects real locals
  • At module level (outside any function), locals() and globals() return the SAME dict
  • vars(obj) returns an object's own namespace — usually its __dict__
  • CPython optimizes locals via array slots, not a dict — locals() builds a view on demand

globals() vs locals() inside a function

globals() vs locals() inside a function
Propertyglobals()locals() (inside a function)
What it returnsthe real module namespacea snapshot dict, rebuilt each call
Editing itchanges real global variablesnot reliably reflected back
Where it appliesmodule-level names, everywherethe CURRENT function call's local names
At module level (no function)same dict as locals()same dict as globals()

Command

python
globals()   # the real, live module namespace — a dict you can safely mutate
locals()     # inside a function: a snapshot dict; at module level: same as globals()
vars(obj)    # an object's own namespace, usually obj.__dict__

globals() is the real, live module namespace — editing it works. locals() inside a function is a snapshot — editing it rarely changes real locals.

4.Closures (the cell mechanism)

CPython implements a closure using cell objects — small boxes holding one shared value. When a nested function references an enclosing variable, both functions share the SAME cell, so a change made through one is visible through the other.

Key features

  • func.__closure__ is a tuple of cell objects — one per free variable the function captures
  • cell.cell_contents is the value stored in that cell — readable and, carefully, writable
  • func.__code__.co_freevars lists the captured NAMES, in the same order as __closure__
  • Two functions from the same enclosing call, referencing the same variable, share one cell
  • Two SEPARATE calls to the enclosing function create two SEPARATE cells, sharing no state

Inspecting a closure from the outside

Inspecting a closure from the outside
ExpressionShows
func.__code__.co_freevarsthe names of variables this function captures, e.g. ('n',)
func.__closure__a tuple of cell objects, one per captured name, same order
func.__closure__[0].cell_contentsthe CURRENT value stored in that cell
two closures' __closure__[0] is the same cell?True only if both came from the SAME enclosing call

Command

python
func.__closure__               # tuple of cell objects, or None if func captures nothing
func.__closure__[0].cell_contents   # the value inside the first captured variable's cell
func.__code__.co_freevars           # names of the captured variables, matching __closure__'s order

A closure uses cell objects — func.__closure__ holds one per captured variable. Same enclosing call shares a cell; separate calls each get their own.

5.Free variables

A free variable is a name a function uses but does not define itself — it comes from an enclosing scope. Python decides which names are free at COMPILE time, listing them in co_freevars before the function ever runs.

Key features

  • A name is free if read but never assigned in the function, and found in an enclosing scope
  • func.__code__.co_freevars lists a function's free variable names, computed at compile time
  • A name assigned anywhere in the function is LOCAL for the whole body — never free
  • A captured name becomes a cellvar in the OUTER function's co_cellvars too
  • Global and builtin names are never free — only an enclosing FUNCTION scope qualifies

Where a name ends up, compile-time classification

Where a name ends up, compile-time classification
Name is...Classified as
assigned anywhere in this functionlocal — even if read before the assignment (see late binding's cousin bug)
read only, and exists in an enclosing FUNCTIONfree — appears in co_freevars
read only, and exists at module levelglobal — not a free variable
read only, and not found anywherelooked up in builtins at runtime, or NameError

Command

python
def outer():
    x = 1
    def inner():
        return x   # x is free in inner — read but not assigned here, found in outer
    return inner

inner_func = outer()
inner_func.__code__.co_freevars   # ('x',)

A free variable is read (never assigned) in a function, found in an enclosing scope, classified at compile time. Any assignment makes it local instead.

6.Late binding

Python looks up a name inside a function body at CALL time, not definition time. A function defined once can behave differently each call if a name it references has changed by the time it runs.

Key features

  • A function body's names are resolved when the function RUNS, never when defined
  • A global function or variable used inside a function can be reassigned before it runs
  • A default argument value is the ONE exception — evaluated at definition time
  • Classic symptom: closures built in a loop all resolve the loop variable to its FINAL value
  • Late binding is not a bug — it is how name resolution always works in Python

Late-bound vs the one early-bound exception

Late-bound vs the one early-bound exception
WhatWhen resolved
A name referenced inside a function bodyat CALL time — every single call, freshly
A global function called by name from inside another functionat call time — whichever function that name currently points to
A closure variable (free variable)at call time — its cell's CURRENT value
A default argument value (def f(x=expr))at DEFINITION time — evaluated once, the exception to the rule
Late-bound reference vs early-bound default

Late binding — the norm

  • Name inside a function body
  • Looked up fresh, every call
  • Sees whatever the name currently refers to

Default argument — the exception

  • A def statement's default value
  • Evaluated ONCE, at definition time
  • Never re-evaluated on later calls
  • Late binding — the norm
    • Name inside a function body
    • Looked up fresh, every call
    • Sees whatever the name currently refers to
  • Default argument — the exception
    • A def statement's default value
    • Evaluated ONCE, at definition time
    • Never re-evaluated on later calls

Command

python
def outer():
    return helper()   # 'helper' is looked up when outer() is CALLED

def helper():
    return "v1"

outer()   # "v1"
def helper():   # redefining the global name
    return "v2"
outer()   # "v2" — outer() sees whichever helper the name currently points to

A function body's names are resolved when it RUNS, not when defined — every call looks them up fresh. Default argument values are the one exception.

7.Bytecode and CPython (conceptual understanding)

Python source compiles into bytecode — a lower-level instruction set — before it runs. CPython, the reference implementation most people mean by 'Python', executes it on a stack-based virtual machine. Bytecode is not stable across versions.

Key features

  • Python source compiles to bytecode before execution — the dis module can show it
  • CPython is the REFERENCE implementation, written in C — 'Python' usually means CPython
  • Bytecode runs on a stack-based VM — instructions push and pop an operand stack
  • Bytecode is cached in .pyc files so re-running an unchanged file skips recompilation
  • Bytecode is NOT guaranteed stable between releases — code depending on opcodes can break

Source code, compiled, executed — the pipeline

Source code, compiled, executed — the pipeline
StageWhat it produces
Source code (.py)human-readable Python
Compilationbytecode — an instruction list for a virtual machine
Cachingbytecode saved to a .pyc file, reused if the source is unchanged
ExecutionCPython's virtual machine runs the bytecode, instruction by instruction
From source to running program

Source code

the .py file you write

Compile

translated into bytecode — an instruction list

Cache

saved to a .pyc file for reuse

Execute

CPython's stack-based virtual machine runs the bytecode

  1. Source code — the .py file you write
  2. Compile — translated into bytecode — an instruction list
  3. Cache — saved to a .pyc file for reuse
  4. Execute — CPython's stack-based virtual machine runs the bytecode

Command

python
import dis
dis.dis(some_function)     # shows the bytecode instructions, human-readable
some_function.__code__      # the compiled code object itself

Source compiles to bytecode, run by CPython's stack-based VM. CPython is the reference implementation — this detail is a CPython fact, not a language guarantee.

Remember

  • Every call to f(args) creates a brand-new frame — recursive calls to the same function never share one.
  • globals() is the real, live module namespace; locals() inside a function is a snapshot — editing it usually has no effect.
  • Functions from the SAME enclosing call share one cell for a captured variable; functions from separate calls each get their own.
  • A name inside a function body is looked up when the function RUNS, not when it is defined — every call resolves it fresh.

Pro tip

  • A closure over a loop variable needs a default argument (lambda x=x: ...) to freeze that iteration's value — late binding otherwise gives every closure the final one.
  • nonlocal is required to REASSIGN a captured variable inside a closure — reading it needs no declaration.
  • Treat bytecode as a debugging aid, not a stable contract — opcode names and shapes are not guaranteed across Python versions.
  • CPython is the reference implementation, not the language spec — refcounting timing, the GIL, and this bytecode format are CPython details.

Next up

  • Functional Programming

Sources

  • Execution model — docs.python.org/3/reference/executionmodel.html
  • Frame objects — docs.python.org/3/reference/datamodel.html#frame-objects
  • bytecode — docs.python.org/3/glossary.html#term-bytecode
  • Every example executed on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.