A code object describes executable work. A function packages that code with globals, defaults, annotations, and possibly closure cells. A frame is one active execution: it joins code to current arguments, local state, global and builtin namespaces, an instruction position, and tracing state.

Conflating those layers causes practical mistakes. Developers expect editing locals() to rewrite optimized function variables, assume a caller's locals participate in lookup, or treat exec() as though it creates an ordinary nested function scope. Frame introspection then appears inconsistent when it is obeying rules designed for efficient execution and debuggability.

This article follows names from compilation through a live frame. Closures are covered by the next series article; here the focus is the local, global, and builtin portions of LEGB and the engineering boundaries around inspection.

Version boundary. Name binding and resolution are Python language behavior. Frame attributes, locals() behavior, tracing APIs, and exec() are documented Python interfaces with context-dependent rules. CPython 3.14's fast-local storage, frame layout, opcode names, and synchronization mechanics are implementation details. Experiments were run on CPython 3.14.7.

Experiment 1: one function, many frames

Recursion makes simultaneous frames from one code object visible.

import sys


def descend(level):
    frame = sys._getframe()
    print(level, frame.f_code is descend.__code__, frame.f_locals["level"])
    if level:
        return descend(level - 1)
    return frame


bottom = descend(2)
print(bottom.f_code.co_name)
print(bottom.f_back.f_code.co_name)

Every invocation uses the same descend.__code__, yet each has its own level and execution state. The returned bottom frame's f_back points at its caller, demonstrating a chain of active calls at capture time.

sys._getframe() is CPython-originated but documented and available on several implementations; an implementation may omit it. Retaining bottom retains references reachable from that frame, including locals and caller frames. Diagnostic systems should extract the small facts they need and release frame and traceback objects promptly.

Python model. Each call executes code with a new local namespace and execution state. CPython detail. Modern CPython can keep interpreter frames in optimized internal storage and materialize a Python frame object when introspection requires one.

Experiment 2: assignment makes a name local

Scope classification happens while compiling the block, not as execution encounters lines.

message = "global"


def broken(flag):
    if flag:
        message = "local"
    return message


print(broken(True))
try:
    broken(False)
except UnboundLocalError as error:
    print(type(error).__name__)
print(broken.__code__.co_varnames)

Because any ordinary assignment to message appears in the function, message is local throughout that block. The false path reaches a local read before a value was assigned. Python does not fall back to the global value when an uninitialized local exists.

This rule makes name access predictable without runtime searches through arbitrary scopes. It also explains why adding an assignment during a refactor can change an earlier read. global message would classify accesses as module-global; it should be used only when process-level mutable state is genuinely intended. More often, pass state in and return a value.

Experiment 3: callers do not provide enclosing scope

Python uses lexical, not dynamic, scoping.

Pyodide / WebAssembly
label = "module"


def read_label():
    return label


def caller():
    label = "caller"
    return read_label(), locals()["label"]


print("[result] lexical and caller-local labels:", caller())
print("[state] label in defining globals:", read_label.__globals__["label"])

The result is ('module', 'caller'), then module. read_label was defined at module scope, and its function object holds that module's globals mapping. Calling it from a frame whose local namespace has label does not insert the caller into the lookup path.

This matters for callbacks and dependency injection. A framework cannot influence an imported function by arranging similarly named caller locals. Pass an argument, replace the name in the defining module for a controlled test, or construct a callable carrying explicit state.

Experiment 4: builtins are the final namespace

Global lookup falls through to the frame's builtin namespace.

import builtins
import sys


def inspect_lookup(items):
    frame = sys._getframe()
    return (
        "len" in frame.f_locals,
        "len" in frame.f_globals,
        frame.f_builtins["len"] is builtins.len,
        len(items),
    )


print(inspect_lookup([1, 2, 3]))

len is neither local nor global here, so builtin lookup supplies it. A module can shadow it with len = something, and a function can shadow it with a parameter or assignment. Shadowing is ordinary name binding, not mutation of the builtins module.

