Closures and Scope — LEGB and the Strange Behavior of Inner Functions
~22 min · closure, scope, LEGB, nonlocal, global
Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
The LEGB rule — where Python looks for a name
When you reference a name like x, Python looks in (in order): Local scope, Enclosing function scopes, the Global (module) scope, and finally Built-ins (print, len, etc.). The first place it finds the name wins. This is why a name defined in a function shadows a same-named name at module level — Python finds the local one first.
Closures — functions that remember their birthplace
An inner function can refer to names from the enclosing function. When you return that inner function, it carries those bindings with it — that's a closure. The inner function holds a reference to the enclosing function's variables, even after the enclosing function has returned. This is how decorators work, how factory functions work, and how callbacks remember context.
Reading vs. writing — the asymmetry
Inner functions can read enclosing variables freely. Writing to them requires nonlocal. Writing to module-level variables requires global. Without these declarations, an assignment creates a new local binding that shadows the outer name. This single rule trips up almost every beginner who tries to mutate a counter from inside a closure.
Warning: The classic late-binding closure trap — fns = [lambda: i for i in range(5)]. Every lambda refers to the same i, which is 4 at the end. Capturing the current value requires lambda i=i: i (default arg captures at definition time). This bites everyone once.
When to reach for closures vs. classes
If you need a function that carries some state, a closure is light and idiomatic. If you need multiple methods sharing state, a class is clearer. The blurry middle is one of the perennial Python design questions — and there's no rule that fits every case.
Code
LEGB in action·python
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # finds local FIRST
inner()
outer() # local
# If we don't define x in inner, Python walks outward
def outer2():
x = "enclosing"
def inner():
print(x) # finds enclosing
inner()
outer2() # enclosing
# If neither defines x, Python finds the global
def outer3():
def inner():
print(x) # finds global
inner()
outer3() # global
Closure factories — the canonical pattern·python
def make_counter(start=0):
count = start
def increment():
nonlocal count
count += 1
return count
return increment
c1 = make_counter()
c2 = make_counter(100)
print(c1()) # 1
print(c1()) # 2
print(c1()) # 3
print(c2()) # 101
print(c2()) # 102
# c1 and c2 each have their own `count` — closures are independent
Without nonlocal, you create a new local·python
def outer():
n = 10
def inner():
n = 99 # creates a NEW local, doesn't touch outer's n
print("inner sees:", n)
inner()
print("outer still:", n)
outer()
# inner sees: 99
# outer still: 10
# With nonlocal
def outer2():
n = 10
def inner():
nonlocal n
n = 99 # MUTATES outer's n
inner()
print("outer now:", n)
outer2() # outer now: 99
The late-binding closure trap·python
# THE TRAP
fns = []
for i in range(5):
fns.append(lambda: i)
print([f() for f in fns]) # [4, 4, 4, 4, 4] <- all the same
# Why: each lambda refers to the SAME `i`, which is 4 when the loop ends.
# FIX 1 — default argument captures the value at DEFINITION time
fns = []
for i in range(5):
fns.append(lambda i=i: i)
print([f() for f in fns]) # [0, 1, 2, 3, 4]
# FIX 2 — closure factory
def make(i):
return lambda: i
fns = [make(i) for i in range(5)]
print([f() for f in fns]) # [0, 1, 2, 3, 4]
global — only when you really must·python
counter = 0
def bump():
global counter
counter += 1
bump()
bump()
bump()
print(counter) # 3
# But — using `global` is usually a sign you should use a class,
# a closure, or pass the value explicitly. Reach for it sparingly.
Write a function make_accumulator() that returns a function. Each time the returned function is called with a number, it adds that number to its running total and returns the new total. Each accumulator created by make_accumulator should have its own independent total. Demonstrate by creating two accumulators, calling each multiple times, and showing they don't interfere. (Use a closure with nonlocal. Don't use a class.)
Progress
Progress is local-only — sign in to sync across devices.