Fast Locals and the LOAD_FAST Family
Inside a Python function, reading a local variable does not hash a string and probe a dictionary. The compiler has already assigned each local a small integer index, and the value lives at that index in a flat C array attached to the frame — the
localsplusarray. Reading a local is therefore a single array load,frame->localsplus[i], and the bytecode that does it,LOAD_FAST, is the most frequently executed instruction in CPython — about 20% of all dynamic bytecode on the benchmark suite (gh-130704). This note explains why local access is O(1) array indexing rather than dict lookup, walks the wholeLOAD_FASTopcode family — including the 3.14LOAD_FAST_BORROWoptimization that skips even the reference-count bump — and shows howframe.f_localsreconstructs a dict-like view on demand. The companion question — how the compiler decides a name is a fast local in the first place — is Local Global and Nonlocal Scopes.
Mental Model: Locals Are Slots, Not Dictionary Keys
At module and class scope, a namespace really is a dictionary, and a name lookup is a hash-table probe (LOAD_NAME/LOAD_GLOBAL). Inside a function it is fundamentally different. Because the symbol table proves at compile time exactly which names are local and in what order, the compiler can replace each local name with a fixed integer offset. The function’s code object carries a tuple co_localsplusnames mapping offset → name, and each running frame carries a parallel array of slots. Reading x becomes “load slot 3,” not “hash the string ‘x’ and probe a table.” The dict-vs-array distinction is the whole performance story: a dict probe is several memory accesses plus a hash; an array load is one indexed memory read.
flowchart LR subgraph Compile["Compile time"] ST["Symbol table:<br/>x→0, y→1, z→2"] end subgraph Frame["Frame at run time"] direction TB SP["specials<br/>(code, globals, builtins)"] L0["localsplus[0] = x"] L1["localsplus[1] = y"] L2["localsplus[2] = z"] CELL["localsplus[3] = cell (free/cell var)"] STK["localsplus[4..] = value stack"] SP --- L0 --- L1 --- L2 --- CELL --- STK end ST -->|"LOAD_FAST 0"| L0
Diagram: the compiler turns each local name into a fixed index; the frame’s localsplus array holds plain locals first, then cell and free variables, then the evaluation stack — all in one contiguous block. LOAD_FAST 0 is a direct index into this array. The insight: a “local variable” is a numbered slot in a C array, and the variable’s name survives only as debugging metadata in co_localsplusnames.
The localsplus Array: One Contiguous Block
A CPython frame (_PyInterpreterFrame in the 3.14.5 source) ends with a flexible array member declared _PyStackRef localsplus[1] (pycore_interpframe_structs.h). The [1] is a C idiom for a variable-length trailing array — the real length is computed when the frame is allocated. The internal frames documentation describes the activation record as three sections: fixed-size specials (globals, builtins, code object), then locals (the fast local variables, “including arguments, cells and free variables”), then the stack (“evaluation stack for intermediate values”). Because “the specials have a fixed size, the offset of the locals is known” (frames.md).
The ordering within the locals region is fixed and load-bearing: plain locals (the co_varnames, count co_nlocals) come first, then cell variables (co_cellvars), then free variables (co_freevars), and finally the value stack. The names for all of them are concatenated in that same order into co_localsplusnames, and a parallel co_localspluskinds bytes object records each slot’s kind (per the code-object definition). This unification is why, since CPython 3.11, the LOAD_DEREF cell index “is no longer offset by the length of co_varnames” (dis docs) — locals, cells, and frees all index into the same localsplus array with one continuous index space. The _PyStackRef element type (a 3.12+ tagged-pointer wrapper) is what lets a slot carry a “borrowed” bit, which the LOAD_FAST_BORROW optimization below exploits.
The interpreter reaches a slot through one macro. In ceval_macros.h:
#define GETLOCAL(i) (frame->localsplus[i])That is the entire “lookup”: index a C array. No hashing, no probing, no string comparison.
Walking the LOAD_FAST Family
All of the following come from the CPython 3.14.5 bytecodes.c; the public semantics are from the dis docs. The inst(...) definitions are CPython’s bytecode DSL; replicate(8) generates eight specialized copies for the common low oparg values, and pure marks the op as having no side effects (useful to the tier-2 optimizer).
LOAD_FAST — the common case
replicate(8) pure inst(LOAD_FAST, (-- value)) {
assert(!PyStackRef_IsNull(GETLOCAL(oparg)));
value = PyStackRef_DUP(GETLOCAL(oparg));
}Line by line: the assert documents the invariant that plain LOAD_FAST is only emitted where the local is provably initialized — since 3.12 it “cannot raise UnboundLocalError” (dis docs). GETLOCAL(oparg) indexes the array. PyStackRef_DUP creates a new owned reference to the same object — i.e. it does an incref — and pushes it. That single incref is the entire cost beyond the array read.
LOAD_FAST_CHECK — when the local might be unbound
inst(LOAD_FAST_CHECK, (-- value)) {
_PyStackRef value_s = GETLOCAL(oparg);
if (PyStackRef_IsNull(value_s)) {
_PyEval_FormatExcCheckArg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(_PyFrame_GetCode(frame)->co_localsplusnames, oparg)
);
ERROR_IF(true);
}
value = PyStackRef_DUP(value_s);
}Added in 3.12, this is the form used when the compiler cannot prove the slot is initialized — e.g. a variable assigned only on one branch. An empty slot is represented by PyStackRef_NULL; the check raises UnboundLocalError, and notice the error message pulls the variable’s name from co_localsplusnames[oparg] — confirming that the name survives only as metadata for diagnostics, not as a lookup key. (This is the runtime half of the UnboundLocalError story whose compile-time cause — “an assignment anywhere makes the name local” — is explained in Local Global and Nonlocal Scopes.)
STORE_FAST and DELETE_FAST
replicate(8) inst(STORE_FAST, (value --)) {
...
_PyStackRef tmp = GETLOCAL(oparg);
GETLOCAL(oparg) = value;
DEAD(value);
PyStackRef_XCLOSE(tmp);
}STORE_FAST writes the slot and XCLOSEs (decrefs) whatever was there before — the assignment overwrites the old binding and releases its reference. DELETE_FAST sets the slot back to PyStackRef_NULL (after raising UnboundLocalError if it was already empty), which is exactly how del x makes a later read raise. The store/delete pair is the write side of the same array; together with the loads they make a local variable a slot you assign, read, and clear by index.
LOAD_FAST_AND_CLEAR — comprehension scoping
inst(LOAD_FAST_AND_CLEAR, (-- value)) {
value = GETLOCAL(oparg);
GETLOCAL(oparg) = PyStackRef_NULL;
}Added in 3.12, this loads the slot and immediately nulls it, even if it was already NULL (it never raises). It is the mechanism behind inlined comprehensions (PEP 709, since 3.12): a list/set/dict comprehension no longer gets its own frame, so the comprehension’s iteration variable must not clobber a same-named local in the enclosing function. The compiler emits LOAD_FAST_AND_CLEAR to save and hide the outer value before the comprehension runs, then restores it after — giving the comprehension its own apparent scope while sharing the enclosing frame (PEP 709). See Comprehension Scoping for the full save/restore dance.
Super-instructions: LOAD_FAST_LOAD_FAST
LOAD_FAST_LOAD_FAST(var_nums): # co_varnames[var_nums >> 4] and co_varnames[var_nums & 15]Added in 3.13, this packs two local indices into one oparg (high nibble and low nibble), pushing both locals with a single dispatch. Because consecutive LOAD_FASTs are extremely common (f(a, b)), fusing them halves the dispatch overhead. There are matching STORE_FAST_STORE_FAST and STORE_FAST_LOAD_FAST fusions.
LOAD_FAST_BORROW — skipping the incref (new in 3.14)
replicate(8) pure inst (LOAD_FAST_BORROW, (-- value)) {
assert(!PyStackRef_IsNull(GETLOCAL(oparg)));
value = PyStackRef_Borrow(GETLOCAL(oparg));
}This is the headline 3.14 optimization. Compare it to LOAD_FAST: the only difference is PyStackRef_Borrow instead of PyStackRef_DUP. Borrow pushes a borrowed reference — it does not increment the reference count. The optimization is sound whenever the compiler can prove “the reference in the frame outlives the reference that is pushed onto the operand stack” (PR #130708): if the frame’s slot keeps the object alive for at least as long as the stack copy is used, the stack copy needs no count of its own. A compiler pass identifies eligible LOAD_FAST/LOAD_FAST_LOAD_FAST and rewrites them to LOAD_FAST_BORROW/LOAD_FAST_BORROW_LOAD_FAST_BORROW. The motivation: LOAD_FAST{_LOAD_FAST} is “the most frequently executed bytecode… ~20% of dynamic instruction frequency,” and while an incref/decref pair is cheap, it is not free (gh-130704).
The double form mirrors the super-instruction:
inst(LOAD_FAST_BORROW_LOAD_FAST_BORROW, ( -- value1, value2)) {
uint32_t oparg1 = oparg >> 4;
uint32_t oparg2 = oparg & 15;
value1 = PyStackRef_Borrow(GETLOCAL(oparg1));
value2 = PyStackRef_Borrow(GETLOCAL(oparg2));
}Performance figures are from the PR description
The PR reports “roughly 97% of
LOAD_FAST{_LOAD_FAST}instructions are optimized” and a speedup that settled around ~2.5% on the default build and ~2.1% on the free-threaded build in the merged revision (earlier comments cited ~2.7%/~3%) (PR #130708). These are benchmark-suite-wide numbers from the PR’s own measurements, so treat them as the implementers’ figures rather than an independent benchmark. The borrowed-reference avoidance matters disproportionately to the free-threaded build, where every refcount is an atomic operation.
Contrast: Why LOAD_GLOBAL/LOAD_NAME Are Dict Lookups
The reason fast locals are fast is precisely the contrast with the name-based opcodes. LOAD_GLOBAL and LOAD_NAME cannot use an array, because module and class namespaces are mutable dictionaries whose key set is not known at compile time — anything can inject a global or class attribute at runtime. So those opcodes hash a string and probe a dict (globals, then builtins). The compiler emits the array-indexed LOAD_FAST family only where it has statically proven a name is local; everywhere else it falls back to the dict-based forms. Which opcode the compiler chooses for a given name — and the LEGB rule that drives the choice — is the subject of Local Global and Nonlocal Scopes; this note is the mechanism on the fast side of that fork.
frame.f_locals: Reconstituting a Dict View
If locals live in a nameless C array, what does locals() or frame.f_locals return? Historically, CPython materialized a snapshot dict by walking co_localsplusnames and copying each non-NULL slot into a fresh dictionary — a one-way copy, so mutating the returned dict did not change the actual variables, and the snapshot could go stale. PEP 667 (Final, implemented in Python 3.13) replaced this for optimized (function) scopes with a write-through proxy, FrameLocalsProxy (PEP 667). The proxy implements the Mapping interface but is backed directly by the frame’s localsplus slots: reading a key reads the slot, writing a key writes the slot, and changes are immediately visible in both directions. Extra keys not corresponding to a real local are kept in a side dict on the frame; deleting a genuine local through the proxy is prohibited. Each access to frame.f_locals yields a fresh proxy instance. For module and class scopes — where the namespace already is a dict — f_locals remains a direct reference to that dict, unchanged. PEP 667 fixed long-standing bugs where edits to locals() inside a debugger or a pdb session silently failed to take effect; the debugger and tracing tools were the principal beneficiaries.
Failure Modes and Common Misunderstandings
locals()“doesn’t update the variable.” Before 3.13 this was real:locals()['x'] = 5did nothing because you mutated a snapshot. Under PEP 667 (3.13+) the write-through proxy makes it work in function scope — but code written for older versions may rely on the old snapshot behavior, and the two differ.exec("x = 1")inside a function doesn’t create a local. The local set is fixed at compile time;execwriting intof_locals(now a proxy) can change an existing slot but cannot add a brand-new fast local, because no slot was reserved. This trips people expectingexecto inject locals.- Reading the disassembly wrong. The dis docs say
LOAD_FASTpushesco_varnames[var_num], which is a simplification: the real array islocalsplusand the names live inco_localsplusnames, of whichco_varnamesis only the plain-locals prefix. For a closure variable you will seeLOAD_DEREF, notLOAD_FAST, even though both index the same array. - Assuming
del xfrees the slot.DELETE_FASTnulls the slot but the slot itself persists for the frame’s lifetime; a subsequentLOAD_FAST_CHECKwill raiseUnboundLocalError, not fall through to a global.
Alternatives and Contrasts
The array-of-slots design is shared, in spirit, by most bytecode VMs: the JVM and the CLR both index locals by integer slot rather than name, for the same reason. What is distinctive about CPython is the late arrival of refcount-elision (LOAD_FAST_BORROW, 3.14) — a consequence of CPython’s reference-counting memory model, where even reading a variable normally touches a count. A tracing-JIT implementation like PyPy sidesteps the whole question by compiling locals into machine registers when a loop is hot, eliminating the array access entirely; CPython’s copy-and-patch JIT instead inherits the localsplus model and leans on LOAD_FAST_BORROW plus tier-2 redundant-refcount elimination to close part of the gap. Within CPython, the only “alternative” to fast locals is the dict-based LOAD_NAME/LOAD_GLOBAL path that module and class bodies are stuck with — which is exactly why function-local code is measurably faster than equivalent module-level code.
Production Notes
Because LOAD_FAST dominates the instruction mix, the classic micro-optimization “hoist an attribute or global into a local before a tight loop” is real and measurable: turning repeated self.method(...) or math.sqrt(...) calls into a local m = self.method / sqrt = math.sqrt replaces LOAD_ATTR/LOAD_GLOBAL (dict work) with LOAD_FAST (array index) on every iteration. The specializing interpreter and LOAD_FAST_BORROW have narrowed the gap, but the array-vs-dict difference still favors the local. When profiling, dis.dis showing a hot loop full of LOAD_GLOBAL is a flag that a hoist would help. The PEP 667 proxy change (3.13) is the other production-relevant item: tooling that edits locals through f_locals — debuggers, REPL magic, some test frameworks — behaves correctly on 3.13+ where it previously silently no-op’d, so a debugger that “couldn’t change a variable” on 3.12 may simply work after the upgrade.
See Also
- Local Global and Nonlocal Scopes — sibling: which opcode the compiler picks (LEGB), and why an assignment anywhere makes a name local
- Stack Frames and the Frame Stack — the frame whose trailing
localsplusarray holds the slots, and how frames are bump-allocated on a per-thread stack - Python Bytecode Instruction Set — the full opcode catalogue this family belongs to
- Cell Variables and Closures — cell and free variables, the other occupants of
localsplusafter the plain locals - Comprehension Scoping — how
LOAD_FAST_AND_CLEARgives inlined comprehensions (PEP 709) their own scope - Symbol Table Construction — the compile-time pass that assigns each local its slot index
- Code Objects — where
co_localsplusnames/co_localspluskindslive - The pdb Debugger — a principal beneficiary of the PEP 667 write-through
f_locals - Python Internals MOC — parent