The Python Execution Model
Every other page in this section - variables, control flow, functions, modules - rests on one underlying model of how Python actually runs code.
That model is simpler than it looks from the outside: names are bound to objects inside namespaces, and almost everything you do in Python is some variation of creating an object and binding a name to it.
This page is the mental model underneath Variables, Types & Dynamic Typing, Control Flow, Functions, Args & Scope, and Modules, Imports & Packages.
Once it clicks, scoping rules, import caching, and the mutable-default-argument trap all stop looking like separate quirks and start looking like the same mechanism showing up in different places.
Summary
- Python code execution is the repeated act of binding names to objects inside namespaces - assignment,
def,class, andimportare all specializations of this one operation. - Insight: Once you see names and objects as separate things connected by bindings, scope rules, shared mutable state, and import caching stop being special cases and become predictable consequences of one mechanism.
- Key Concepts: name binding, namespace, scope (LEGB), object identity, module caching.
- When to Use: Reasoning about why two variables mutate together, why a function reads an outer variable but can't reassign it without
nonlocal, or why editing a module file mid-run doesn't affect an already-imported copy. - Limitations/Trade-offs: The flexibility of dynamic name binding costs some static guarantees - a name's target type can change at runtime, and tooling can only approximate what a fully static language checks for free.
- Related Topics: reference semantics and mutability, function scope and closures, the import system, CPython's object model.
Foundations
The single most useful shift in thinking about Python is to stop calling x = 5 "storing 5 in x" and start calling it "binding the name x to an integer object 5."
The object exists independently, with its own type, value, and identity; the name is just a label pointing at it, stored in a namespace.
A namespace is, concretely, a dictionary mapping names (strings) to objects - globals() and locals() give you a direct look at two of them.
x = [1, 2, 3]
print(globals()["x"] is x) # True - the module namespace really is a dictAssignment (x = ...), def, class, and import all reduce to the same primitive: build (or locate) an object, then bind a name to it in the current namespace.
That is why a function and a module both have attributes you can inspect - a function object has a __name__, a __doc__, a __defaults__; a module object has whatever names its top-level code bound.
A useful analogy is a filing system: objects are the physical folders sitting in storage, and names are sticky notes pointing at them - moving a sticky note (rebinding a name) never touches the folder, but writing inside a folder (mutating an object) is visible to every sticky note pointing at it.
Mechanics & Interactions
Every namespace exists inside a specific scope, and Python resolves a bare name by searching scopes in a fixed order: Local, Enclosing, Global, Built-in - LEGB.
Each function call creates a fresh local namespace (a frame), which is why recursive calls don't overwrite each other's variables even though they share the same code object.
nonlocal and global exist because reading a name searches outward through LEGB automatically, but assigning to a name defaults to creating it locally - those two keywords tell Python "skip local creation, rebind the name that already exists further out."
def make_counter():
count = 0
def increment():
nonlocal count # without this, count += 1 raises UnboundLocalError
count += 1
return count
return increment
tick = make_counter()
print(tick(), tick()) # 1 2 - state lives in the enclosing frame, not globallyThe import system layers a caching mechanism on top of the same model: the first import mymodule executes that file's top-level code exactly once, builds a module object out of the resulting namespace, and stores it in sys.modules; every later import mymodule anywhere in the process just looks up that cached object instead of re-running the file.
This single fact explains two common surprises at once: editing a module's source mid-process has no effect until the process restarts or the module is explicitly reloaded, and importing a module with side effects (like opening a connection at the top level) only pays that cost once no matter how many files import it.
Default argument values interact with this model in a way that trips up almost everyone once: def f(items=[]) evaluates [] a single time, when the def statement runs, and stores that one list object in the function's __defaults__ - every call that omits items shares that same object, which is why mutating it inside the function leaks state across calls.
Advanced Considerations & Applications
CPython compiles source into bytecode (viewable via the dis module) before a virtual machine executes it frame by frame, which is why Python is usually called an interpreted language even though a real compilation step exists.
For most of CPython's history, the Global Interpreter Lock (GIL) has serialized bytecode execution across threads in one process, meaning CPU-bound multi-threaded code rarely runs faster than single-threaded code - a direct consequence of how the reference-counting object model manages memory safety.
Python 3.13 introduced an experimental free-threaded build (PEP 703) that can run without the GIL, and Python 3.14 continues stabilizing it as an official (though still opt-in) build mode - a genuine evolution of this execution model, not just a tuning knob.
| Execution model | Strength | Weakness | Best fit |
|---|---|---|---|
| CPython bytecode + GIL (default build) | Simple, safe reference counting; huge ecosystem compatibility | One thread executes Python bytecode at a time per process | General-purpose scripts, I/O-bound services, most production code today |
| CPython free-threaded build (3.13+, opt-in) | True multi-core parallelism for pure-Python CPU work | Newer, some C-extension incompatibility, extra per-object locking overhead | CPU-bound workloads that can't use multiprocessing or native code |
| Multiprocessing / subprocess isolation | Sidesteps the GIL entirely via separate processes | Higher memory use, slower inter-process communication | CPU-bound work today, on any supported Python build |
Garbage collection follows the same object-and-reference story: CPython primarily uses reference counting (an object is freed the instant its count hits zero), backed by a cyclic collector that periodically sweeps for reference cycles that counting alone can't catch, such as two objects that point at each other.
Understanding namespaces as dictionaries also explains why dir(), vars(), and getattr() all work uniformly across modules, instances, and classes - they are all just inspecting or querying a namespace-backed object rather than three unrelated language features.
Common Misconceptions
- "Python variables are like C variables - typed storage slots." A name has no type of its own; the object it's bound to has a type, and the same name can be rebound to a completely different type of object at any time.
- "Assignment copies the value." Assignment binds a name to the existing object; two names can point at the very same mutable object, which is why
y = x; y.append(1)changes whatxsees too for mutable objects. - "Every
importre-runs the module's code." Only the first import per process does; every subsequent import of the same module name returns the cached object fromsys.modules. - "A nested function can read and write any enclosing variable freely." Reading walks LEGB automatically, but assigning inside a nested function creates a new local name by default unless
nonlocalsays otherwise. - "Mutable default arguments are re-evaluated on every call." The default expression runs once, at
deftime, and the resulting object is reused on every call that omits that argument.
FAQs
What does it actually mean to say a name is "bound" to an object?
It means an entry mapping that name (a string) to that object now exists in some namespace - a dictionary-like structure such as a module's globals, a function's locals, or a class body.
Why doesn't `x = y` make `x` a copy of `y`?
Because assignment never copies data - it only creates or updates a name-to-object binding, so x and y end up pointing at the same object when the right-hand side is already an object reference.
What is LEGB, exactly?
It is the fixed order Python searches when resolving a bare name: Local (the current function frame), Enclosing (any outer function frames), Global (the module's namespace), and Built-in (Python's built-in names like len or print).
Why does assigning to a variable inside a function sometimes raise `UnboundLocalError`?
If the function body assigns to that name anywhere, Python treats it as local for the entire function, so reading it before that assignment fails - global or nonlocal are needed to rebind an outer name instead of shadowing it.
Is a Python module basically a dictionary?
Functionally, yes - a module object's namespace (everything you can access as module.name) is backed by a dictionary that's populated by running the module's top-level code once.
What happens if I import the same module from two different files?
Python runs the module's top-level code only the first time; every subsequent import anywhere in the same process retrieves the identical cached module object from sys.modules rather than re-executing anything.
Why is the mutable-default-argument trap connected to this execution model?
Because a def statement runs its default-value expressions exactly once and stores the resulting object on the function - every call that omits that argument reuses the same object, so mutating it accumulates state across calls.
What is a namespace, concretely?
A mapping from names to objects - in practice, most namespaces (module globals, function locals, class bodies) are implemented as dictionaries you can inspect with globals(), locals(), or vars().
Does Python compile code before running it?
Yes - CPython compiles source into bytecode first, then a virtual machine executes that bytecode frame by frame; that's why it's usually described as interpreted even though a real compilation step happens.
Why can only one thread run Python bytecode at a time in a normal CPython process?
The Global Interpreter Lock (GIL) serializes bytecode execution to keep reference counting safe without needing a lock around every single object; Python 3.13+ offers an experimental free-threaded build that removes this constraint at the cost of extra per-object locking overhead.
How does Python decide when to free an object's memory?
Primarily through reference counting - the moment an object's reference count reaches zero it is freed immediately - with a periodic cyclic garbage collector handling the rarer case of objects that reference each other in a cycle.
Why does understanding names-versus-objects matter more than memorizing syntax?
Because scoping rules, shared-mutation bugs, import caching behavior, and the mutable-default trap are all separate-looking symptoms of the exact same underlying mechanism, so understanding the mechanism once explains all of them instead of memorizing four unrelated rules.
Related
- Variables, Types & Dynamic Typing - reference semantics and object identity in depth
- Control Flow - how branching and looping constructs execute inside this same model
- Functions, Args & Scope - LEGB scoping and argument binding in practice
- Modules, Imports & Packages - the import cache and package namespaces up close
- Python Basics - a hands-on tour of the syntax this model underlies
Stack versions: This page was written for Python 3.14 (stable) and Python 3.13 (maintenance), including the free-threaded build notes introduced in 3.13 and stabilized further in 3.14.