Closures and Scope — The LEGB Rule
How Python resolves variable names, what a closure actually captures, and the scoping bugs that confuse everyone once.
Four Places Python Looks for a Name, In Order
Every time Python encounters a variable name, it searches for it in a fixed order across four scopes, stopping at the first match — this search order is commonly remembered by the acronym LEGB.
# L — Local: names assigned inside the current function
# E — Enclosing: names in any enclosing function (for nested functions)
# G — Global: names assigned at the top level of the module
# B — Built-in: names Python provides automatically (len, print, str, ...)
x = "global x"
def outer():
x = "enclosing x"
def inner():
x = "local x"
print(x) # "local x" — found immediately in Local scope, search stops there
inner()
print(x) # "enclosing x" — Local scope for outer() has its own x
print(x) # "global x"If inner() did not assign its own x, Python would continue searching outward — checking Enclosing, then Global, then finally Built-in — raising a NameError only if none of the four scopes contain the name at all.
A Function That Remembers Its Enclosing Scope
A closure is what happens when an inner function references a variable from its enclosing function, and that inner function is then returned or passed elsewhere — Python keeps the enclosing variable alive and accessible, bound to that specific inner function, even after the outer function has already finished running.
def make_multiplier(factor):
def multiply(n):
return n * factor # "factor" is captured from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10 — remembers factor=2, even though make_multiplier already returned
print(triple(5)) # 15 — a COMPLETELY separate captured factor=3This should look immediately familiar — it is exactly the mechanism decorators are built on (the previous module), and it is why double and triple behave completely independently despite both being created by the same make_multiplier function: each call to make_multiplier creates a fresh, separate factor variable, and each returned multiply function closes over its own copy.
Inspecting what a closure actually captured
print(double.__closure__[0].cell_contents) # 2
print(triple.__closure__[0].cell_contents) # 3The Classic Loop-Variable-Closure Bug
A closure captures a reference to the enclosing variable, not a frozen snapshot of its value at the time the inner function was created. This produces one of the most common, genuinely surprising bugs in intermediate Python code.
functions = []
for i in range(3):
functions.append(lambda: i)
print([f() for f in functions])
# [2, 2, 2] <- probably not what you expected!
# All three lambdas share the SAME "i" variable, which is 2 by the time
# any of them are actually called, since the loop has already finishedThis is the exact same underlying mechanism as the comprehension-scoping behaviour touched on in the Comprehensions module, and it is a genuinely common source of confusion — the mental model "the lambda captured whatever i was at that moment in the loop" is simply wrong; it captured the variable itself, and that variable keeps changing until the loop ends.
functions = []
for i in range(3):
functions.append(lambda i=i: i) # i=i: default arguments are evaluated immediately, at DEFINITION time
print([f() for f in functions])
# [0, 1, 2] <- correct nowlambda i=i: i works: it evaluates the current, this-iteration's i immediately and stores it as the default, completely independent of whatever i becomes on later iterations. This same default-argument timing is also the root cause of the classic mutable-default-argument trap covered back in the Constructors module.Modifying (Not Just Reading) an Enclosing Variable
Reading an enclosing variable from a nested function works automatically, as shown throughout this module. Assigning to one does not — by default, any assignment inside a function creates a brand-new local variable, shadowing the enclosing one entirely, rather than modifying it.
def make_counter():
count = 0
def increment():
count = count + 1 # UnboundLocalError!
return count
return increment
counter = make_counter()
counter()
# UnboundLocalError: cannot access local variable 'count' where it is not associated with a valuePython sees the assignment count = count + 1 inside increment and decides, at compile time, that count is a local variable of increment — which means the read on the right-hand side ( count + 1) is now reading that not-yet-assigned local variable, not the enclosing one, raising the error before the assignment even happens.
def make_counter():
count = 0
def increment():
nonlocal count # "count" here refers to the enclosing scope's variable
count = count + 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2 — the enclosing "count" genuinely persists and increments across callsThe Module-Level Equivalent, and Why It Is Usually a Smell
counter = 0
def increment():
global counter
counter += 1
increment()
increment()
print(counter) # 2global to let a function mutate module-level state is usually a design smell, not just a syntax detail. It makes a function's behaviour depend on hidden external state and makes that same function's side effects invisible to anyone reading a call site — increment() gives no hint that it silently changes a module-level variable. Passing state explicitly as arguments and return values, or using a class to hold state (Object-Oriented Python phase) that methods explicitly operate on, is almost always the more maintainable design.A Broken Batch of Button Handlers at a Detroit Robotics Startup
An engineer builds a control panel that dynamically creates one callback function per actuator, in a loop, each meant to send that actuator's specific ID when triggered. Every single button ends up controlling the last actuator in the list, no matter which one is actually pressed.
callbacks = {}
for actuator_id in actuator_ids: # e.g. [101, 102, 103]
callbacks[actuator_id] = lambda: send_command(actuator_id)
# Later, when any button is pressed:
callbacks[101]() # sends command for 103! Every callback shares the SAME actuator_id variablecallbacks = {}
for actuator_id in actuator_ids:
callbacks[actuator_id] = lambda aid=actuator_id: send_command(aid)
callbacks[101]() # correctly sends command for 101 nowWhy this specific bug is so common in real production code
It shows up constantly in any code that builds a batch of similar callbacks in a loop — UI event handlers, per-item processing functions queued for later execution, or handler dictionaries exactly like this one. The bug is especially dangerous because it produces no error at all — the code runs, every button appears to work, and it is only the specific behaviour (always controlling the wrong actuator) that reveals something is wrong, often much later than the line that actually caused it.
Four Misconceptions About Closures and Scope
5 Interview Questions — With Complete Answers
Closure & Scope Mistakes Beginners Make Constantly
Errors You Will Hit With Closures & Scope — And Exactly Why
🎯 Key Takeaways
- ✓LEGB (Local, Enclosing, Global, Built-in) is the fixed order Python searches to resolve a variable name, stopping at the first match.
- ✓A closure is a nested function that references a variable from its enclosing scope — that variable stays alive and attached to the function even after the outer function has returned.
- ✓Closures capture a REFERENCE to the enclosing variable, not a snapshot of its value — closures created in a loop all share the same variable, which is why they often surprise beginners by all reflecting the loop's FINAL value.
- ✓Force per-iteration capture with a default argument (lambda i=i: i), since default argument values are evaluated once, immediately, at function-definition time.
- ✓Assigning to an enclosing variable inside a nested function requires nonlocal (or global for module-level) — without it, the assignment silently creates a new local variable, and reading it beforehand raises UnboundLocalError.
- ✓global is usually a design smell for sharing state — it hides side effects from anyone reading the call site. Prefer explicit arguments/return values, or a class holding the state.
What comes next
Module 32 begins the Advanced Python phase with regular expressions — pattern matching for text, and the syntax that looks intimidating but follows a small set of real rules.
Module 32 → Regular Expressions with reDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.