Scope, LEGB &
Closures in Python
Where does a variable live, and for how long? Master Python's four scope levels, the LEGB lookup rule, the global and nonlocal keywords, and how closures let inner functions carry their enclosing environment with them. Every example is runnable in your browser.
def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter = make_counter() print(counter()) print(counter()) print(counter())
1
2
3
π Introduction β Why Scope Matters
Understanding where names live is the difference between a bug and a feature
Every name in a Python program β every variable, function, class, and module β is stored in a namespace. A scope is the region of code where a particular namespace is accessible. When Python looks up a name, it follows a precise, layered search path. Getting this wrong is the source of some of the most confusing bugs beginners encounter: why does assigning to a variable inside a function not update the outer variable? Why does UnboundLocalError happen when the variable clearly exists at module level?
This lesson answers those questions by building the mental model from the ground up. By the end, you'll understand:
- βThe four scope levels: Local, Enclosing, Global, Built-in
- βThe LEGB name resolution order β and why it always wins
- βWhen to use
globalandnonlocalβ and when not to - βWhy
UnboundLocalErrorhappens and how to avoid it - βWhat a closure is and how to use it for state, factories, and decorators
The core mental model
Think of scope as a series of nested rooms. The innermost room is the function you're currently executing. If a name isn't there, Python walks outward β one room at a time β until it reaches the building's global namespace and finally the built-in namespace. If the name isn't found anywhere, you get a NameError. But if you assign to a name, Python decides at compile time which room it lives in β and this is where most surprises come from.
π The four scope levels at a glance
π The LEGB Rule β Python's Name Resolution Order
One rule that explains every lookup
When Python evaluates a name (like total or len), it searches four namespaces in this order:
Local (L) β the namespace of the current function (or the module if you're at module level). This is the first place Python looks.
Enclosing (E) β the namespaces of any enclosing functions. Only matters when you have nested functions.
Global (G) β the namespace of the module. All names defined at the top level of the file live here.
Built-in (B) β the namespace of Python's built-in functions and exceptions. The last resort.
π Example β all four levels in action
message = "global" def outer(): message = "enclosing" def inner(): message = "local" print(message) inner() outer() print(message) print(len("abc"))
message is defined at module level β global scope.Lines 4, 7 β inside
outer, a new message is defined β that's the enclosing scope from inner's perspective.Line 8 β inside
inner, another new message is defined β that's the local scope. When print(message) runs on line 9, Python finds the local one first.Line 14 β after
outer() returns, the local and enclosing names are gone. print(message) at module level prints the global "global".Line 15 β
len is not defined anywhere in the file β Python finds it in the built-in namespace.
π§ What happens when a name is not found?
If Python searches all four levels and still can't find the name, it raises NameError: name 'x' is not defined. This is the only case in which the search path fails β and it means the name was never bound anywhere accessible.
def try_lookup(): print(undefined_name) try: try_lookup() except NameError as e: print(f"NameError: {e}")
undefined_name is looked up first in local, then enclosing, then global, then built-in. It's not found anywhere, so a NameError is raised.Why this order matters β if you had defined
undefined_name at module level, this would print that value instead. The error only occurs because all four searches failed.
π¦ Local Scope β Variables Inside a Function
Created on call, destroyed on return
Every function call creates a fresh local namespace. Any variable assigned inside the function is local by default β it exists only while the function is running, and disappears the moment the function returns. Different calls to the same function get completely separate local namespaces.
π Example β local scope is per-call
def process(value): result = value * 2 print(f"Inside process: result = {result}") return result process(5) process(10) try: print(result) except NameError as e: print(f"Outside process: NameError: {e}")
result is created in the local namespace of this process call.Line 3 β
print accesses it β the local search succeeds.Lines 7β8 β each call gets its own
result. process(5) produces 10, process(10) produces 20. They don't interfere.Lines 10β13 β after
process returns, its local namespace is gone. result is undefined at module level, so a NameError is raised.
π Parameters are also local
Function parameters live in the same local namespace as variables you assign inside the body. A parameter behaves exactly like a local variable that has already been assigned for you.
def show_param(name): print(f"Inside: name = {name}") show_param("Ana") try: print(name) except NameError: print("Outside: name is not defined")
name is a parameter, so it's automatically bound in the local namespace.Line 7 β outside the function,
name doesn't exist. The local namespace was destroyed when show_param returned.
β οΈ Assignment inside a function makes a name local
This is the most important rule in the entire lesson: if a name is assigned to anywhere in a function, Python treats it as local for the entire function β including lines that appear before the assignment.
count = 0 def increment_buggy(): print(count) count += 1 try: increment_buggy() except UnboundLocalError as e: print(f"UnboundLocalError: {e}")
count += 1 is an assignment to count. Python decides at compile time that count is local to increment_buggy for the entire function body.Line 3 β when
print(count) runs, Python tries to look up a local count that has not yet been assigned. Result: UnboundLocalError.The global
count is invisible here β even though it exists, Python's compile-time decision means the function will never look for it at module level.
UnboundLocalError, the cause is almost always: "you assigned to this name later in the function, so Python made it local, and you read it before the assignment." Fix it by either passing the value in, using global, or restructuring the code.π Global Scope & the global Keyword
Module-level names β and how to modify them from inside a function
Names defined at the top level of a module live in the global namespace. Any function can read them without declaring anything β the LEGB lookup will find them. But to assign to a global name from inside a function, you must declare it with global. Otherwise Python creates a local variable that shadows the global, and the outer one is left untouched.
π Example β reading vs. modifying a global
counter = 0 def read_counter(): print(f"Read counter: {counter}") def modify_counter_wrong(): counter = 100 print(f"Inside wrong: {counter}") def modify_counter_right(): global counter counter = 100 print(f"Inside right: {counter}") read_counter() modify_counter_wrong() read_counter() modify_counter_right() read_counter()
counter = 100 creates a local variable named counter. The global one is unchanged.Lines 9β12 β
global counter tells Python: "assignments to counter in this function refer to the global one." The change survives after the function returns.Lines 14β18 β after
modify_counter_wrong(), read_counter() still prints 0. After modify_counter_right(), it prints 100.
global should be rare. If you find yourself reaching for it often, consider passing the value in as a parameter and returning the new value instead β that makes the data flow explicit and the function easier to test. The main legitimate uses are configuration flags, singleton-like state, and module-level counters.β οΈ global must be declared before any use
The global statement must appear at the top of the function β before the name is assigned or even read. Python enforces this at compile time.
value = 10 def bad_order(): print(value) global value try: bad_order() except SyntaxError as e: print(f"SyntaxError: {e}")
global value comes after the use of value on line 4. Python raises SyntaxError: name 'value' is used prior to global declaration.The fix β move the
global declaration to the first line of the function body.
π§ A cleaner alternative β return the new value
β Mutating a global
global countcount += 1
The change is invisible to callers and hard to test.
β Pure function
def increment(n): return n + 1
Caller decides where to store the result.
Pure functions β those that take inputs and return outputs without touching global state β are easier to test, reason about, and reuse. Prefer them whenever you can.
π Enclosing Scope & the nonlocal Keyword
Nested functions, and how to modify a variable from the enclosing function
When you define a function inside another function, the inner function can read variables from the outer function's scope β that's the E in LEGB. But to assign to an enclosing variable, you must declare it with nonlocal. Without it, the inner assignment creates a new local variable in the inner function.
π Example β reading an enclosing variable works by default
def outer(): greeting = "Hello" def inner(): print(f"{greeting}, world!") inner() outer()
greeting is local to outer, but it's also enclosing from inner's perspective.Line 5 β
inner reads greeting. Python searches local β enclosing β global. It finds the enclosing one and prints Hello, world!.No
nonlocal needed β reading is always allowed. The keyword is only required for assignment.
π Example β assigning an enclosing variable needs nonlocal
def outer(): count = 0 def increment_wrong(): count = 100 print(f"Inside wrong: {count}") def increment_right(): nonlocal count count = 100 print(f"Inside right: {count}") increment_wrong() print(f"After wrong: {count}") increment_right() print(f"After right: {count}") outer()
count = 100 creates a new local variable inside increment_wrong. The enclosing count is untouched β still 0.Line 10 β
nonlocal count says: "the assignment to count in this function modifies the enclosing one, not a new local." The change persists after increment_right returns.Line 16 β after the "right" version runs, the enclosing
count is now 100. The "wrong" version had no effect on it.
π nonlocal vs. global
| Keyword | What it targets | When to use |
|---|---|---|
global | The module-level namespace | Modifying module-level state from inside a function |
nonlocal | The nearest enclosing function's namespace | Modifying a variable from an outer function inside a nested function |
nonlocal was added in Python 3. It doesn't exist in Python 2. If you see a Python 2 codebase, it uses mutable containers (like a one-element list) as a workaround β a pattern you should not copy in modern Python.π Closures β Functions That Remember Their Environment
A function that carries its enclosing scope with it
A closure is a nested function that remembers the variables from its enclosing scope β even after the outer function has returned. The inner function doesn't just hold a reference to the outer function's namespace; it keeps it alive. This is the mechanism behind decorators, partial application, and many factory patterns.
π Example β the classic counter
def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter_a = make_counter() counter_b = make_counter() print(counter_a()) print(counter_a()) print(counter_b()) print(counter_a())
count = 0 creates a local variable in make_counter's namespace. Ordinarily this would be destroyed when make_counter returns.Lines 4β7 β
increment closes over count. Because it references count via nonlocal, Python keeps the make_counter namespace alive as long as increment exists.Line 9 β returning
increment hands back a function object that carries the enclosing namespace with it.Lines 11β12 β each call to
make_counter() creates a fresh count and a fresh increment closure. counter_a and counter_b are independent.Lines 14β17 β
counter_a counts independently from counter_b. The output is 1, 2, 1, 3 β proving that each closure has its own state.
increment.__closure__ β it returns a tuple of cells, each holding the value of a captured variable. And increment.__code__.co_freevars lists the names of captured variables. This is how Python represents "the function remembers its environment."π Example β a closure factory
Closures are great for generating specialised functions from a template. Here's a factory that creates multiplier functions.
def make_multiplier(factor): def multiply(number): return number * factor return multiply double = make_multiplier(2) triple = make_multiplier(3) ten_times = make_multiplier(10) print(double(7)) print(triple(7)) print(ten_times(7))
make_multiplier takes a factor and returns a new function.Line 3 β
multiply references factor from the enclosing scope. This is the closure.Lines 7β9 β each call to
make_multiplier creates a different closure with a different factor.Lines 11β13 β each specialised function multiplies its input by the factor it remembers: 14, 21, 70.
This pattern is the foundation of partial application β creating a new function by fixing some arguments of an existing one. It's also exactly how decorators are built.
π― Closures in the wild β decorators
A decorator is a closure that wraps a function. The wrapper closes over the original function, so it can call it later with whatever arguments it receives.
import functools def logger(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") result = func(*args, **kwargs) print(f"{func.__name__} returned {result}") return result return wrapper @logger def add(a, b): return a + b print(add(3, 4))
func is the original function. wrapper closes over it.Lines 5β9 β
wrapper calls func(*args, **kwargs) using the arguments it received. This is the closure in action β func is remembered even though the decorator has returned.Line 14 β
@logger is equivalent to add = logger(add). The name add is now bound to the wrapper closure.Line 15 β calling
add(3, 4) runs wrapper, which calls the original add and prints before and after.
β οΈ The late-binding closure trap
Closures capture variables, not values. If you create multiple closures in a loop, they all share the same variable β which ends up holding the last value.
functions = [] for i in range(3): def make_function(): print(f"i = {i}") functions.append(make_function) for f in functions: f() print("--- Fix ---") def make_fixed(n): def inner(): print(f"n = {n}") return inner fixed = [make_fixed(i) for i in range(3)] for f in fixed: f()
make_function closure. But all three closures capture the same variable i β the loop variable β not the value at that iteration.Lines 8β9 β when you call the closures, they all print
i = 2 because i finished at 2. This is "late binding."Lines 13β21 β the fix: pass the loop value into a factory function. Each call to
make_fixed(i) creates a fresh n parameter, and each inner closure captures that distinct n. Now they print 0, 1, 2 as expected.
def inner(n=i):).β οΈ UnboundLocalError β The Most Common Scope Bug
Why it happens, and three ways to fix it
UnboundLocalError is a subclass of NameError that specifically means: "you tried to use a local variable before it was assigned." It's the single most confusing scope error for beginners because the variable does exist at module level β but Python isn't looking there.
π Why it happens
Python compiles each function once, before running it. During compilation, it decides which names are local based on whether they appear as assignment targets anywhere in the function body. If count is assigned on line 5, then count is local throughout β even on line 3 where it's read before the assignment.
total = 100 def buggy(): print(total) total = total + 1 try: buggy() except UnboundLocalError as e: print(f"UnboundLocalError: {e}")
total = total + 1 and marks total as local to buggy.Line 3 β reading
total fails because the local total hasn't been assigned yet. The global total is never considered.The error message β
cannot access local variable 'total' where it is not associated with a value.
β Three fixes
Fix 1 β Pass it in
def buggy(total): return total + 1
The cleanest option β the function becomes a pure function.
Fix 2 β Declare global
def buggy(): global total total = total + 1
Use only if you genuinely need to mutate module state.
Fix 3 β Use a new local
def buggy(): new_total = total + 1 return new_total
If you don't need to modify the original.
π§ The Python 3.10+ error message is helpful
Since Python 3.10, the error message for this situation includes a hint about the likely cause:
print("cannot access local variable 'total' where it is not associated with a value") print(" β 'total' is assigned somewhere later in this function.") print(" β Python therefore treats 'total' as local throughout the function.") print(" β The global 'total' is never consulted.")
global if the mutation is intentional.π Quick-Reference Cheat Sheet
Every scope rule at a glance β bookmark this page
β The LEGB lookup order
π When a name is looked up vs. when it is created
| Action | What happens | Example |
|---|---|---|
| Read a name | Searched L β E β G β B. If found anywhere, that value is used. | print(x) |
| Assign to a name | Creates (or rebinds) the name in the current local scope β unless global or nonlocal is declared. | x = 10 |
Assign with global | Binds the name in the module namespace. | global x; x = 10 |
Assign with nonlocal | Binds the name in the nearest enclosing function's namespace. | nonlocal x; x = 10 |
| Use a name that is local but unassigned | Raises UnboundLocalError. | print(x); x = 1 |
π The three rules to remember
Reading follows LEGB. Python always searches local first, then enclosing, then global, then built-in. It stops at the first match.
Assignment creates a local. If you assign to a name inside a function, that name is local for the entire function β even on lines before the assignment. To reach an outer scope, declare global or nonlocal.
Closures capture names, not values. If you need to capture a specific value at closure-creation time, pass it in as a parameter (or a default argument).
What to remember from this lesson
1. Scope levels: Local, Enclosing, Global, Built-in β in that order.
2. global modifies the module-level name; nonlocal modifies the nearest enclosing function's name.
3. Assigning to a name inside a function makes it local for that function β this is why UnboundLocalError happens.
4. A closure is a nested function that carries its enclosing scope with it. It's the mechanism behind decorators and function factories.
5. Closures capture names, not values β the late-binding trap is real.