Functions & Scope

How Python resolves names and how functions capture their environment

Why

Functions are first-class citizens in Python — they can be passed as arguments, returned from other functions, and stored in variables. This makes closures and higher-order functions natural, but also introduces scope bugs that are hard to debug if you don't know the rules.

The late-binding closure trap is a well-known gotcha — it looks wrong to anyone who doesn't understand how Python looks up free variables at call time rather than definition time.

Intuition

When Python sees a variable name, it searches four scopes in order: Local → Enclosing → Global → Built-in (LEGB). It finds the first match and stops. This lookup happens at runtime, not when the function is defined.

A closure remembers the enclosing scope it was created in — but it remembers the variable (the label), not the value at creation time. If the enclosing variable later changes, the closure sees the new value.

Explanation
1. LEGB scope rule

When Python sees a variable name, it searches four scopes in order: Local → Enclosing → Global → Built-in. The first match wins — this lookup happens at runtime, not at definition time.

x = 'global' def outer(): x = 'enclosing' def inner(): x = 'local' print(x) # local — found in L inner() print(x) # enclosing — found in E outer() print(x) # global — found in G # nonlocal reaches into the enclosing scope to mutate it def counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment
2. *args and **kwargs

*args and **kwargs let a function accept any number of positional and keyword arguments — essential for wrappers and forwarding calls without listing every parameter.

def log(level, *args, **kwargs): # args → tuple of extra positional arguments # kwargs → dict of extra keyword arguments print(f"[{level}]", *args, **kwargs) log("INFO", "starting", "pipeline", sep=" | ") # Unpacking at call site vals = [1, 2, 3] opts = {"z": 3} process(*vals[:2], **opts) # process(1, 2, z=3)

*args collects extra positional arguments as a tuple; **kwargs collects keyword arguments as a dict. Use *iterable and **mapping at the call site to unpack them back out.

3. The late-binding closure trap

Closures capture the variable reference, not its value at creation time. In loops, this causes a classic bug where all closures read the final loop value instead of the one at the time they were created.

# BUG — all closures reference the same variable i fns = [lambda: i for i in range(3)] print([f() for f in fns]) # [2, 2, 2] — NOT [0, 1, 2] # loop runs: # i=0 → lambda: i (doesn't store 0, just remembers "i") # i=1 → lambda: i (doesn't store 1, just remembers "i") # i=2 → lambda: i (doesn't store 2, just remembers "i") # loop ends — i is now 2 fns[0]() # goes and checks i → 2 fns[1]() # goes and checks i → 2 fns[2]() # goes and checks i → 2 # FIX — capture the current value as a default argument fns = [lambda i=i: i for i in range(3)] print([f() for f in fns]) # [0, 1, 2]

Closures capture the variable, not its value. The default-argument trick captures the current value at definition time because default arguments are evaluated immediately.