Scope and Closures

Difficulty: Intermediate

In Python, scope determines the visibility and lifetime of variables. When you reference a name, Python searches for it using the LEGB rule, checking four scopes in order: Local (inside the current function), Enclosing (inside any enclosing functions), Global (at the module level), and Built-in (Python's built-in namespace). Understanding this lookup chain is fundamental to predicting how your code behaves, especially when multiple functions are nested.

The Local scope contains variables defined within the current function. These are created when the function is called and destroyed when it returns. The Enclosing scope applies to nested functions -- an inner function can read variables from its outer (enclosing) function. The Global scope contains names defined at the top level of the module, and the Built-in scope contains Python's pre-defined names like `len`, `print`, and `range`.

The `global` keyword lets you modify a module-level variable from inside a function. Without it, assigning to a variable name inside a function creates a new local variable that shadows the global one. The `nonlocal` keyword serves a similar purpose for enclosing scopes -- it allows a nested function to modify a variable from its enclosing function rather than creating a new local variable. Both keywords should be used sparingly because they create implicit dependencies between functions and the state they modify.

A closure is a function that remembers the values from its enclosing scope even after the outer function has finished executing. When a nested function references a variable from its outer function, Python captures that variable in the inner function's closure. This is what makes function factories and decorators possible. The closed-over variables live on as long as the inner function exists, stored in the function's `__closure__` attribute.

Closures are one of Python's most powerful features. They enable patterns like memoization, where a function remembers previous results; factory functions, which produce configured functions on the fly; and decorators, which wrap existing functions with additional behavior. Mastering closures is essential for understanding how frameworks and libraries work under the hood.

Code examples

The LEGB Rule in Action

x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(f"Inner: {x}")

    inner()
    print(f"Outer: {x}")

outer()
print(f"Global: {x}")

Each scope has its own `x`. The inner function finds `x` in its local scope first (L). The outer function sees its own local `x` (which is the enclosing scope for inner). The module level sees the global `x`. Each assignment creates a new variable in its respective scope.

Using global and nonlocal

counter = 0

def increment_global():
    global counter
    counter += 1

def outer_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    print(increment())
    print(increment())
    print(increment())

increment_global()
increment_global()
print(f"Global counter: {counter}")

outer_counter()

The `global` keyword allows `increment_global` to modify the module-level `counter`. The `nonlocal` keyword allows the inner `increment` function to modify `count` from the enclosing `outer_counter` scope. Without these keywords, the assignments would create new local variables instead.

Closures: Functions that Remember

def make_counter(start=0):
    count = start

    def increment():
        nonlocal count
        count += 1
        return count

    def get_count():
        return count

    return increment, get_count

inc, get = make_counter(10)
print(inc())
print(inc())
print(inc())
print(f"Current: {get()}")

After `make_counter` returns, the `count` variable survives because both `increment` and `get_count` close over it. Each call to `inc()` modifies the same `count` variable. This is a closure in action -- the inner functions remember their enclosing environment.

Closure Pitfall: Late Binding in Loops

# Common mistake: closures in a loop
functions = []
for i in range(4):
    functions.append(lambda: i)

print("Late binding:", [f() for f in functions])

# Fix: capture the value with a default argument
fixed = []
for i in range(4):
    fixed.append(lambda i=i: i)

print("Fixed:       ", [f() for f in fixed])

In the first loop, all lambdas share the same variable `i`, which ends up as 3 after the loop completes. In the fixed version, `i=i` captures the current value of `i` as a default parameter at each iteration, preserving the expected value.

Key points

Concepts covered

LEGB Rule, global Keyword, nonlocal Keyword, Closures