Python

Closures and scope — the LEGB rule

How Python actually decides which variable a name refers to, and why a function can keep remembering a value from a scope that's already finished running.

Advanced

3 min read

The rule Python actually follows

Every name lookup in Python resolves in a fixed order: Local → Enclosing → Global → Built-in — LEGB. Python checks the innermost scope first and works outward, stopping at the first match:

x = "global"
 
def outer():
    x = "enclosing"
 
    def inner():
        x = "local"
        print(x)   # "local" — found immediately, no need to look further out
 
    inner()
 
outer()

If inner didn't define its own x, the lookup would keep walking outward — checking outer's scope (Enclosing), then module scope (Global), then Python's built-ins (Built-in) — using the first one it finds. This is a static, read-time decision based on where a name is assigned in the source code, not a dynamic search of "whatever's currently in memory."

Why assignment inside a function is special

count = 0
 
def increment():
    count += 1   # UnboundLocalError, not "count is 0 + 1"

Assigning to a name anywhere inside a function — even on a line that hasn't executed yet — makes Python treat that name as local to the entire function body. count += 1 is really count = count + 1, and the assignment makes count local; but then the right-hand side tries to read count before it's been assigned locally, raising UnboundLocalError. This trips people up constantly: the bug isn't "count doesn't exist," it's "Python already decided this name is local, before your code ran."

The fix, if you actually mean the outer variable: global count (module scope) or nonlocal count (enclosing function scope) — an explicit declaration that tells Python "don't shadow this name, use the outer one."

A closure: a function that remembers its enclosing scope

def make_multiplier(factor):
    def multiplier(x):
        return x * factor   # factor comes from the enclosing scope
    return multiplier
 
double = make_multiplier(2)
triple = make_multiplier(3)
 
double(5)   # 10
triple(5)   # 15

make_multiplier returns and its stack frame is gone — normally, local variables die when a function returns. But multiplier still works, and double and triple behave differently, because each multiplier closed over its own factor. Python keeps the enclosing variables a nested function actually references alive for as long as that function itself is reachable. This is what "closure" means: the function plus the specific enclosing variables it captured, bundled together.

The classic closure bug: capturing a loop variable

funcs = [lambda: i for i in range(3)]
[f() for f in funcs]   # [2, 2, 2] — not [0, 1, 2]

All three lambdas close over the same variable i, not its value at creation time — closures capture variables, not snapshots. By the time any lambda is called, the loop has finished and i is 2, so all three see the same final value. The fix is forcing evaluation at creation time, most simply with a default argument (which is evaluated once, immediately — the same mechanism behind the mutable-default-argument bug):

funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs]   # [0, 1, 2] — each lambda got its own i

Where this is actually useful, not just a gotcha

Decorators are closures — the wrapper function returned by a decorator closes over func from the enclosing scope, which is exactly how it can still call the original function on every invocation, long after the decorator itself finished running. Any "factory function that returns a customized function" pattern relies on the same mechanism: the returned function keeps private, per-instance state without needing a class at all.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What order does Python use to resolve a variable name, per the LEGB rule?

2. Why does `count += 1` inside a function raise UnboundLocalError if `count` is only defined at module level?

3. In `funcs = [lambda: i for i in range(3)]`, why do all three lambdas return 2 when called, instead of 0, 1, 2?

4. Why are decorators fundamentally built on closures?