Skip to main content

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 UnboundLocalError trap and why it is a compile-time decision, not a runtime one
  • The nonlocal keyword: how it differs from global, 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:

  1. There is a nested function (inner function defined inside an outer function)
  2. The inner function references a variable from the outer function's scope (a free variable)
  3. 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:

  1. You need multiple methods (operations on the same state)
  2. You need __repr__ for debugging
  3. The state management is complex enough to benefit from named attributes
  4. You need inheritance or protocol compliance
  5. 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:

  1. lambda x: x * i in the loop captures i by reference. After the loop, i = n. All lambdas use i = n. Fix: use default argument lambda x, i=i: x * i.

  2. total += value contains an assignment - Python marks total as local to add. At runtime, total + value reads an unassigned local → UnboundLocalError. Fix: nonlocal total.

  3. return logs returns a reference to the internal logs list. The caller can mutate it directly, corrupting the logger's state. Fix: return list(logs) to return a copy, or return logs[-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:

  1. get(key) - returns the cached value or None if not present
  2. set(key, value, ttl_seconds=None) - stores a value with optional expiry
  3. delete(key) - removes a key
  4. clear() - removes all entries
  5. stats() - returns a dict with hits, misses, size
  6. TTL-expired entries are treated as cache misses
  7. 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
  • _hits and _misses are single-element lists to allow mutation without nonlocal
  • 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 += 1 inside an inner function marks count as local at compile time; reading it before assignment causes UnboundLocalError - the fix is nonlocal count
  • nonlocal reaches into the nearest enclosing scope; global reaches into the module scope - do not confuse them
  • Inspect closures with func.__closure__ (cell objects) and func.__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.

© 2026 EngineersOfAI. All rights reserved.