Module 1 · How Python really works

Scope, closures & LEGB

Intermediate 16 min read Where does Python find a name?

When Python sees a name like total, it has to decide which binding you mean. The rule is short: Local, Enclosing, Global, Built-in (LEGB). And the decision about whether a name is local is made when the function is compiled, not when it runs. That one fact explains UnboundLocalError, closures, and the lambda-in-a-loop surprise.

🏢

Looking for a colleague

You need "Priya". You check your own desk first (local), then your team's floor (enclosing function), then the company directory (module globals), and finally the public phone book (built-ins). You stop at the first match. If there is a Priya on your floor, the one in the company directory is never reached.

1. The LEGB lookup

B · Built-in len, print, int, ValueError … (module builtins) G · Global (module) names assigned at top level of the .py file E · Enclosing function(s) def outer(): … names of outer, seen by inner L · Local parameters + every name ASSIGNED anywhere in this function body
Lookup goes inside-out and stops at the first scope that has the name. Only functions (and classes, modules, comprehensions) create scopes; if, for and with blocks do not.
x = "global"

def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print("inner sees:", x)
    inner()
    print("outer sees:", x)

outer()
print("module sees:", x)
print(len("abc"))               # len is found in built-ins

for i in range(3):
    pass
print("loop variable after the loop:", i)   # for does not create a scope
inner sees: local outer sees: enclosing module sees: global 3 loop variable after the loop: 2

2. "Local" is decided at compile time

When Python compiles a function, it scans the whole body. Any name that is assigned anywhere in the function (with =, +=, for x in, import, def, and so on) becomes local for the entire function, including the lines above the assignment.

count = 0

def increment():
    count += 1          # an assignment, so count is local to the WHOLE function...
    return count        # ...and reading it before it has a value fails

increment()
Traceback (most recent call last): File "example.py", line 7, in <module> increment() ~~~~~~~~~^^ File "example.py", line 4, in increment count += 1 # an assignment, so count is local to the WHOLE function... ^^^^^ UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

count += 1 means count = count + 1, so the compiler marks count as local. At run time the right-hand side reads the local count, which has no value yet. The global count is never consulted, even though it exists.

3. global and nonlocal

count = 0

def increment():
    global count        # "count" in this function means the module-level name
    count += 1

increment(); increment()
print(count)

def make_counter():
    n = 0
    def step():
        nonlocal n      # "n" means the enclosing function's variable
        n += 1
        return n
    return step

c = make_counter()
print(c(), c(), c())
2 1 2 3
Prefer returning values to global

global makes a function depend on hidden module state, which makes it harder to test and unsafe to call from several threads. It is fine in small scripts. In libraries, pass values in and return results, or keep state on an object. nonlocal is the respectable cousin: the state lives in a closure that only its own functions can reach.

4. Closures: functions that remember

A closure is an inner function that uses names from an enclosing function, bundled with those variables so they survive after the outer function returns. Python stores them in cells.

def make_multiplier(factor):
    def multiply(x):
        return x * factor        # factor comes from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10), triple(10))

print(double.__code__.co_freevars)                      # names captured from outside
print([cell.cell_contents for cell in double.__closure__])
20 30 ('factor',) [2]
double function object __code__: multiply's bytecode __closure__: (cell,) cell a box holding one reference int 2 make_multiplier has returned and its frame is gone, but the cell keeps factor alive for as long as double exists.

5. The late-binding loop trap

Closures capture variables, not values. The cell is looked up when the inner function runs, so every function created in a loop sees the variable's final value.

handlers = [lambda: i for i in range(3)]
print([h() for h in handlers])            # all three share one i, which ended at 2

# Fix 1: freeze the value as a default argument (evaluated at definition time)
handlers = [lambda i=i: i for i in range(3)]
print([h() for h in handlers])

# Fix 2: functools.partial binds the value immediately
from functools import partial
def show(i):
    return i
handlers = [partial(show, i) for i in range(3)]
print([h() for h in handlers])
[2, 2, 2] [0, 1, 2] [0, 1, 2]
Where this bites in real code

Creating button callbacks in a loop, registering event handlers, building a list of tasks or threads with lambda. If every handler reports the last item, this is the cause.

6. Comprehensions have their own scope

x = "outer"
squares = [x * x for x in range(4)]
print(squares, x)          # the comprehension's x did not leak out

class Config:
    base = 10
    try:
        values = [base + n for n in range(3)]
    except NameError as e:
        error = str(e)
print(Config.error)
[0, 1, 4, 9] outer name 'base' is not defined

The second example is a genuine trap. A class body is a scope, but it is not an enclosing scope for functions or comprehensions defined inside it, so the comprehension cannot see base. (The loop source, range(3), is evaluated in the class scope, which is why that part works.) Inside methods, reach class attributes through self or the class name.

Recap

  • LEGB: Local → Enclosing → Global → Built-in. The first match wins.
  • Only functions, classes, modules and comprehensions create scopes. Loops and if blocks do not.
  • Assigning a name anywhere in a function makes it local for the whole function, which is the cause of UnboundLocalError.
  • global / nonlocal redirect assignment to an outer scope.
  • Closures capture variables in cells, looked up at call time. Freeze loop values with a default argument or partial.

Checkpoint

1 · Why does this raise UnboundLocalError? total = 5 / def f(): print(total); total = 1
Scope is decided at compile time from the whole function body. The later total = 1 makes every total in f refer to the local variable.
2 · fs = [lambda: n * 10 for n in range(3)]. What does [f() for f in fs] return?
All three lambdas close over the same variable n, which is 2 when they are called. Use lambda n=n: n * 10 to capture each value.