skip to content

How do the globals and locals mappings passed to exec() decide what the code can see?

level: middleimportance: should knowfreq 30%

answer

  1. Three call shapes, three different meanings
  2. One dict means globals and locals are the same
  3. Two mappings resolve like a class body
  4. Bindings land in the locals mapping
  5. Nested defs do not close over locals

basics

~20 s

They are the namespace the code runs in. Pass nothing and it uses the caller's scope; pass one dict and it serves as both globals and locals; pass two and lookups fall back locals to globals to builtins, while new names bind into the locals mapping.

solid answer

~50 s

`exec()` and `eval()` take up to two namespace arguments and there are three meaningful shapes. **Neither given**: the code runs in the caller's scope, using the caller's globals and its local mapping. **Globals only**: that dict serves as both globals and locals, so the string executes like module-level code and every name it binds appears in your dict. **Both given**: name resolution goes locals, then globals, then builtins, and new bindings land in the locals mapping - the code behaves as if it were a class body. That last shape has a sharp edge: a function or comprehension defined inside the string does not close over the locals mapping, so it raises `NameError` for names that were only in `locals`. `globals` must be a real `dict`; `locals` may be any mapping, so a subclass with a custom `__getitem__` can supply names lazily. Since **3.13** both may be passed as keyword arguments.

code

python · 9 lines
python
ns = {"start": 10}
exec("doubled = start * 2", ns)
print(ns["doubled"])        # 20

g = {"start": 10}
l = {}
exec("doubled = start * 2", g, l)
print(l["doubled"])         # 20
print("doubled" in g)       # False

go deeper

for a junior

Recall the simplest safe pattern: pass one dictionary as the globals argument, and read anything the code bound back out of that same dictionary afterwards. Do not rely on the caller's own scope.

for a middle

Explain all three shapes and what changes between them: caller's scope, one dict serving as both namespaces, and two mappings with class-body resolution where writes land in locals.

for a senior

Demonstrate the operational judgement: a fresh mapping per evaluation so nothing leaks between runs or threads, awareness that builtins are injected regardless, and the nested-def trap that makes the two-mapping form surprising.

for a principal

Own the design question of whether a runtime-evaluated expression surface belongs in the system at all, what its contract is (which names are guaranteed, who authors the strings, how it is reviewed), and what a safer parsed grammar would cost.

## The three call shapes `exec(source, globals=None, locals=None)` - and `eval()` with the same signature - lets you choose the namespace the code runs in. Which of the two arguments you supply changes the semantics, not just the contents. **Neither argument.** The code runs in the caller's scope. At module level that means the module's globals, so `exec("total = 5")` really creates a module attribute. Inside a function it means the caller's globals plus a *snapshot* of the caller's locals, which is why an assignment there never reaches the function's own variables. **Globals only.** The locals argument defaults to the same object, so the string executes in one flat namespace exactly like top-level module code. Everything it binds is visible in your dict afterwards: ```python ns = {"start": 10} exec("doubled = start * 2", ns) ns["doubled"] # 20 ``` This is the shape most code wants. One dict in, one dict out, no surprises about where a name went. **Both arguments.** Now the two namespaces are distinct. Reads resolve locals first, then globals, then builtins. Writes go to the locals mapping only - your globals dict is not modified: ```python g = {"start": 10} l = {} exec("doubled = start * 2", g, l) l["doubled"] # 20 "doubled" in g # False ``` ## Class-body semantics, and the trap that follows The language reference describes the two-mapping form as executing "as if the code were embedded in a class definition", and that is not a loose analogy - it is the same scoping rule. A class body has a local namespace that is a real dict, and crucially **that namespace does not become an enclosing scope for functions defined inside it**. The same holds here: ```python g, l = {}, {"n": 5} exec("def h(): return n\nres = h()", g, l) # NameError: name 'n' is not defined ``` `h` compiles a global lookup for `n`, because there was no enclosing *function* scope to close over. At call time it consults `g`, where `n` does not exist. Comprehensions hit the same wall, since a comprehension body is compiled as its own scope: `exec("out = [n for _ in range(2)]", {}, {"n": 3})` raises `NameError` too. The fix is simply to pass one dict rather than two, which collapses the distinction and makes every name a global from the executed code's point of view. ## What each argument must be `globals` must be an actual `dict`. Handing `exec()` some other mapping raises `TypeError: exec() globals must be a dict`. `locals` is far more permissive: any mapping will do, so a `dict` subclass or a custom class implementing `__getitem__` can compute or log names on demand - a useful hook when the executed code is a small operator-authored formula and you want to resolve field names against a live record. Also worth knowing: if the globals dict you pass has no builtins entry, Python inserts a reference to the `builtins` module into it before running the code. That is a convenience, not a restriction, and it is why an empty globals dict is **not** a sandbox - the executed code still reaches every builtin. ## The 3.13 keyword-argument change Both arguments used to be positional-only. Since **Python 3.13** you can write `eval(src, globals=g, locals=l)`, which mostly helps readability at call sites that pass only the second one - previously you had to pass `None` for globals to reach it. ## Choosing a shape in practice For evaluating a small expression against a record, the sensible default is a single dict per evaluation: build it from the fields the expression is allowed to reference, pass it as globals, and let any bindings land there. Reuse a fresh dict per evaluation rather than a shared one, so no state carries between runs and nothing is shared across threads. Reach for the two-mapping form only when you deliberately want a stable read-only backdrop plus a scratch namespace for results - and remember that a function defined in the string will not see the scratch half.

  • Why does a function defined inside the exec'd string fail to see a name from the locals mapping?
    Because with two mappings the code runs with class-body scoping, and a class body is not an enclosing scope for functions defined inside it. The nested `def` compiles a global lookup for the name, so at call time it consults the globals dict, not the locals mapping, and raises `NameError`. Comprehensions fail the same way, since a comprehension body is its own scope. Passing a single dict avoids the whole issue.
  • Does passing an empty dict as globals stop the executed code from reaching builtins?
    No. If the globals dict has no builtins entry, Python inserts a reference to the `builtins` module before running the code, so `open`, `__import__` and everything else remain reachable. An empty namespace limits which of *your* names the code can see; it does not limit what the code can do. Treating it as a sandbox is a well-known mistake.
  • Can the globals argument be a mapping other than a dict?
    No - `exec()` and `eval()` raise `TypeError` unless globals is a real `dict`, because the interpreter's global-lookup fast paths assume dict layout. The locals argument is unconstrained: any mapping works, including a `dict` subclass or a class implementing `__getitem__`, which is how you supply names lazily or resolve them against a live record.

saying these in an interview costs you the question

  • Says an empty globals dict sandboxes the executed code
  • Expects new bindings in globals when a separate locals mapping was given
  • Thinks omitting both mappings gives a fresh empty namespace
  • Assumes a def inside the string closes over the locals mapping
  • Passes a non-dict mapping as globals and expects it to work

context