skip to content

Why does assigning into locals() inside a Python function not change the variable?

level: middleimportance: should knowfreq 26%

answer

  1. function variables are not stored in a dict
  2. the builtin hands back a copy
  3. the frame attribute is not the builtin
  4. PEP 667 landed in 3.13
  5. f_locals writes through, locals() does not

basics

~20 s

Function locals live in numbered slots on the frame, not in a dictionary. locals() manufactures a snapshot dict of those slots, so writing into it changes only the copy. Since 3.13, a frame's f_locals is a write-through proxy and does update the real variable.

solid answer

~50 s

The compiler turns a function body into an optimized scope: it knows every local name up front and gives each one a slot on the frame, so there is no dictionary to hand back. `locals()` builds a snapshot of those slots, and writing into that snapshot changes a copy — the variable is untouched. PEP 667, shipped in **Python 3.13**, split the two cases that used to be conflated: `locals()` in a function is now specified to return an independent snapshot each call, while `frame.f_locals` returns a **write-through proxy** whose reads are always current and whose writes go straight back into the slots. Before 3.13 both were snapshots and both discarded writes outside a debugger. Module and class bodies are different — those scopes really do use a dictionary, so `locals()` there is the live namespace.

code

python · 12 lines
python
import sys


def f():
    x = 1
    locals()["x"] = 2
    print(x)  # 1 - locals() is a snapshot on every version
    sys._getframe().f_locals["x"] = 3
    print(x)  # 3 on 3.13+, 1 on 3.12 and earlier


f()

go deeper

for a junior

Recall the headline: writing into locals() inside a function does not change the variable, because you are writing into a copy. Bind values with ordinary assignment statements the compiler can see.

for a middle

Explain the mechanics: optimized function scopes store locals in frame slots, so the dict has to be manufactured. State the 3.13 split — f_locals is a write-through proxy, locals() is a specified snapshot.

for a senior

Show where it bites in real tooling: debuggers setting variables at a breakpoint, and libraries tempted to read a caller's locals to resolve names. Note the pre-3.13 trace-hook write-back and why it was replaced.

for a principal

Own the guidance: reading another frame's locals is a coupling decision, not a convenience. Decide whether framework magic that inspects caller state is allowed, and what the supported alternative is.

Python compiles function bodies into what the interpreter calls an *optimized* scope: the compiler knows every local name at compile time and assigns each one a numbered slot on the frame, so reading `x` inside a function is an index into an array, not a dictionary lookup. That single optimization is the source of the whole `locals()` confusion. There is no dictionary of local variables to hand you — one has to be *manufactured* on request. ### Before 3.13 In Python 3.12 and earlier, both `locals()` and a frame's `f_locals` attribute produced the same thing inside a function: a dictionary snapshot, built by copying the current value of every slot. Writing into that dictionary changed the copy and nothing else, and the next access rebuilt the snapshot from the slots, silently discarding your write. So: ```python def f(): x = 1 locals()["x"] = 2 print(x) # 1 on every version ``` The only exception was a debugger: while a trace function was active, the interpreter had a write-back step that pushed the snapshot's contents into the real slots, which is how a debugger could let you assign to a local at a breakpoint. That write-back was famously unreliable and was the motivation for changing the design. ### PEP 667, shipped in Python 3.13 PEP 667 split the two behaviours that had been conflated. * `frame.f_locals` in a function scope now returns a **write-through proxy** rather than a snapshot. Reads go straight to the frame's slots, so it is always current, and writes go straight back into the slots, so an assignment through it really does change the variable. * `locals()` inside a function is now explicitly defined to return an **independent snapshot dict**. Mutating it is guaranteed to have no effect on the function's variables, and each call returns a fresh dict rather than repeatedly refreshing one shared object. ```python import sys def f(): x = 1 locals()["x"] = 2 print(x) # 1 — snapshot, write discarded sys._getframe().f_locals["x"] = 3 print(x) # 3 on 3.13+, 1 on 3.12 and earlier ``` That is the whole interview answer: since 3.13, `f_locals` writes through and `locals()` does not, and before 3.13 neither did. ### The asymmetry that trips people up At module level and inside a class body, the scope is *not* optimized — names really do live in a dictionary — and `locals()` returns that actual mapping. So `locals()["x"] = 2` at module level does create a usable `x`. People generalize from a REPL experiment to function bodies and are wrong, because the two scopes have genuinely different storage. The same applies to `exec("x = 1")` inside a function: the assignment lands in whatever mapping `exec` was given — by default the snapshot — and evaporates. `exec` cannot create a local variable in a compiled function, on any version. There is one more edge worth knowing. Writing a name through the proxy that the function never declares as a local has nowhere to go in the slot array, so the frame stores it in an extra side-mapping. It is visible if you read `f_locals` again, but the compiled bytecode never looks there — the compiler already decided that name was a global — so reading it as a bare name raises `NameError`. The proxy is a way to set variables the function *has*, not a way to inject new ones. ### Why any of this matters in practice Three groups of people care. Debugger and REPL authors care because "set this variable and continue" is a core feature, and PEP 667 is what made it dependable rather than a trace-hook trick. Template and dependency-injection libraries care because they are tempted to read a caller's locals to resolve names — the proxy makes that read cheap and always current, but it does not make the coupling a good idea. And everyone else cares for the negative reason: it explains why the tempting one-liner — build a dict, dump it into `locals()`, get free variables — has never worked and, for `locals()`, is now specified never to work. The practical rule: treat `locals()` as a read-only reporting snapshot, use `f_locals` only in tooling that is knowingly reaching into another frame, and bind variables the ordinary way — with assignment statements the compiler can see.

  • Why does writing into locals() work at module level but not inside a function?
    Module and class bodies are unoptimized scopes: their names genuinely live in a namespace dictionary, and `locals()` returns that dictionary, so a write to it is a write to the namespace. A function body is an optimized scope whose names live in numbered frame slots decided at compile time; there is no dictionary, so one has to be built on demand, and the write lands in the copy.
  • Can exec("x = 1") create a local variable inside a function?
    No, on any version. `exec` assigns into whatever mapping it was given, which defaults to the result of `locals()` — a snapshot in a function scope — so the binding evaporates. The compiler has also already decided how bare `x` is looked up in that function, so even a stored value would not be reachable by name. Pass an explicit dict to `exec` and read the result out of it.
  • What happens if you set a name through f_locals that the function never declares as a local?
    There is no slot for it, so the frame keeps it in a side-mapping. You can read it back through `f_locals`, but the compiled bytecode never consults that mapping — the compiler classified the bare name as a global — so referring to it raises `NameError`. The proxy can change variables the function has; it cannot inject new ones.

locals() hands you a photocopy of the frame's variables; scribbling on the copy changes nothing. A frame's f_locals since 3.13 hands you a window onto the originals instead.

saying these in an interview costs you the question

  • Believes locals() returns a live view you can write through
  • Says locals() and frame.f_locals behave identically
  • Generalizes module-level locals() behaviour to function bodies
  • Claims exec can create a local variable in a function
  • Thinks PEP 667 made locals() writable rather than f_locals

context