Skip to content
C.W.K.
Stream
Lesson 05 of 07 · published

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.

A closure is not automatically a tiny class. When one operation owns the state and nothing else needs to inspect, reset, or serialize it, the closure is the clearer boundary. Once several behaviors share a persistent identity, promoting that state to an object becomes honest. In Dad's terms, the closure remains the right shape while the one strategy being encapsulated matters more than object identity.

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.

External links

Exercise

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.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.