The builtins namespace used for execution derives from the globals namespace's __builtins__ entry. That entry is commonly a module in imported modules and may be a dictionary in dynamic execution. Application code should import builtins when it needs the actual module rather than depending on the shape of __builtins__.

Replacing builtins is not a security sandbox. Reachable Python objects can expose powerful capabilities, and resource exhaustion remains possible. Run untrusted code in a separately secured process or service with operating-system limits.

Experiment 5: locals() is context-sensitive

At module scope, locals and globals are the same mapping. In an optimized function, locals() returns a current mapping of bindings but writing it does not reliably assign fast locals.

Pyodide / WebAssembly
def attempt_edit():
    value = 10
    snapshot = locals()
    snapshot["value"] = 99
    snapshot["added"] = 42
    return value, snapshot["value"], "added" in locals()


print("[result] local value, edited snapshot, and added-name check:", attempt_edit())
print("[check] module locals are globals:", locals() is globals())

On Python 3.14 the function returns (10, 99, True): the mapping remembers edits, but the optimized local value remains 10. PEP 667 defined this behavior more precisely in Python 3.13. Each call to locals() in an optimized scope returns an independent snapshot updated from current fast locals; mutations do not write back to variables. Trace hooks receive a write-through proxy through frame.f_locals for optimized frames.

The version history matters. Code written around accidental synchronization behavior in old CPython releases was fragile. Use locals() for formatting, diagnostics, and deliberate namespace capture, never as an assignment API. Assign the name in source or redesign the interface.

Experiment 6: globals really are a writable mapping

Module code uses its namespace mapping directly.

Pyodide / WebAssembly
rate = 5


def total(hours):
    return rate * hours


namespace = total.__globals__
before = namespace["rate"]
namespace["rate"] = 8
try:
    print("[result] total after temporary global rate change:", total(3))
finally:
    namespace["rate"] = before

The call prints 24. Unlike optimized local snapshots, function global lookup reads the mapping held by function.__globals__. Test patching works for this reason, but the patch must target the module where the consumer looks up the name, and restoration must be guaranteed.

Direct global mutation creates process-wide coupling. Prefer unittest.mock.patch.object, pytest's monkeypatch, or an explicit dependency parameter. Concurrent code can observe a temporary patch, so subprocess isolation may be necessary when global state is part of the behavior under test.

Experiment 7: exec() with one namespace

Supplying one dictionary makes it both global and local namespace for module-style code.

Pyodide / WebAssembly
code = compile("base = 40\nresult = base + len([1, 2])", "dynamic.py", "exec")
namespace = {}
exec(code, namespace)

print("[result] dynamic base and result:", namespace["base"], namespace["result"])
print("[check] exec inserted __builtins__:", "__builtins__" in namespace)
print("[state] inserted __builtins__ type:", type(namespace["__builtins__"]).__name__)

This prints 40 42; exec inserts __builtins__ when absent. The compiled code object contains names but no binding values. Execution supplies those through the namespace and creates a frame for that run.

Use one explicit namespace when trusted generated code should behave like module code. Avoid passing your application's full globals unless the code genuinely needs every capability there. A small namespace improves reviewability but, again, does not establish security against hostile code.

Experiment 8: two namespaces behave like a class body

Separate globals and locals are not an ordinary function closure.

source = """
factor = 6
def scale(value):
    return value * factor
"""

global_space = {"factor": 7}
local_space = {}
exec(source, global_space, local_space)

print(local_space["factor"])
print(local_space["scale"](3))
print(local_space["scale"].__globals__ is global_space)

The assignment writes 6 into local_space, but the created function uses global_space as its globals and therefore returns 21. Executing with distinct mappings follows class-definition-like rules: top-level assignments go to locals, while functions do not close over that mapping as an enclosing function namespace.

This is a recurring configuration-engine bug. If definitions need to share dynamically created module globals, use one namespace. If you intend a class body, use normal class construction or understand the separate namespace semantics explicitly.

Frames are powerful and expensive evidence

