Updated for Python 3.12+ Β· Intermediate Β· 8 lessons

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.

Intermediate LEGB local global nonlocal closures
closure_demo.py
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = make_counter()
print(counter())
print(counter())
print(counter())
$ python closure_demo.py
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 global and nonlocal β€” and when not to
  • βœ“Why UnboundLocalError happens 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

Built-in (B) ← names Python provides automatically (print, len, sum, …) Global (G) ← names defined at module level Enclosing(E) ← names in an outer function (from a nested function's perspective) Local (L) ← names defined inside the currently executing function Lookup order: L β†’ E β†’ G β†’ B

πŸ” 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"))
Line 1 β€” 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}")
Line 2 β€” 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.
πŸ’‘ The LEGB rule applies to reads, not writes. Assigning to a name inside a function creates a local variable β€” it does not modify the enclosing or global name. That's the entire subject of the next section.

πŸ“¦ 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}")
Line 2 β€” 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")
Line 1 β€” 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}")
Line 4 β€” the line 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.
πŸ’‘ This is the single most surprising scope rule in Python. If you see 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()
Line 6 β€” 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.
πŸ’‘ The common saying is right: 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}")
Line 5 β€” 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 count
count += 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()
Line 2 β€” 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()
Line 5 β€” 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

KeywordWhat it targetsWhen to use
globalThe module-level namespaceModifying module-level state from inside a function
nonlocalThe nearest enclosing function's namespaceModifying 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())
Line 2 β€” 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.
πŸ’‘ You can inspect a closure with 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))
Line 1 β€” 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))
Line 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.
πŸ’‘ Without closures, decorators would not work. The entire mechanism depends on the wrapper remembering the original function after the decorator's outer scope has returned. This is the most important practical use of closures in everyday Python.

⚠️ 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()
Lines 3–6 β€” each iteration creates a new 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.
πŸ’‘ The rule is: closures capture names, not values. To capture a value at closure-creation time, pass it in as a parameter (or use a default argument: 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}")
Line 4 β€” Python sees 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.")
πŸ’‘ When you see this error, the first thing to check is: "does my function assign to this name anywhere?" If yes, the name is local. The fix is almost always to pass the value in, or to use global if the mutation is intentional.

πŸ“‹ Quick-Reference Cheat Sheet

Every scope rule at a glance β€” bookmark this page

βœ… The LEGB lookup order

L β†’ E β†’ G β†’ B β”‚ β”‚ β”‚ β”‚ Local Enclosing Global Built-in (current (outer (module (Python's function) functions) level) builtins)

πŸ”„ When a name is looked up vs. when it is created

ActionWhat happensExample
Read a nameSearched L β†’ E β†’ G β†’ B. If found anywhere, that value is used.print(x)
Assign to a nameCreates (or rebinds) the name in the current local scope β€” unless global or nonlocal is declared.x = 10
Assign with globalBinds the name in the module namespace.global x; x = 10
Assign with nonlocalBinds the name in the nearest enclosing function's namespace.nonlocal x; x = 10
Use a name that is local but unassignedRaises 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.

Back to Home Full Curriculum