Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

Closures and Scope — The LEGB Rule

How Python resolves variable names, what a closure actually captures, and the scoping bugs that confuse everyone once.

35 min August 2026
// Part 01 — The LEGB Rule

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.

LEGB, in search order
# 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.

// Part 02 — What a Closure Actually Is

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.

A closure in action
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=3

This 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

Closures are inspectable
print(double.__closure__[0].cell_contents)   # 2
print(triple.__closure__[0].cell_contents)   # 3
// Part 03 — Captured by Reference, Not by Value

The 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.

The bug — every closure ends up sharing the SAME final value of i
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 finished

This 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.

The fix — force capture of the CURRENT value via a default argument
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 now
🎯 Pro Tip
Default argument values are evaluated once, at function-definition time — not at call time. This is precisely why lambda 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.
// Part 04 — nonlocal

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.

Assignment creates a NEW local variable by default
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 value

Python 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.

nonlocal — explicitly tells Python to modify the ENCLOSING variable
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 calls
// Part 05 — global

The Module-Level Equivalent, and Why It Is Usually a Smell

global — the same idea, one level further out
counter = 0

def increment():
    global counter
    counter += 1

increment()
increment()
print(counter)   # 2
⚠️ Important
Reaching for global 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.
// Part 06 — Real World
💼 What This Looks Like at Work

A Broken Batch of Button Handlers at a Detroit Robotics Startup

Scenario — Robotics startup, Detroit · Dashboard UI bug

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.

The bug — the classic loop-closure trap, in a realistic setting
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 variable
The fix — force each callback to capture its own value
callbacks = {}
for actuator_id in actuator_ids:
    callbacks[actuator_id] = lambda aid=actuator_id: send_command(aid)

callbacks[101]()   # correctly sends command for 101 now

Why 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.

// Part 07 — Misconceptions

Four Misconceptions About Closures and Scope

✕ ""A closure captures the VALUE a variable had at the time the inner function was created""
It captures a REFERENCE to the variable itself, not a snapshot of its value — which is exactly why the loop-variable-closure bug happens. All closures created inside the same loop iteration share the same underlying variable, which keeps changing until the loop ends.
✕ ""You can freely reassign an enclosing variable from a nested function without any special keyword""
Any assignment inside a function creates a new LOCAL variable by default, shadowing the enclosing one — attempting to both read and reassign an enclosing variable without nonlocal raises UnboundLocalError, since Python decides at compile time that the name is local to that function.
✕ ""global and nonlocal do the same thing""
nonlocal reaches into the nearest ENCLOSING function scope (for nested functions); global reaches all the way to the module-level scope, skipping any enclosing function scopes entirely. They are not interchangeable, and nonlocal has no effect at module level.
✕ ""LEGB scoping means Python checks the function you're currently in, then jumps straight to global""
For NESTED functions, there is a distinct Enclosing scope checked in between Local and Global — every level of function nesting the current function sits inside is checked, in order, before Global is ever consulted.
// Part 08 — Interview Prep

5 Interview Questions — With Complete Answers

What does LEGB stand for, and what problem does it solve?
Local, Enclosing, Global, Built-in — the fixed order Python searches when resolving a variable name, stopping at the first scope where the name is found. It explains exactly which variable a given name refers to whenever multiple scopes could plausibly define the same name.
What is a closure, and why does the returned inner function keep working after the outer function has already returned?
A closure is an inner function that references a variable from its enclosing scope, and Python keeps that enclosing variable alive (attached to the inner function itself, inspectable via __closure__) for as long as the inner function exists — even after the outer function's own execution has finished.
Explain the classic "closures created in a loop all return the same value" bug, and how to fix it.
Closures capture a reference to the loop variable, not its value at each iteration — since all closures share the same underlying variable, and that variable holds its FINAL value by the time any closure is actually called, they all appear to return the same (last) value. Fixed by capturing the current value explicitly via a default argument, e.g. lambda i=i: i, since default argument values are evaluated immediately at definition time.
Why does assigning to an enclosing variable inside a nested function raise UnboundLocalError without nonlocal?
Any assignment anywhere inside a function makes Python treat that name as local to that function throughout its entire body, at compile time — so a line like "count = count + 1" makes the READ on the right-hand side refer to the not-yet-assigned local "count", not the enclosing one, raising the error before assignment even happens.
What is the difference between nonlocal and global?
nonlocal binds a name to the nearest ENCLOSING function scope (skipping the module/global scope entirely) — it only makes sense inside nested functions. global binds a name directly to the module-level scope, regardless of any function nesting in between. Using global to mutate module state from inside a function is generally considered a design smell, since it hides side effects from anyone reading the call site.
// Common Mistakes

Closure & Scope Mistakes Beginners Make Constantly

Creating several closures in a loop, expecting each to capture that iteration's value
All of them share the same underlying loop variable, and its final value is what every closure sees once actually called — the exact bug shown in the Real World example above. Force per-iteration capture with a default argument.
Reassigning an enclosing variable inside a nested function without nonlocal
Raises UnboundLocalError, since the assignment silently makes Python treat the name as local throughout the whole nested function — including on the line reading its "current" value before ever assigning it.
Reaching for global as a first instinct for sharing state between functions
It works, but it makes side effects invisible at the call site and couples functions to hidden module-level state. Passing values as arguments/return values, or grouping related state and behaviour into a class, is almost always more maintainable.
Assuming a closure "copies" the captured variable rather than referencing it
It references the same underlying variable object — mutating that variable from any closure that shares it (or from the enclosing function itself, after the closure was created) is visible to every closure that captured it.
// Error Library

Errors You Will Hit With Closures & Scope — And Exactly Why

UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Cause: A nested function assigns to a name that also exists in an enclosing scope, without declaring nonlocal — Python treats the name as local to the whole function body, so reading it before the local assignment line fails.
Fix: Add "nonlocal count" (or "global count" at module level) at the top of the function before assigning to it, if the intent is really to modify the enclosing/global variable.
NameError: name 'x' is not defined
Cause: The name genuinely does not exist in any of the four LEGB scopes checked — Local, Enclosing, Global, or Built-in.
Fix: Check for a typo, or confirm the variable is actually assigned somewhere before this point in a scope that is actually reachable from here.
SyntaxError: no binding for nonlocal 'count' found
Cause: nonlocal was used in a function with no enclosing function scope at all (e.g. directly at module level, or in a function that is not nested inside another function defining that name).
Fix: Use "global" instead if the intended target is module-level, or confirm the function is actually nested inside the function that defines the variable.

🎯 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 re
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...