← Python Code Loops, Functions & Scopes
Browse Python Concepts

Closures — How Functions Capture Variables

Mental Model

Python closures are like a camera pointed at a variable name, not at its value. When you later look at the photo (execute the closure), you see whatever is in front of the camera now, not what was there when you pressed the shutter (created the closure).

Rule: When creating nested functions inside loops, always bind iteration variables as default arguments to freeze their values at creation time.

The Setup

You are generating a list of rate-limiting checkers for different tier structures, dynamically mapping limit thresholds using a looping generator.

What Does This Print?

Broken code
Python
def build_rate_limiters():
    limiters = []
    # Define tier limits
    tiers = [100, 500, 1000]
    for limit in tiers:
        # Dynamically construct validation closures
        limiters.append(lambda request_count: request_count <= limit)
    return limiters

limit_checkers = build_rate_limiters()
# We want to check if 150 hits exceed the 100 limit (tier 0)
is_valid = limit_checkers[0](150)
print(f"Is 150 requests within Tier 0 limit? {is_valid}")
Predict the output. Will the first rate-limiting checker correctly evaluate against the 100 threshold, returning False?

The Output

What actually happens
Is 150 requests within Tier 0 limit? True

The check returns True. Every single generated checker evaluates against the final value of the loop variable (limit = 1000). This is because the lambda functions capture the variable limit itself, rather than the integer value assigned to limit during that particular loop iteration. When the loop completes, the variable limit holds the value 1000, which all three closures reference.

Why Python Does This

Under the hood, Python resolves outer variables in closures via cell objects (cell objects inside the function's __closure__ attribute). When an inner function references an outer scope variable, CPython flags it as a cell variable (CO_CELL and CO_FREE flags). Instead of binding the value directly to the closure, Python points both the outer and inner scopes to a shared PyCellObject wrapper that points to the underlying value. Because all three lambdas point to the exact same cell object, any mutation of the loop variable limit updates the value contained inside the single cell, meaning all closures resolve to the final value at execution time.

The Fix

Corrected pattern
Python
def build_rate_limiters():
    limiters = []
    tiers = [100, 500, 1000]
    for limit in tiers:
        # Capture the current value of 'limit' by binding it
        # as a default argument, which evaluates eagerly at creation time.
        limiters.append(lambda request_count, current_limit=limit: request_count <= current_limit)
    return limiters

limit_checkers = build_rate_limiters()
# This will now correctly evaluate 150 <= 100 (False)
print(f"Is 150 requests within Tier 0 limit? {limit_checkers[0](150)}")

Binding the loop variable as a default argument to the lambda function effectively 'freezes' its value. Default arguments are evaluated once, at function definition time, so each lambda captures the specific limit value from its respective iteration, rather than a reference to the dynamically changing limit variable itself.

How This Fails in Real Systems

A dynamically generated access control matrix relied on loops to produce verification callbacks for different API endpoints. Because of late-binding, every route checker ended up validating user access against the admin permissions of the last evaluated route in the loop, creating a critical security bypass that survived in production for three days.

Key Takeaway

When creating nested functions inside loops, always bind iteration variables as default arguments to freeze their values at creation time.
Common mistake: Developers expect a closure created within a loop to capture the value of the loop variable at the time of its creation, rather than its reference, leading to all closures reflecting the variable's final value.