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.

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.