Closures — How Functions Capture Variables
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).
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?
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}")
The Output
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
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.