7.3 Variable Scopes, the LEGB Rule, and Closures

Key Takeaways

  • Python resolves identifier references sequentially using the LEGB rule: Local -> Enclosing -> Global -> Built-in.
  • Any assignment to an identifier within a function binds it to the local scope during compilation, raising an UnboundLocalError if referenced prior to assignment.
  • The global keyword rebinds module-level variables from local scope, while nonlocal rebinds identifiers in the nearest enclosing non-global function scope.
  • A closure occurs when an inner function retains access to free variables from its enclosing lexical scope even after the outer function has completed execution.
  • Closures capture variable names rather than value snapshots (late binding); freezing loop variables requires default parameter arguments (def f(x=i):).
Last updated: August 2026

Variable Scopes, the LEGB Rule, and Closures

In Python, the accessibility and lifetime of identifiers (variables, functions, classes) are governed by scoping rules and namespaces. When Python code references or modifies a variable, the interpreter uses a deterministic lookup hierarchy to locate the corresponding binding. Combining lexical scoping with Python's first-class functions gives rise to closures—a fundamental mechanism for data encapsulation and functional programming.


1. Namespace Hierarchy and the LEGB Resolution Rule

A scope is a textual region of a Python program where a namespace is directly accessible. When an identifier is referenced, Python searches up to four nested scopes in strict sequential order, known as the LEGB Rule:

Local (L)  ──▶  Enclosing (E)  ──▶  Global (G)  ──▶  Built-in (B)

The Four Scopes Defined

  1. Local (L): Identifiers defined inside the currently executing function, method, or lambda (including formal parameters and locally assigned variables). Local scopes are created upon function invocation and destroyed upon function exit.
  2. Enclosing (E): Identifiers in the local scope of any enclosing (outer) functions from inner to outer. This exists in nested function definitions.
  3. Global (G): Identifiers defined at the top level of the current module file, or identifiers explicitly declared with the global keyword inside a function.
  4. Built-in (B): Identifiers provided in Python's standard builtins module, loaded automatically upon interpreter startup (e.g., print, len, range, ValueError, int, open).
x = "GLOBAL"  # Global Scope (G)

def outer():
    x = "ENCLOSING"  # Enclosing Scope (E) relative to inner()
    
    def inner():
        x = "LOCAL"  # Local Scope (L)
        print(x)     # Resolves to LOCAL
    
    inner()
    print(x)         # Resolves to ENCLOSING

outer()
print(x)             # Resolves to GLOBAL

If an identifier is not found in any of the four LEGB scopes, Python raises a NameError.


2. Scope Mutation: The global and nonlocal Keywords

Reading a variable searches the LEGB hierarchy. However, assigning to a variable follows a completely different rule: by default, any assignment inside a function creates or rebinds a local variable in that function's namespace.

The Local Shadowing Trap and UnboundLocalError

When Python compiles a function, it scans the function body. If an identifier appears on the left-hand side of an assignment (=, +=, -=, etc.) anywhere in the function, Python marks that identifier as strictly local for the entire duration of the function.

counter = 0  # Global variable

def increment():
    # Attempting to read counter, then assign to it
    counter = counter + 1  # Raises UnboundLocalError!

# Calling increment() fails:
# UnboundLocalError: local variable 'counter' referenced before assignment

Because counter = ... is present, Python designates counter as local. When the right-hand side counter + 1 is evaluated, the local counter has not yet been assigned a value, triggering an UnboundLocalError.

The global Keyword

To rebind a module-level global variable from within a local function scope, declare the identifier using global:

counter = 0

def increment():
    global counter  # Instructs Python: bind 'counter' to the module global scope
    counter += 1

increment()
increment()
print(counter)  # 2 (Global variable successfully modified)

The nonlocal Keyword (Python 3)

The nonlocal statement causes the listed identifiers to refer to previously bound variables in the nearest enclosing function scope (excluding the global module scope):

def outer():
    count = 10
    
    def inner():
        nonlocal count  # Rebinds 'count' in outer()'s scope
        count += 5
        print("Inner count:", count)
    
    inner()  # Inner count: 15
    print("Outer count:", count)  # Outer count: 15

outer()

Critical nonlocal Rules for the PCAP Exam

  1. Enclosing Scope Requirement: The identifier specified in nonlocal x must exist in an outer function scope. If no matching identifier is found in any enclosing function scope, Python raises a compile-time SyntaxError: no binding for nonlocal 'x' found.
  2. No Global Binding: nonlocal cannot bind to module-level global variables. If a variable exists only in the global scope, nonlocal will fail with a SyntaxError.
global_val = 100

