Skip to content
Codeloom
Python

Python Closures and Scoping Rules Explained

Understand how Python closures capture variables, the LEGB scope resolution rule, and common pitfalls like late binding in loops.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How the LEGB rule determines variable lookup order
  • What closures are and how they capture enclosing state
  • The late-binding gotcha in loops and how to fix it
  • Using nonlocal and global correctly

Prerequisites

None — this post is self-contained.

Closures are one of Python’s most useful features, but they confuse developers who do not understand scoping rules. A closure is a function that remembers variables from the scope where it was defined, even after that scope has finished executing. This article explains how Python resolves names, how closures capture variables, and the traps you need to avoid.

The LEGB Rule

When Python encounters a name, it searches four scopes in order:

  1. Local — names defined inside the current function
  2. Enclosing — names in any enclosing function (for nested functions)
  3. Global — names defined at the module level
  4. Built-in — names in the builtins module (print, len, etc.)
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)  # "local" (L)

    inner()
    print(x)  # "enclosing" (E from outer's perspective, L from outer's own)

outer()
print(x)  # "global" (G)

If you remove the local assignment in inner, Python finds x in the enclosing scope:

def outer():
    x = "enclosing"

    def inner():
        print(x)  # "enclosing" -- found in E scope

    inner()

What Is a Closure?

A closure occurs when a nested function references a variable from its enclosing scope, and the enclosing function has returned. The inner function “closes over” the variable, keeping it alive.

def make_multiplier(factor):
    def multiply(x):
        return x * factor  # 'factor' is captured from enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

After make_multiplier returns, factor would normally be garbage collected. But because multiply references it, Python keeps it alive in the closure.

Inspecting Closures

You can see what a closure captures:

print(double.__closure__)
# (<cell at 0x...: int object at 0x...>,)

print(double.__closure__[0].cell_contents)
# 2

print(double.__code__.co_freevars)
# ('factor',)

Closures Capture Variables, Not Values

This is the most important thing to understand. Closures capture the variable itself, not its value at the time of capture. The variable is looked up when the closure is called, not when it is defined.

def make_functions():
    functions = []
    for i in range(5):
        def f():
            return i  # Captures the variable 'i', not its current value
        functions.append(f)
    return functions

funcs = make_functions()
print([f() for f in funcs])
# [4, 4, 4, 4, 4]  -- NOT [0, 1, 2, 3, 4]

All five functions share the same i variable, and by the time they are called, the loop has finished and i is 4.

Fix 1: Default Argument Binding

Force early binding by capturing the current value as a default argument:

def make_functions():
    functions = []
    for i in range(5):
        def f(i=i):  # Default arg captures current value of i
            return i
        functions.append(f)
    return functions

print([f() for f in make_functions()])
# [0, 1, 2, 3, 4]

Fix 2: Factory Function

Create a new scope for each iteration:

def make_functions():
    def make_f(i):
        def f():
            return i  # Each f closes over its own 'i'
        return f
    return [make_f(i) for i in range(5)]

print([f() for f in make_functions()])
# [0, 1, 2, 3, 4]

Fix 3: functools.partial

from functools import partial

def identity(x):
    return x

funcs = [partial(identity, i) for i in range(5)]
print([f() for f in funcs])
# [0, 1, 2, 3, 4]

The nonlocal Keyword

By default, assigning to a variable inside a function creates a new local variable. To modify a variable in the enclosing scope, use nonlocal:

def counter():
    count = 0

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

    def get():
        return count

    return increment, get

inc, get = counter()
print(inc())  # 1
print(inc())  # 2
print(inc())  # 3
print(get())  # 3

Without nonlocal, the line count += 1 would raise UnboundLocalError because Python sees the assignment and treats count as local.

nonlocal vs global

total = 0

def outer():
    total = 10

    def inner_nonlocal():
        nonlocal total  # Refers to outer's 'total'
        total += 1

    def inner_global():
        global total  # Refers to module-level 'total'
        total += 1

    inner_nonlocal()
    print(f"outer's total: {total}")  # 11

    inner_global()

outer()
print(f"module total: {total}")  # 1

nonlocal targets the nearest enclosing scope. global skips all enclosing scopes and goes straight to the module level.

Practical Closure Patterns

Memoization

def memoize(func):
    cache = {}  # Captured by the closure

    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]

    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(30))  # 832040 (fast, thanks to caching)

Configuration Factories

def create_logger(prefix, level="INFO"):
    def log(message):
        print(f"[{level}] {prefix}: {message}")
    return log

db_log = create_logger("DATABASE")
api_log = create_logger("API", level="DEBUG")

db_log("Connection established")   # [INFO] DATABASE: Connection established
api_log("Request received")        # [DEBUG] API: Request received

Encapsulating State

Closures can replace simple classes when you only need state and one or two functions:

def rate_limiter(max_calls, period_seconds):
    import time
    calls = []

    def allow():
        nonlocal calls
        now = time.time()
        calls = [t for t in calls if now - t < period_seconds]
        if len(calls) < max_calls:
            calls.append(now)
            return True
        return False

    return allow

limiter = rate_limiter(max_calls=3, period_seconds=10)
print(limiter())  # True
print(limiter())  # True
print(limiter())  # True
print(limiter())  # False

Key Takeaways

Python resolves names using the LEGB rule: Local, Enclosing, Global, Built-in. Closures capture variables by reference, not by value, which causes the classic loop-binding bug. Use default arguments or factory functions to force early binding. Use nonlocal to modify enclosing variables and global only when you genuinely need module-level state. Closures are the foundation of decorators, callbacks, and many functional patterns in Python.