Skip to content
Path to Engineer
All lessons
PythonPython internals13 min read

Python scope and the LEGB rule

Local, enclosing, global, built-in — how Python resolves a name, when global and nonlocal are needed, and the loop-variable trap.

Day 30 of the 488-day pathway. Published in full — nothing held back.

Scope and the LEGB rule · global, nonlocal · closures over loop variables

The sentence to own: a closure captures the variable, not the value. That one fact explains the most famous "why do all my callbacks do the same thing?" bug in every language with closures, and it is a direct consequence of Day 024 — names are bindings, and a closure keeps the binding, not a snapshot.


Part 1 — LEGB

   NAME LOOKUP ORDER — the first match wins, and the search STOPS there:

    L — LOCAL      the current function
    E — ENCLOSING  any enclosing function(s), innermost first
    G — GLOBAL     the module
    B — BUILTIN    len, print, list, ...
x = "global"
def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)        #   local
    inner()

Shadowing a builtin is legal and silent: list = [1,2] then list(...) raises TypeError: 'list' object is not callable, three functions later. id, type, list, dict, str, input, sum, max, filter and hash are the ones people shadow.

The rule that produces UnboundLocalError

count = 0
def bump():
    print(count)       # ⚠️    UnboundLocalError: cannot access local variable 'count'
    count += 1
   WHY — and this is a COMPILE-TIME decision, not a runtime one:
       If a name is ASSIGNED ANYWHERE in a function body, it is LOCAL for the WHOLE body.
    ⇒ the compiler decides this when compiling the function, before it ever runs.
    ⇒ so `print(count)` on line 1 refers to the LOCAL count — which has no value yet.
    ⇒    `count += 1` is a READ then a WRITE, so it triggers the rule too.

  ⇒   MENTAL MODEL: the function's local names are FIXED at compile time.
       `dis` shows it: LOAD_FAST (local) vs LOAD_GLOBAL (module).
def bump():
    global count        #   "assignments here mean the MODULE-level name"
    count += 1

global is almost always the wrong answer — module-level mutable state is exactly the thing that breaks when you run four workers (Day 021). Prefer returning a value, or holding state on an object.


Part 2 — nonlocal, and closures

def counter():
    n = 0
    def inc():
        nonlocal n        #    "the n in the ENCLOSING function, not global, not new-local"
        n += 1
        return n
    return inc            #   inc outlives counter() — and still sees n

c = counter()
c(); c(); c()             # 1, 2, 3
   WHAT A CLOSURE IS, MECHANICALLY:
    the enclosing variable is stored in a CELL — a small heap object.
    the inner function keeps a reference to the cell in __closure__.
    ⇒    so the variable OUTLIVES the function call that created it,
    ⇒    and the closure sees LIVE UPDATES, not a snapshot.
c.__closure__            # (<cell at 0x...: int object at 0x...>,)
c.__closure__[0].cell_contents   #   3 — you can watch the captured value change
c.__code__.co_freevars   #   ('n',)

nonlocal vs global: nonlocal binds to the nearest enclosing function scope and fails at compile time if there is no such name — a genuinely useful safety property. global will happily create a module-level name that did not exist.


Part 3 — The loop-variable trap

funcs = [lambda: i for i in range(3)]
[f() for f in funcs]        #    [2, 2, 2]   ← NOT [0, 1, 2]
   WHY: all three lambdas closed over THE SAME VARIABLE i.
    The loop finished with i == 2. Then they were called.
    ⇒    a closure captures the VARIABLE, not the VALUE at creation time.
    ⇒   if you call them DURING the loop you get 0, 1, 2 — the timing is the whole bug.
#    FIX 1 — default argument: evaluated AT DEFINITION TIME (Day 025, used deliberately)
funcs = [lambda i=i: i for i in range(3)]        # ⇒ [0, 1, 2]

#   FIX 2 — a factory: each call gets its own scope, which is the cleaner statement of intent
def make(i): return lambda: i
funcs = [make(i) for i in range(3)]

#   FIX 3 — functools.partial
from functools import partial
funcs = [partial(lambda i: i, i) for i in range(3)]

Where this bites in real code: registering callbacks in a loop, building event handlers, scheduling tasks (asyncio.create_task(handle(x)) inside a loop), and constructing route handlers dynamically. Every one of them fires later, which is exactly when the trap springs.

The async version is worse, because the delay is guaranteed:

for url in urls:
    tasks.append(asyncio.create_task(fetch(url)))   #   fine — url is EVALUATED at call time
for url in urls:
    tasks.append(asyncio.create_task(lambda: fetch(url)()))  # ⚠️    all fetch the LAST url

Comprehension scope

#   Python 3: a comprehension has its OWN scope. The loop variable does not leak.
[i for i in range(3)]
i                    #   NameError (in Python 2 it was 2 — this was fixed deliberately)

# ⚠️    BUT: a comprehension inside a CLASS BODY cannot see the class's other names:
class C:
    vals = [1, 2, 3]
    doubled = [v * 2 for v in vals]          #   works — the ITERABLE is evaluated in class scope
    tripled = [v * n for v in vals for n in vals]   #   works
    scaled  = [v * factor for v in vals]     # ⚠️ NameError if factor is a class attribute

The reason: the class body is not a function scope, so it is skipped by the enclosing-scope lookup — the comprehension's implicit function cannot see it. Only the outermost iterable is evaluated in the class scope. It is obscure, but it produces a genuinely baffling NameError in Django/SQLAlchemy model bodies, which is exactly where people write comprehensions over class attributes.


Part 4 — Where closures are actually useful

