Closures Deep Dive - Free Variables, Cell Objects, and nonlocal
Reading time: ~30 minutes | Level: Intermediate → Engineering
Before reading further, predict every output:
def make_counter():
count = 0
def increment():
count += 1
return count
return increment
c = make_counter()
print(c()) # ?
Show Answer
This raises UnboundLocalError: local variable 'count' referenced before assignment.
It does not print 1. The assignment count += 1 (which is count = count + 1) contains an assignment to count. Python's compiler sees this assignment and marks count as a local variable of increment at compile time - before the code runs. At runtime, count + 1 tries to read the local count before it has been assigned - hence the error.
The fix is nonlocal count, which tells Python's compiler that count refers to the enclosing scope's variable, not a new local.
def make_counter():
count = 0
def increment():
nonlocal count # now the compiler knows: count is from the enclosing scope
count += 1
return count
return increment
c = make_counter()
print(c()) # 1
print(c()) # 2
print(c()) # 3
Now consider: every decorator you write creates a closure. Every lambda that captures a variable is a closure. Every factory function returns a closure. Understanding closures at this depth - including CPython's cell object implementation - means understanding how Python manages state without classes, and why certain patterns (especially involving loops) behave unexpectedly.
What You Will Learn
- What a closure is: a function plus its enclosing scope's free variables
- How CPython implements closures: cell objects,
__closure__,co_freevars - Python's compile-time variable classification: LEGB - local, enclosing, global, builtin
- The
UnboundLocalErrortrap and why it is a compile-time decision, not a runtime one - The
nonlocalkeyword: how it differs fromglobal, when to use each - Inspecting closures:
__closure__,cell_contents,__code__.co_freevars - Practical closure patterns: factory functions, memoization, partial application, event handlers
- Closure vs class: when closures are cleaner than single-method classes
- The mutable closure state pattern with
nonlocal - Late binding and the loop variable capture bug
Prerequisites
- Lesson 04: Iterator Protocol - enclosing scope mechanics referenced briefly
- Lesson 05: Decorators - every decorator is a closure; this lesson explains why
- Comfortable writing functions that return functions
Part 1 - What a Closure Is
Definition
A closure is a function that remembers the values from its enclosing scope even after the outer function has returned. More precisely: a closure is a function object together with an environment - a mapping from free variable names to their values (held in cell objects).
def make_multiplier(factor):
# 'factor' is a free variable of the inner function
def multiply(x):
return x * factor # 'factor' is captured from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20 - factor=2 is captured in double's closure
print(triple(10)) # 30 - factor=3 is captured in triple's closure
# make_multiplier() has returned, but factor lives on in the closures
Each call to make_multiplier creates a new closure with its own independent captured value. double and triple are separate functions each holding a different factor.
The Three Conditions for a Closure
A closure exists when all three conditions hold:
- There is a nested function (inner function defined inside an outer function)
- The inner function references a variable from the outer function's scope (a free variable)
- The outer function returns the inner function (or the inner function escapes otherwise)
# All three conditions met - this IS a closure
def outer():
x = 10 # condition 2: x is a free variable of inner
def inner():
return x # references outer's variable
return inner # condition 3: inner escapes
# Condition 3 not met - inner doesn't escape, not really a closure
def outer2():
x = 10
def inner():
return x
return inner() # calling inner(), not returning it - result is 10
# Condition 2 not met - inner doesn't reference outer's variables
def outer3():
x = 10
def inner():
return 99 # doesn't use x - no free variables
return inner
Part 2 - CPython's Implementation: Cell Objects
How Python Stores Captured Variables
CPython implements closures using cell objects. When the compiler detects that a variable is referenced by a nested function, it allocates a cell - a mutable container that holds a reference to the variable's value. Both the outer function and the inner function share the same cell object.
def make_counter():
count = 0 # stored in a cell object, shared with increment
def increment():
nonlocal count
count += 1
return count
return increment
c = make_counter()
# Inspect the closure
print(c.__closure__) # (<cell at 0x...>,)
print(c.__closure__[0].cell_contents) # 0 (the current value of count)
c()
c()
print(c.__closure__[0].cell_contents) # 2 (modified through the cell)
# Inspect what variables are captured
print(c.__code__.co_freevars) # ('count',)
Cell Object Mechanics
The critical insight: the outer frame (make_counter's local variables) is garbage collected after make_counter returns. But the cell object is not - because increment.__closure__ holds a reference to it. The cell outlives the outer function.
Inspecting Closures in Detail
def make_adder(base, step=1):
label = f"adder(base={base}, step={step})"
def add(x):
return base + x * step # captures 'base', 'step', and 'label'
return add
adder = make_adder(10, step=2)
# __closure__: tuple of cell objects, one per free variable
print(adder.__closure__)
# (<cell at 0x...>, <cell at 0x...>, <cell at 0x...>)
# __code__.co_freevars: names of free variables (same order as __closure__)
print(adder.__code__.co_freevars)
# ('base', 'label', 'step') ← alphabetically sorted
# Map names to values
free_vars = dict(zip(
adder.__code__.co_freevars,
(cell.cell_contents for cell in adder.__closure__)
))
print(free_vars)
# {'base': 10, 'label': 'adder(base=10, step=2)', 'step': 2}
print(adder(5)) # 10 + 5 * 2 = 20
Part 3 - Variable Classification at Compile Time
The LEGB Rule and Compile-Time Decisions
Python classifies every variable in a function at compile time - before any code runs. The classification determines how the variable is looked up at runtime:
- L - Local: assigned anywhere in the function (including
for x in ...,with ... as x,except ... as x) - E - Enclosing: referenced from an outer function's scope (free variable)
- G - Global: declared with
global name - B - Builtin: Python's built-in names (
len,print,range, ...)
The UnboundLocalError Trap - A Compile-Time Decision
This is the most common closure bug. It stems from Python making variable classification decisions once, at compile time, before any line executes:
x = 10
def f():
print(x) # would work if x were a free/global variable
x = 20 # but this assignment makes x LOCAL for the entire function!
f() # UnboundLocalError: local variable 'x' referenced before assignment
Python's compiler sees the assignment x = 20 and marks x as local to f. The entire function body uses the local x. The print(x) reads the local x - which has not been assigned yet. This decision is made at compile time; it does not matter that the assignment appears after the read at the source level.
The same trap in a closure:
def make_counter():
count = 0
def increment():
# count += 1 ← this assignment marks count as LOCAL to increment
# Python sees: count = count + 1
# count on the right side: reads local count (not yet assigned) → UnboundLocalError
count += 1
return count
return increment
The nonlocal Keyword
nonlocal declares that a name refers to a variable in the nearest enclosing scope (not global, not local to this function):
def make_counter():
count = 0
def increment():
nonlocal count # tells compiler: count is in the enclosing scope
count += 1 # now valid: reads and writes the enclosing count
return count
def reset():
nonlocal count
count = 0
def get():
return count # read-only: no assignment, no nonlocal needed
return increment, reset, get
inc, reset, get = make_counter()
print(inc()) # 1
print(inc()) # 2
print(inc()) # 3
print(get()) # 3
reset()
print(get()) # 0
print(inc()) # 1
nonlocal vs global
x = "global"
def outer():
x = "enclosing"
def inner_global():
global x # refers to module-level x, skips enclosing
x = "modified global"
def inner_nonlocal():
nonlocal x # refers to outer()'s x, not module-level
x = "modified enclosing"
inner_global()
print(x) # "enclosing" - inner_global changed the global, not this x
inner_nonlocal()
print(x) # "modified enclosing" - inner_nonlocal changed outer's x
outer()
print(x) # "modified global" - inner_global changed module-level x
:::warning Avoid mutable shared state in closures accessed from multiple threads
Closure variables are not thread-safe. If multiple threads call a closure that modifies shared state (nonlocal count in a counter), the operations are not atomic and races occur.
import threading
def make_counter():
count = 0
def increment():
nonlocal count
count += 1 # NOT atomic: read, increment, write - races in threads
return count
return increment
counter = make_counter()
threads = [threading.Thread(target=counter) for _ in range(1000)]
for t in threads: t.start()
for t in threads: t.join()
# Final count is less than 1000 due to race conditions
# Fix: use threading.Lock or threading.local, or use a class with a Lock
:::
Part 4 - Practical Closure Patterns
Factory Functions
Closures are the natural way to create specialized functions from a template:
def make_validator(min_val: float, max_val: float, name: str = "value"):
"""Creates a validator function for a specific range."""
def validate(x: float) -> float:
if not (min_val <= x <= max_val):
raise ValueError(
f"{name} must be between {min_val} and {max_val}, got {x}"
)
return x
validate.__name__ = f"validate_{name}"
return validate
validate_age = make_validator(0, 150, "age")
validate_score = make_validator(0.0, 100.0, "score")
validate_temp = make_validator(-273.15, 1e10, "temperature_celsius")
print(validate_age(25)) # 25
print(validate_score(98.5)) # 98.5
try:
validate_age(200)
except ValueError as e:
print(e) # age must be between 0 and 150, got 200
Memoization Without Classes
def memoized(func):
"""Closure-based memoization - no class needed."""
cache = {} # captured in the closure - persists across calls
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
wrapper.cache = cache # expose for inspection
return wrapper
@memoized
def expensive_computation(n: int) -> int:
print(f" computing {n}...")
return n * n + n
print(expensive_computation(5)) # computing 5... then 30
print(expensive_computation(5)) # 30 - cache hit, no recomputation
print(expensive_computation(10)) # computing 10... then 110
print(expensive_computation.cache) # {(5,): 30, (10,): 110}
Partial Application
Closures enable partial application - fixing some arguments of a function to create a specialized version:
def partial_apply(func, *fixed_args, **fixed_kwargs):
"""Fix some arguments of func, returning a new function."""
def applied(*args, **kwargs):
return func(*fixed_args, *args, **{**fixed_kwargs, **kwargs})
return applied
def power(base, exponent):
return base ** exponent
square = partial_apply(power, exponent=2)
cube = partial_apply(power, exponent=3)
print(square(5)) # 25
print(cube(3)) # 27
# Python's functools.partial does the same thing (more robustly)
from functools import partial
square2 = partial(power, exponent=2)
print(square2(5)) # 25
Event Handlers and Callbacks
Closures are the standard way to attach context to callbacks:
def make_button_handler(button_id: str, action: str):
"""Creates an event handler that knows its button's context."""
click_count = 0
def on_click(event):
nonlocal click_count
click_count += 1
print(f"Button {button_id!r}: action={action!r}, clicks={click_count}")
on_click.get_click_count = lambda: click_count
return on_click
save_handler = make_button_handler("btn-save", "save_document")
delete_handler = make_button_handler("btn-delete", "delete_item")
save_handler(None) # Button 'btn-save': action='save_document', clicks=1
save_handler(None) # Button 'btn-save': action='save_document', clicks=2
delete_handler(None) # Button 'btn-delete': action='delete_item', clicks=1
print(save_handler.get_click_count()) # 2
print(delete_handler.get_click_count()) # 1
Part 5 - Closure vs Class
When Closures Are Cleaner
A closure is effectively a minimal object with one method. For simple stateful callables with a single operation, closures are cleaner than classes:
# Single-operation stateful callable - closure is cleaner
def make_accumulator(initial: float = 0.0):
total = initial
def add(value: float) -> float:
nonlocal total
total += value
return total
return add
acc = make_accumulator(100.0)
print(acc(10)) # 110.0
print(acc(20)) # 130.0
print(acc(5)) # 135.0
When to Switch to a Class
Switch to a class when:
- You need multiple methods (operations on the same state)
- You need
__repr__for debugging - The state management is complex enough to benefit from named attributes
- You need inheritance or protocol compliance
- You need the object to be picklable (closures are not picklable)
# Multiple methods, __repr__, complex state - use a class
class Accumulator:
def __init__(self, initial: float = 0.0):
self._total = initial
self._count = 0
self._history: list[float] = []
def add(self, value: float) -> float:
self._total += value
self._count += 1
self._history.append(value)
return self._total
def reset(self) -> None:
self._total = 0.0
self._count = 0
self._history.clear()
@property
def average(self) -> float:
return self._total / self._count if self._count > 0 else 0.0
def __repr__(self) -> str:
return f"Accumulator(total={self._total}, count={self._count})"
:::tip Use Closures for Simple Factory Functions; Switch to a Class When You Need Multiple Methods
The decision boundary is clear: if your stateful callable needs more than one method, or needs __repr__, or needs to participate in a protocol - make it a class. Otherwise, a closure is less boilerplate and equally correct.
# Closure: perfect for this - one function, simple state
def make_rate_checker(limit, period):
calls = []
def check():
...
return check
# Class: better here - multiple methods, needs __repr__, needs reset()
class RateChecker:
def check(self): ...
def reset(self): ...
def __repr__(self): ...
:::
Part 6 - The Mutable Closure State Pattern
Multi-Variable Mutable Closures
When a closure needs to manage multiple pieces of mutable state, use multiple nonlocal declarations or a mutable container:
def make_statistics():
"""Returns a function that accumulates statistics."""
count = 0
total = 0.0
minimum = float('inf')
maximum = float('-inf')
def update(value: float) -> dict:
nonlocal count, total, minimum, maximum
count += 1
total += value
if value < minimum: minimum = value
if value > maximum: maximum = value
return {
"count": count,
"mean": total / count,
"min": minimum,
"max": maximum,
}
return update
stats = make_statistics()
print(stats(10)) # {'count': 1, 'mean': 10.0, 'min': 10, 'max': 10}
print(stats(20)) # {'count': 2, 'mean': 15.0, 'min': 10, 'max': 20}
print(stats(5)) # {'count': 3, 'mean': 11.67, 'min': 5, 'max': 20}
Using a Mutable Container to Avoid nonlocal
If you want to avoid nonlocal (e.g., for Python 2 compatibility or stylistic preference), use a mutable container:
def make_counter_v2():
state = {"count": 0} # mutable dict - no nonlocal needed
def increment():
state["count"] += 1 # mutates the dict, does not rebind 'state'
return state["count"]
def reset():
state["count"] = 0 # mutates the dict
return increment, reset
The key: state["count"] += 1 does not rebind state - it mutates the dict that state already points to. No nonlocal needed. But state = {...} would rebind state and require nonlocal.
Part 7 - Late Binding and the Loop Variable Capture Bug
The Bug
This is one of Python's most notorious gotchas. When closures capture a loop variable, they all capture the same cell - and that cell holds the loop variable's final value:
# Trying to create 5 functions that add 0, 1, 2, 3, 4 respectively
adders = []
for i in range(5):
adders.append(lambda x: x + i)
print(adders[0](10)) # 14 - expected 10, got 14!
print(adders[1](10)) # 14
print(adders[2](10)) # 14
print(adders[3](10)) # 14
print(adders[4](10)) # 14
All five functions return x + 4 because they all capture the variable i - not its value at the time the lambda was created. After the loop, i = 4. All lambdas look up i at call time, finding 4.
Why This Happens
Python's closures capture variables (cell objects), not values. There is one cell for i shared by all five lambdas. When the loop ends, that cell holds 4. All five lambdas read the same cell.
adders = []
for i in range(5):
adders.append(lambda x: x + i)
# At this point: i = 4 (the loop variable's final value)
# All lambdas' closures point to the same cell holding i
print([f.__code__.co_freevars for f in adders])
# [('i',), ('i',), ('i',), ('i',), ('i',)] - all capture 'i'
cell_id = id(adders[0].__closure__[0])
print(all(id(f.__closure__[0]) == cell_id for f in adders))
# True - all five lambdas share the SAME cell object!
The Fix: Default Argument Binding
Capture the current value of i as a default argument. Default arguments are evaluated at function definition time, not call time:
# Fix 1: default argument captures the value at creation time
adders = []
for i in range(5):
adders.append(lambda x, i=i: x + i) # i=i evaluates i NOW
print(adders[0](10)) # 10
print(adders[1](10)) # 11
print(adders[2](10)) # 12
print(adders[3](10)) # 13
print(adders[4](10)) # 14
# Fix 2: factory function creates a new scope for each i
def make_adder(n):
def adder(x):
return x + n # captures n, not i - n is a new variable each call
return adder
adders = [make_adder(i) for i in range(5)]
print(adders[0](10)) # 10
print(adders[1](10)) # 11
print(adders[2](10)) # 12
# Fix 3: functools.partial
from functools import partial
def add(x, n):
return x + n
adders = [partial(add, n=i) for i in range(5)]
print(adders[0](10)) # 10
print(adders[1](10)) # 11
:::danger Closures Hold References to Their Enclosing Scope - Large Objects Won't Be Garbage Collected If a closure captures a reference to a large object (a big DataFrame, an open file, a large list), that object will not be garbage collected as long as the closure exists - even if nothing else references it.
import sys
def make_processor(large_data):
# large_data is captured in the closure
def process(key):
return large_data.get(key) # captures the entire dict
return process
big_dict = {i: i**2 for i in range(100_000)}
print(sys.getsizeof(big_dict)) # ~4 MB
processor = make_processor(big_dict)
# Even if we delete big_dict, the closure keeps it alive
del big_dict
import gc; gc.collect()
# processor.__closure__[0].cell_contents is still the 4MB dict!
print(sys.getsizeof(processor.__closure__[0].cell_contents)) # still ~4 MB
# Fix: capture only what you need
def make_processor_lean(large_data):
lookup = dict(large_data) # still large, but explicit copy
# or: extract only needed keys before closure
def process(key):
return lookup.get(key)
return process
In long-running servers, closures in request handlers or event loops that capture large objects are a common source of memory leaks. :::
:::note Every Decorator Creates a Closure Understanding closures means understanding how decorators actually work at the CPython level. When you write:
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) # 'func' is a free variable
return wrapper
wrapper is a closure that captures func in a cell object. func lives in that cell as long as the decorated function (wrapper) is referenced. This is why the original function is not garbage collected after decoration - the wrapper's closure holds a reference.
:::
Part 8 - Putting It Together: A Production-Quality Closure
import time
import functools
from typing import Callable, TypeVar, Optional
F = TypeVar("F", bound=Callable)
def debounce(wait_seconds: float) -> Callable[[F], F]:
"""
Decorator factory that delays function execution until wait_seconds
have passed with no new calls. Classic UI debouncing in Python.
Common in:
- Search-as-you-type: wait until user stops typing
- Window resize handlers: wait until resize stops
- Auto-save: wait until editing pauses
"""
def decorator(func: F) -> F:
# Mutable closure state: last call time and cached result
last_call_time: list[Optional[float]] = [None] # list avoids nonlocal
pending_result: list[Optional[object]] = [None]
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
last_call_time[0] = now
# Simulate: in real async code this would schedule a callback
# For sync demo: execute immediately if debounce window passed
if last_call_time[0] is not None:
elapsed = time.monotonic() - last_call_time[0]
if elapsed >= wait_seconds:
pending_result[0] = func(*args, **kwargs)
return pending_result[0]
pending_result[0] = func(*args, **kwargs)
return pending_result[0]
wrapper.last_called = lambda: last_call_time[0]
return wrapper # type: ignore
return decorator
def make_pipeline(*transforms: Callable) -> Callable:
"""
Compose multiple transform functions into a single pipeline function.
Each transform receives the output of the previous one.
Uses a closure to capture the transforms tuple.
"""
def pipeline(value):
result = value
for transform in transforms: # 'transforms' is a free variable
result = transform(result)
return result
pipeline.__name__ = f"pipeline({', '.join(f.__name__ for f in transforms)})"
return pipeline
# Example: text processing pipeline
clean = make_pipeline(
str.strip,
str.lower,
lambda s: s.replace("-", "_"),
lambda s: s.replace(" ", "_"),
)
print(clean(" Hello World ")) # hello_world
print(clean(" User-Name ")) # user_name
print(clean.__name__) # pipeline(strip, lower, <lambda>, <lambda>)
# Closure inspection
print(clean.__code__.co_freevars) # ('transforms',)
print(clean.__closure__[0].cell_contents)
# (<function strip>, <function lower>, <lambda>, <lambda>)
Common Mistakes
Mistake 1 - Forgetting nonlocal on Assignment
# Wrong - count += 1 makes count local → UnboundLocalError
def make_counter():
count = 0
def increment():
count += 1 # BUG: assignment makes count local
return count
return increment
# Right
def make_counter():
count = 0
def increment():
nonlocal count # declare enclosing scope
count += 1
return count
return increment
Mistake 2 - Using global When nonlocal Is Correct
x = 0 # module-level
def outer():
x = 10 # outer's local x
def inner():
global x # BUG: modifies module-level x, not outer's x
x += 1
return x
return inner
f = outer()
print(f()) # 1 - modified MODULE-LEVEL x, not outer's x=10
# Right
def outer():
x = 10
def inner():
nonlocal x # correct: refers to outer's x
x += 1
return x
return inner
Mistake 3 - The Loop Variable Capture Bug
# Wrong - all functions capture the same loop variable
handlers = [lambda: print(i) for i in range(3)]
handlers[0]() # 2 - not 0!
handlers[1]() # 2 - not 1!
handlers[2]() # 2
# Right - default argument captures the value at creation
handlers = [lambda i=i: print(i) for i in range(3)]
handlers[0]() # 0
handlers[1]() # 1
handlers[2]() # 2
Mistake 4 - Leaking Large Objects Through Closures
# Wrong - entire large DataFrame captured in closure
def make_summary(df):
def get_stats():
return df.describe() # holds reference to full df
return get_stats
# Right - extract only needed data before creating closure
def make_summary(df):
stats = df.describe() # compute upfront, capture only the small result
def get_stats():
return stats
return get_stats
Graded Practice Challenges
Level 1 - Predict the Output
Question 1: What does this print?
def outer():
x = 1
def inner():
x = 2
return x
result = inner()
return x, result
print(outer())
Show Answer
Output:
(1, 2)
inner assigns x = 2. This creates a new local variable in inner's scope - it does not modify outer's x. Without nonlocal x, the assignment creates a separate local. outer's x remains 1. inner() returns its local x = 2. So outer returns (1, 2).
Question 2: What does this print?
def make_functions():
funcs = []
for i in range(4):
funcs.append(lambda: i * i)
return funcs
fs = make_functions()
print([f() for f in fs])
Show Answer
Output:
[9, 9, 9, 9]
All four lambdas capture the same variable i (via the same cell object). After the loop, i = 3. All four lambdas compute 3 * 3 = 9. This is the loop variable capture bug. Fix: lambda i=i: i * i.
Question 3: What does this print?
def counter_factory(start=0):
state = [start]
def inc():
state[0] += 1
return state[0]
def dec():
state[0] -= 1
return state[0]
def reset():
state[0] = start
return inc, dec, reset
inc, dec, reset = counter_factory(10)
print(inc())
print(inc())
print(dec())
reset()
print(inc())
Show Answer
Output:
11
12
11
11
All three returned functions share the same state list (and the same start variable). state[0] starts at 10. inc() → 11, inc() → 12, dec() → 11. reset() sets state[0] = start = 10. inc() → 11. The mutable list avoids nonlocal - mutating the list contents does not rebind the state name.
Question 4: What does this print?
def make_greeting(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = make_greeting("Hello")
hi = make_greeting("Hi")
print(hello("Alice"))
print(hi("Bob"))
print(hello.__code__.co_freevars)
print(hello.__closure__[0].cell_contents)
Show Answer
Output:
Hello, Alice!
Hi, Bob!
('greeting',)
Hello
hello and hi are two separate closure objects, each with their own cell holding a different greeting string. co_freevars shows the name of the captured variable. cell_contents shows its current value - "Hello" for the hello closure.
Question 5: What does this print?
def broken():
result = []
for x in [1, 2, 3]:
result.append(lambda: x)
return result
def fixed():
result = []
for x in [1, 2, 3]:
result.append(lambda x=x: x)
return result
for f in broken():
print(f(), end=" ")
print()
for f in fixed():
print(f(), end=" ")
print()
Show Answer
Output:
3 3 3
1 2 3
broken(): all three lambdas capture the variable x by reference. After the loop, x = 3. All three return 3.
fixed(): the x=x default argument captures the current value of x at lambda creation time. Each lambda gets its own default argument: x=1, x=2, x=3. They return 1, 2, 3 respectively.
Level 2 - Debug Challenge
Find and fix all bugs:
def make_pipeline(*steps):
def run(value):
for step in steps:
value = step(value)
return value
return run
def make_multiplier_table(n):
multipliers = []
for i in range(1, n + 1):
multipliers.append(lambda x: x * i) # bug 1
return multipliers
def make_bounded_adder(limit):
total = 0
def add(value):
total += value # bug 2
if total > limit:
raise ValueError(f"Limit {limit} exceeded: total={total}")
return total
return add
def make_logger(prefix):
logs = []
def log(message):
logs.append(f"{prefix}: {message}")
return logs # bug 3: returns shared mutable state
return log
pipeline = make_pipeline(str.strip, str.lower, lambda s: s.replace(" ", "_"))
print(pipeline(" Hello World "))
mults = make_multiplier_table(3)
print(mults[0](5)) # should be 5
print(mults[1](5)) # should be 10
print(mults[2](5)) # should be 15
adder = make_bounded_adder(100)
print(adder(30))
print(adder(40))
Show Solution
Bugs:
-
lambda x: x * iin the loop capturesiby reference. After the loop,i = n. All lambdas usei = n. Fix: use default argumentlambda x, i=i: x * i. -
total += valuecontains an assignment - Python markstotalas local toadd. At runtime,total + valuereads an unassigned local →UnboundLocalError. Fix:nonlocal total. -
return logsreturns a reference to the internallogslist. The caller can mutate it directly, corrupting the logger's state. Fix:return list(logs)to return a copy, or returnlogs[-1]to return just the last entry.
Fixed version:
def make_pipeline(*steps):
def run(value):
for step in steps:
value = step(value)
return value
return run
def make_multiplier_table(n):
multipliers = []
for i in range(1, n + 1):
multipliers.append(lambda x, i=i: x * i) # fix 1: default arg
return multipliers
def make_bounded_adder(limit):
total = 0
def add(value):
nonlocal total # fix 2: declare nonlocal
total += value
if total > limit:
raise ValueError(f"Limit {limit} exceeded: total={total}")
return total
return add
def make_logger(prefix):
logs = []
def log(message):
logs.append(f"{prefix}: {message}")
return list(logs) # fix 3: return a copy, not the mutable list
return log
pipeline = make_pipeline(str.strip, str.lower, lambda s: s.replace(" ", "_"))
print(pipeline(" Hello World ")) # hello_world
mults = make_multiplier_table(3)
print(mults[0](5)) # 5
print(mults[1](5)) # 10
print(mults[2](5)) # 15
adder = make_bounded_adder(100)
print(adder(30)) # 30
print(adder(40)) # 70
Level 3 - Design Challenge
Design a make_cache function that returns a cache object (via closures, no class) supporting:
get(key)- returns the cached value orNoneif not presentset(key, value, ttl_seconds=None)- stores a value with optional expirydelete(key)- removes a keyclear()- removes all entriesstats()- returns a dict withhits,misses,size- TTL-expired entries are treated as cache misses
- The returned object is a dict of named callables (not a class)
Show Reference Solution
import time
from typing import Any, Optional
def make_cache() -> dict:
"""
Creates a TTL-aware cache using closures.
Returns a dict of callables - no class needed.
"""
# Shared mutable state - all returned functions close over these
_store: dict[str, tuple[Any, Optional[float]]] = {} # key → (value, expiry_time or None)
_hits = [0]
_misses = [0]
def get(key: str) -> Optional[Any]:
if key not in _store:
_misses[0] += 1
return None
value, expiry = _store[key]
if expiry is not None and time.monotonic() > expiry:
del _store[key]
_misses[0] += 1
return None
_hits[0] += 1
return value
def set(key: str, value: Any, ttl_seconds: Optional[float] = None) -> None:
expiry = None
if ttl_seconds is not None:
expiry = time.monotonic() + ttl_seconds
_store[key] = (value, expiry)
def delete(key: str) -> bool:
if key in _store:
del _store[key]
return True
return False
def clear() -> None:
_store.clear()
_hits[0] = 0
_misses[0] = 0
def stats() -> dict:
# Count only non-expired entries
now = time.monotonic()
live_count = sum(
1 for _, expiry in _store.values()
if expiry is None or expiry > now
)
return {
"hits": _hits[0],
"misses": _misses[0],
"size": live_count,
"hit_rate": _hits[0] / (_hits[0] + _misses[0]) if (_hits[0] + _misses[0]) > 0 else 0.0,
}
return {
"get": get,
"set": set,
"delete": delete,
"clear": clear,
"stats": stats,
}
# Usage
cache = make_cache()
cache["set"]("user:1", {"name": "Alice", "role": "admin"})
cache["set"]("session:abc", "token_value", ttl_seconds=0.1) # expires in 0.1s
print(cache["get"]("user:1")) # {'name': 'Alice', 'role': 'admin'}
print(cache["get"]("user:1")) # cache hit
print(cache["get"]("missing")) # None
print(cache["stats"]()) # {'hits': 2, 'misses': 1, 'size': 2, 'hit_rate': 0.667}
# Wait for TTL to expire
time.sleep(0.15)
print(cache["get"]("session:abc")) # None - expired
print(cache["stats"]()) # {'hits': 2, 'misses': 2, 'size': 1, ...}
cache["delete"]("user:1")
print(cache["get"]("user:1")) # None
print(cache["stats"]()["size"]) # 0
Design decisions:
- All five functions close over the same
_store,_hits, and_misses- shared state without a class _hitsand_missesare single-element lists to allow mutation withoutnonlocal- Expiry is stored alongside the value as a tuple - no separate expiry dict
stats()counts live entries at query time - expired-but-unaccessed entries are excluded
Key Takeaways
- A closure is a function plus the cell objects that hold its free variables - the enclosing scope's values persist as long as the inner function is referenced
- CPython implements closures with cell objects: mutable containers shared between the outer function's frame and the inner function's
__closure__tuple - Python classifies every variable as local, enclosing, global, or builtin at compile time - this classification cannot change at runtime
count += 1inside an inner function markscountas local at compile time; reading it before assignment causesUnboundLocalError- the fix isnonlocal countnonlocalreaches into the nearest enclosing scope;globalreaches into the module scope - do not confuse them- Inspect closures with
func.__closure__(cell objects) andfunc.__code__.co_freevars(names of captured variables) - Closures are ideal for factory functions, memoization, partial application, and event handlers with context
- Use a closure for one-method stateful callables; switch to a class when you need multiple methods,
__repr__, or pickling - The loop variable capture bug: closures capture variables (cells), not values - all lambdas in a loop see the loop variable's final value; fix with
lambda i=i: ...or a factory function - Closures hold strong references to captured objects - large objects captured in closures will not be garbage collected, which is a common source of memory leaks in long-running processes
What's Next
Lesson 07 covers pure functions and referential transparency - the mathematical foundation of functional programming. You will learn why pure functions make code easier to test, parallelize, and reason about; how to identify impure functions and refactor them; and how Python's standard library functions are designed with purity in mind.
