skip to content

Why does an assignment inside exec() not change a function's local variable?

level: middleimportance: should knowfreq 35%

answer

  1. Function scope is not a dictionary
  2. The compiler fixes the locals before running
  3. exec writes into a copy of the frame
  4. Reads work, writes are dropped
  5. PEP 667 in 3.13 made it defined

basics

~20 s

A function's locals are compiled into fixed numbered slots, and exec() with no explicit namespace writes into a dictionary snapshot of those slots instead. The assignment lands in the throwaway mapping, so the real variable never changes.

solid answer

~50 s

Inside a function, the compiler decides at compile time exactly which local names exist and stores them in a fixed array of slots on the frame, addressed by index rather than by name. `exec("x = 99")` with no namespace arguments runs against the mapping that `locals()` produces for that frame, which is a **snapshot dictionary** built from those slots. The exec'd assignment updates the dictionary; nothing copies it back into the slot array, so the function's own `x` is untouched. Since **3.13** (PEP 667) that snapshot is specified to be an independent copy created on each call, making the behaviour defined rather than an implementation detail. The fix is not a trick but a design change: pass an explicit dict as the namespace and read the value back out of it, or have the string produce a value through `eval()` and bind it yourself.

code

python · 6 lines
python
def f():
    x = 1
    exec("x = 99")
    return x

print(f())   # 1

go deeper

for a junior

Recall the outcome and the safe pattern: an assignment inside exec() does not change a function's variable, so pass your own dictionary as the namespace and read the value back out of it afterwards.

for a middle

Explain the mechanism: function locals are compile-time slots addressed by index, and exec()'s implicit local mapping is a snapshot dict built from them. Be able to say why reads work and writes do not.

for a senior

Show that you know the version story - PEP 667 in 3.13 made the snapshot independent and the behaviour specified - and steer a design away from needing this at all, using an explicit namespace or eval() for a value.

for a principal

Own the broader position: runtime name injection into live frames is a debugging facility, not an architecture. Be ready to argue for a declared, parsed configuration or expression surface over code that mutates namespaces on the fly.

## Two different ways Python stores names At module level and inside a class body, the local namespace **is** a real dictionary. Names are looked up by string key at run time, so anything that can write into that dictionary genuinely creates a variable. A function body is compiled differently. When the compiler walks a function, it sees every name that gets assigned anywhere in the body and fixes the set of local variables right then, before the function ever runs. Those names become numbered slots on the frame, and the bytecode addresses them by index, not by name - which is why local access is fast and why a typo'd local raises `UnboundLocalError` rather than looking anything up. The consequence that matters here: **the set of local variables in a function is closed at compile time.** No runtime operation can add one. ## Where exec()'s assignment actually goes `exec(source)` with no namespace arguments runs "in the current scope", which means it uses the caller's globals and the caller's *local mapping*. In a function, that local mapping is not the slot array - the slot array is not a mapping at all. It is the dictionary that `locals()` produces: a snapshot built by copying each occupied slot into a dict. So the exec'd `x = 99` does exactly what it says: it binds the key `"x"` to `99` **in that dictionary**. The dictionary is then discarded. Nothing walks it afterwards looking for changes to push back into the slots, and even if something did, the compiler's slot layout could not accommodate a name it had never seen. ```python def f(): x = 1 exec("x = 99") return x f() # 1 ``` Note what *does* work: **reading**. The snapshot is built from the live slots at the moment of the call, so `exec("print(x)")` prints `1`. Only the write direction is lost. ## What changed in 3.13 Before Python 3.13 this was a well-known wart with fuzzy edges. `locals()` in a function returned a dict that CPython *cached* on the frame and re-synchronised from the slots on each call, so writes into it could survive until the next refresh and could be observed by a later `locals()` lookup in the same function - without ever affecting the actual variable. That made "what happens" an implementation detail rather than a language rule. **PEP 667, shipped in 3.13**, tightened it: `locals()` in an optimized (function) scope now returns an **independent snapshot** created fresh on every call. Writing to it is guaranteed to have no effect on the frame, and a second `locals()` call will not show your write. Debuggers get a separate write-through view of a frame's variables, which is a deliberate carve-out for tooling rather than a channel for ordinary code. The headline outcome for this question is unchanged across 3.10 through 3.14 - the assignment never reaches the local - but from 3.13 it is specified behaviour instead of an accident. ## The fixes, in order of preference **Pass your own namespace and read the result out.** This is the only approach that is both explicit and reliable: ```python def price(expr, ctx): ns = dict(ctx) exec(expr, ns) return ns["bid"] ``` **Use `eval()` when you want a single value.** If the string is a formula rather than a block, `bid = eval(expr, ctx)` binds the local yourself, with normal compile-time semantics. **Move the code to module or class scope** if it genuinely needs to define names in a real namespace - both use dictionaries, so `exec` there does bind names. ## Two fixes that do not work Passing `locals()` explicitly changes nothing: you are handing exec() the same snapshot dict it would have used anyway, and you now hold a reference to a copy. And declaring `global x` inside the exec'd string does not reach the function's local either - it binds a *module-level* name of that name, which is a different variable that the function's compiled code will not read, since the compiler already classified `x` as local. ## Why it is worth knowing The question is a compact probe of whether a candidate understands that Python's scopes are not uniformly dictionaries. The same fact explains why `UnboundLocalError` exists, why a nested function's closure variables are separate from both locals and globals, and why tools that need to inject variables into a running frame have to go through a debugger-oriented interface rather than through `exec()`.

  • Does the same thing happen when exec() is called at Python module level?
    No. At module level the local namespace *is* the module's globals dictionary, so `exec("x = 99")` really does create or rebind a module-level name, and the surrounding code sees it. Class bodies behave the same way, because a class body also executes against a real dict. The failure is specific to function scope, where locals live in compile-time slots rather than in a mapping.
  • Can the exec'd code at least read the function's local variables?
    Yes. The implicit local mapping is a snapshot taken from the frame's slots at the moment of the call, so every local that is currently bound is visible by name inside the string. Only writes are lost. That asymmetry is the thing to state clearly in an interview: exec() sees a copy, so reads reflect reality and writes go nowhere.
  • Why doesn't declaring the name global inside the exec'd string fix it?
    Because it binds a different variable. `global x` makes the exec'd assignment write into the module's globals dict, but the surrounding function's compiler already classified `x` as a local, so its bytecode reads the local slot and never consults globals. You end up with a module-level name nobody reads, plus an unchanged local - two bugs instead of one.

saying these in an interview costs you the question

  • Claims exec() cannot see the function's locals at all
  • Says the write worked but Python cached a stale value
  • Thinks passing locals() explicitly makes writes stick
  • Suggests declaring the name global inside the string
  • Assumes exec() can create new local slots at run time

context