inspect.currentframe(), traceback objects, profilers, debuggers, coverage tools, and exception handlers can expose frames. A frame offers f_code, f_globals, f_builtins, f_locals, f_back, and a current line. That makes it tempting to build application protocols by walking callers.

Resist that temptation. Stack inspection couples behavior to wrappers, decorators, optimizations, and test runners. It can leak secrets from locals, extend object lifetimes, and add substantial overhead. Context should travel through parameters, object state, or contextvars, not through guessing which caller frame owns a name.

Tracing also changes the system being observed. Events occur per frame and can request per-line or per-opcode callbacks. Keep callbacks small, filter early by code object, restore tracing in finally, and never assume timings under tracing represent production.

Comprehensions and class bodies complicate the picture

Not every source block maps to the same namespace rules. A class statement executes a body with a prepared namespace, then passes that mapping to the metaclass to construct the class. Methods created inside it do not use the class namespace as an enclosing lexical scope. An unqualified class attribute name therefore is not automatically visible inside a method; access it through self, cls, or the class name.

Comprehensions have their own implicit execution scope in modern Python, which is why their iteration variable does not leak into the surrounding function or module. CPython's exact lowering has changed: some releases used a nested code object in cases that newer optimization work can inline while preserving isolation. Scope behavior is the contract; whether a separate visible frame appears is version-specific implementation evidence.

These cases are why the slogan "LEGB searches four dictionaries" is incomplete. Compilation determines the kind of block and classifies each name. Some locals use optimized slots, class bodies use a mapping, comprehensions isolate iteration names, and closures use cells. Lookup does not blindly iterate four arbitrary mappings for every expression.

Debugging without changing execution

A production exception formatter often wants local context. Apply a strict disclosure policy before reading values. Names can contain passwords, tokens, personal data, large payloads, and objects whose repr() executes expensive or failing code. Prefer type names, sizes, and an allowlist of safe scalar fields. Bound representation length and catch exceptions from formatting.

Sampling profilers generally impose less overhead than line tracing because they interrupt periodically instead of calling Python code for every event. Deterministic profilers are useful when complete call accounting matters. Debuggers need frame-level control. Choose the least invasive mechanism that answers the question, and reproduce without instrumentation before concluding that a race or timing issue is fixed.

Frame access can also trigger auditing events in CPython APIs. Hosts embedding Python or enforcing audit policy may observe or reject operations such as retrieving frames. Diagnostic libraries should degrade clearly when introspection is unavailable instead of treating unrestricted frame access as universal.

Practical decisions

  • Treat a frame as one execution, a function as a callable package, and a code object as an immutable recipe.
  • Diagnose UnboundLocalError by finding binding operations in the whole function, not by inspecting only the failing branch.
  • Pass dependencies explicitly rather than relying on caller locals or mutable module globals.
  • Use locals() as inspection data, not a way to assign optimized variables.
  • Use one namespace for module-like exec; understand class-like behavior before passing two.
  • Never present restricted globals or builtins as a sandbox.
  • Extract diagnostic facts and release frames and tracebacks to avoid retaining object graphs.
  • Qualify frame and locals tooling by Python version and implementation.

Exercises

  1. Add a dead if False: token = 1 assignment to a function that reads global token. Predict and explain the result.
  2. Shadow len locally, globally, and through an exec builtins mapping. Record which namespace wins each time.
  3. Capture two recursive frames using sys._getframe() and prove their f_code objects match while their locals differ.
  4. Compare locals() mutation at module scope, class scope, and function scope on Python 3.14.
  5. Rewrite the separate-namespace exec experiment so scale deliberately receives factor without relying on dynamic globals.
  6. Build a traceback formatter that extracts filename, function, line, and selected safe local types, then drops all frame references.

Keep this model

Compilation classifies names; function construction supplies globals and closure state; each execution supplies a frame. Local reads do not search caller frames. Global reads use the function's defining globals, then its builtins. Optimized function locals are real execution storage even when locals() presents them through a mapping interface.

That model predicts UnboundLocalError, builtin shadowing, global patching, and the surprising two-namespace form of exec(). Use frames to observe execution, not to smuggle dependencies through it.

Primary sources