#   1. Decorators — the whole mechanism is a closure (Day 044)
def retry(times):
    def deco(fn):
        def wrapper(*a, **kw):
            for attempt in range(times):     #    `times` captured from the enclosing scope
                try: return fn(*a, **kw)
                except Exception:
                    if attempt == times - 1: raise
        return wrapper
    return deco

#   2. Configured callbacks without a class
def threshold_filter(limit):
    return lambda x: x > limit

#   3. Memoisation — the cache lives in the closure, not in a global
def memo(fn):
    cache = {}                               #    one cache per decorated function
    def wrapper(n):
        if n not in cache: cache[n] = fn(n)
        return cache[n]
    return wrapper

A closure is a poor man's object, and an object is a poor man's closure — both bundle behaviour with state. Use a closure for one function and a little state; use a class when there are several related operations or the state needs a name.


Common mistakes

Mistake Correction
Expecting [lambda: i for i in ...] to capture values It captures the variable. Use i=i or a factory.
Reading then assigning a global UnboundLocalError — assignment anywhere makes it local.
Reaching for global Return a value, or hold state on an object.
Confusing nonlocal and global Enclosing function vs module.
Shadowing builtins list = [...] breaks list() far away from the cause.
Expecting the loop variable to leak from a comprehension It has its own scope in Python 3.
Comprehension over class attributes Class scope is not visible inside it.
Mutable state in a closure across threads Cells are shared — the same race as any shared object.
Not knowing decorators are closures It is the same mechanism (Day 044).

Interview questions

Q: What does this print, and why?

fs = [lambda: i for i in range(3)]
print([f() for f in fs])

[2, 2, 2]. All three lambdas close over the same variable, not over its value at the moment they were created, so when they're finally called the loop has finished and i is 2. Calling them inside the loop would give 0, 1, 2 — the timing is the whole bug. The fixes are a default argument, lambda i=i: i, which binds at definition time, or a factory function so each closure gets its own scope. It matters in real code whenever you register callbacks in a loop, because callbacks are by definition called later.

Q: Explain UnboundLocalError.

If a name is assigned anywhere in a function body, the compiler marks it local for the entire body — that's a compile-time decision, visible in the bytecode as LOAD_FAST rather than LOAD_GLOBAL. So reading it before the assignment reads an unbound local, not the global. count += 1 triggers it too, since augmented assignment reads before it writes. global or nonlocal declares the intent — though wanting global usually means the state belongs somewhere else.

Q: global vs nonlocal?

global binds to the module namespace and will create the name if it doesn't exist. nonlocal binds to the nearest enclosing function scope and is a compile-time error if no such binding exists, which makes it much safer — you can't accidentally create state. Neither reaches class scope, which is why comprehensions in a class body can't see class attributes.

Q: What is a closure, mechanically?

A function plus references to variables from an enclosing scope. CPython stores those variables in cell objects on the heap and the inner function holds references to the cells in __closure__, so the variable outlives the call that created it and the closure observes live updates rather than a snapshot. That's exactly why the loop-variable trap exists — and it's also the machinery behind decorators, which are closures over the function being decorated.

Q: When would you use a closure rather than a class?

When there's one operation and a small amount of configuration or state — a configured predicate, a retry wrapper, a memo cache that belongs to one function. A class earns its place when there are several related operations, when the state needs to be inspected or named, or when it needs to be serialised or subclassed. The saying that a closure is a poor man's object and vice versa is accurate: they're the same idea with different ergonomics.


Mini task

  1. Write the [lambda: i for i in range(3)] bug. Print the result. Then fix it three ways.
  2. Call the lambdas inside the loop and show you get 0, 1, 2. Say out loud why.
  3. Reproduce UnboundLocalError, then dis the function and find LOAD_FAST.
  4. Write counter() with nonlocal. Then inspect c.__closure__[0].cell_contents after each call.
  5. Remove nonlocal and read the error.
  6. Shadow list in a module and call list() from another function.
  7. Write a comprehension in a class body that references a class attribute. Read the NameError and explain it.
  8. Write a memoising decorator whose cache lives in the closure. Prove two decorated functions have separate caches.
  9. Register three callbacks in a loop that fire later, hit the bug for real, then fix it.

Exit questions

Answer aloud, no notes.

  1. What does LEGB stand for, in order?
  2. What makes a name local, and when is that decided?
  3. Why does count += 1 cause UnboundLocalError on a global?
  4. global vs nonlocal — scope reached, and which fails safely?
  5. What is a closure, in terms of cells?
  6. Why does [lambda: i for i in range(3)] print [2,2,2]?
  7. Give three fixes and say what each one actually changes.
  8. Where does this bite in real code? Name three places.
  9. Does a comprehension leak its variable in Python 3?
  10. Why can't a comprehension in a class body see class attributes?
  11. Give three legitimate uses of closures.
  12. When would you choose a class instead?

Articulation drill

Record two minutes: "Why do all my loop-created callbacks behave identically?"

Diagnose it in one sentence: the closures captured the variable, not its value, and by the time they run the loop has finished. Then show that calling them inside the loop gives the expected answer, which proves the diagnosis rather than asserting it.

Then the fixes, and what each one actually does: lambda i=i: i works because default arguments are evaluated at definition time — the same mechanism as Day 025's famous bug, used deliberately this time. A factory function works because each call creates a fresh scope with its own cell.

Then close with why it is worth knowing rather than looking up: "this isn't a Python quirk — it's how closures work in JavaScript, in Go before 1.22, in C#. It follows from names being bindings rather than boxes, which is the same fact behind aliasing and the mutable default. One model, three famous bugs."


Previous: Day 029 · Tomorrow: Day 031 — the data model: dunder methods as the entire language's interface