def bad_scope():
    # SyntaxError: no binding for nonlocal 'global_val' found
    nonlocal global_val
    global_val += 1

Scope Modification Comparison

KeywordTarget ScopeBehavior on Missing TargetApplicable In
(none)LocalCreates a new local variableFunctions, Lambdas
globalGlobal (Module-level)Creates a new global variable if not presentFunctions, Lambdas
nonlocalNearest Enclosing FunctionRaises SyntaxError if not foundNested Functions

3. First-Class Functions and Higher-Order Patterns

In Python, functions are instances of types.FunctionType. They can be:

  • Assigned to variables: f = math.sqrt; print(f(25)) $\rightarrow$ 5.0
  • Passed as arguments: map(func, seq)
  • Stored in data structures: ops = {'+': add, '-': subtract}
  • Returned from other functions: Function factories and closures
def get_multiplier(factor):
    def multiply(number):
        return number * factor
    return multiply  # Returning the function object itself

4. Lexical Closures and Cell Objects

A closure is a function object that remembers and retains access to values in enclosing lexical scopes even after the enclosing scope has finished execution and returned.

Anatomical Requirements for a Closure

For a closure to exist, three conditions must be met:

  1. There must be a nested function (an inner function defined inside an outer function).
  2. The inner function must reference at least one free variable (a variable defined in the outer enclosing function).
  3. The outer function must return the inner function object.
def make_greeter(greeting):
    # 'greeting' is in the enclosing scope of 'greet'
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

# outer function completes execution and returns
say_hello = make_greeter("Hello")
say_hola = make_greeter("Hola")

print(say_hello("Alice"))  # Hello, Alice!
print(say_hola("Bob"))     # Hola, Bob!

Internal Closure Mechanics: __closure__ and Cell Objects

How does say_hello remember "Hello" after make_greeter() has finished execution and its stack frame has been destroyed?

Python allocates a special cell object on the heap for any variable referenced by an inner function. The returned function object stores a tuple of these cell objects in its __closure__ attribute:

print(type(say_hello.__closure__))        # <class 'tuple'>
print(len(say_hello.__closure__))         # 1
cell = say_hello.__closure__[0]
print(cell.cell_contents)                 # 'Hello'

If a nested function does not reference any free variables from an enclosing scope, func.__closure__ is None.


5. The Late-Binding Loop Closure Trap and Fixes

One of the most frequently tested advanced scoping questions on the PCAP exam is the late-binding loop closure trap.

The Problem: Late Binding

In Python, closures bind to variable names, not the snapshot value of the variable at the moment the closure is created. The variable is looked up at the time the inner function is called (invoked), not when it is defined.

# TRAP: Creating closures in a loop
multipliers = []
for i in range(4):
    multipliers.append(lambda x: x * i)

# When the loop finishes, i is 3
results = [m(2) for m in multipliers]
print(results)  # [6, 6, 6, 6] (NOT [0, 2, 4, 6]!)

When m(2) executes, it looks up i in the enclosing scope. By the time any lambda is called, the loop has completed and i holds its final value: 3. Every lambda multiplies by 3 ($2 \times 3 = 6$).

The Fix: Freezing Values with Default Parameter Arguments

Default parameter values in Python are evaluated at function definition time, not invocation time. By binding the loop variable to a default argument, each lambda freezes the current loop value in its own local parameter namespace:

# SOLUTION: Freeze loop variable via default argument
fixed_multipliers = []
for i in range(4):
    fixed_multipliers.append(lambda x, step=i: x * step)

results = [m(2) for m in fixed_multipliers]
print(results)  # [0, 2, 4, 6] (Correct!)

During each loop iteration, step=i captures a snapshot of the current value of i ($0, 1, 2, 3$) as the default argument for that specific function instance.

Loading diagram...
Python LEGB Scope Hierarchy and Closure Cell Architecture
Test Your Knowledge

What is the result of executing the following Python code?

val = 50

def compute():
    val = val + 10
    return val

print(compute())

A
B
C
D
Test Your Knowledge

Consider the following script:

x = 10

def outer():
    def inner():
        nonlocal x
        x += 5
        return x
    return inner()

print(outer())

A
B
C
D
Test Your Knowledge

What is printed to standard output by the following Python program?

funcs = [lambda: i * 10 for i in range(3)]
output = [f() for f in funcs]
print(output)

A
B
C
D
Test Your Knowledge

What is the output of running the following Python counter program?

def make_accumulator(base):
    total = base
    def add(amount=1):
        nonlocal total
        total += amount
        return total
    return add

acc = make_accumulator(100)
print(acc(5), acc(10))

A
B
C
D