Python Closures Deep Dive Practice Problems & Exercises
Practice: Closures Deep Dive
← Back to lessonEasy
Build a make_multiplier closure factory that returns a function which multiplies any number by a fixed factor. Verify the returned functions are independent objects.
def make_multiplier(factor):
def multiply(x):
return x * factor
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
times_seven = make_multiplier(7)
print(f"double(5): {double(5)}")
print(f"triple(5): {triple(5)}")
print(f"times_seven(4): {times_seven(4)}")
print(f"all are different objects: {double is not triple is not times_seven}")Solution
double(5): 10
triple(5): 15
times_seven(4): 28
all are different objects: True
How it works:
Each call to make_multiplier(factor) creates a fresh stack frame where factor is bound to the argument value. The inner function multiply references factor, so Python stores it in a cell object attached to multiply.__closure__. When make_multiplier returns, the frame is discarded but the cell lives on, keeping the captured value alive.
double.__closure__[0].cell_contentsis2triple.__closure__[0].cell_contentsis3times_seven.__closure__[0].cell_contentsis7
The three function objects are distinct (is not is True) and each has its own independent cell.
Expected Output
double(5): 10
triple(5): 15
times_seven(4): 28
all are different objects: TrueHints
Hint 1: Each call to `make_multiplier` creates a new scope with its own `factor` cell. The returned inner function captures `factor` as a free variable.
Hint 2: Use `fn is not fn2` to verify that each call to the factory produces a distinct function object with its own closure.
Build a make_power factory that returns a function raising any number to a fixed exponent. Inspect __closure__ to confirm what each returned function has captured.
def make_power(exp):
def power(base):
return base ** exp
return power
square = make_power(2)
cube = make_power(3)
print(f"square(4): {square(4)}")
print(f"cube(3): {cube(3)}")
print(f"square free vars: {square.__code__.co_freevars}")
print(f"cube free vars: {cube.__code__.co_freevars}")
print(f"square exp captured: {square.__closure__[0].cell_contents}")
print(f"cube exp captured: {cube.__closure__[0].cell_contents}")Solution
square(4): 16
cube(3): 27
square free vars: ('exp',)
cube free vars: ('exp',)
square exp captured: 2
cube exp captured: 3
Closure cell inspection:
__code__.co_freevars is a tuple of the names of all free variables captured by this function. Since power references only exp from its enclosing scope, co_freevars is ('exp',) for both returned functions.
__closure__ is a tuple of cell objects in the same order as co_freevars. cell_contents retrieves the current value stored in the cell. For square, the cell holds 2; for cube, it holds 3.
The cells are completely independent — changing the exponent in one would not affect the other (if mutation were possible). This independence is the key property that makes closure factories useful.
Expected Output
square(4): 16
cube(3): 27
square free vars: ('exp',)
cube free vars: ('exp',)
square exp captured: 2
cube exp captured: 3Hints
Hint 1: Return an inner function that uses the captured `exp` from the enclosing scope as the exponent.
Hint 2: Inspect `__code__.co_freevars` and `__closure__[0].cell_contents` to verify the captured exponent value.
Build a make_greeter factory that captures a greeting style and a sign-off phrase, returning a personalised greeting function for each style.
def make_greeter(greeting, signoff):
def greet(name):
return f"{greeting}, {name}. {signoff}"
return greet
formal_hello = make_greeter("Good morning", "How may I assist you today?")
informal_hi = make_greeter("Hey", "What's up?")
farewell_formal = make_greeter("Goodbye", "Have a wonderful day.")
farewell_informal = make_greeter("See you later", "")
print(f"formal_hello: {formal_hello('Dr. Smith')}")
print(f"informal_hi: {informal_hi('Alex!')}")
print(f"farewell_formal: {farewell_formal('Dr. Smith')}")
print(f"farewell_informal: {farewell_informal('Alex').rstrip('. ')}")Solution
formal_hello: Good morning, Dr. Smith. How may I assist you today?
informal_hi: Hey, Alex! What's up?
farewell_formal: Goodbye, Dr. Smith. Have a wonderful day.
farewell_informal: See you later, Alex!
Two free variables in one closure:
greet captures both greeting and signoff from the enclosing scope. Inspecting greet.__code__.co_freevars would yield ('greeting', 'signoff') and greet.__closure__ would contain two cells — one for each captured variable.
This shows closures can capture any number of variables from the enclosing scope, not just one. The inner function's signature stays minimal (name only) because all the configurable parts are baked in at factory-call time.
Expected Output
formal_hello: Good morning, Dr. Smith. How may I assist you today?
informal_hi: Hey, Alex! What's up?
farewell_formal: Goodbye, Dr. Smith. Have a wonderful day.
farewell_informal: See you later, Alex!Hints
Hint 1: Capture both the greeting word and the sign-off phrase in the enclosing scope. The returned function captures both as free variables.
Hint 2: The inner function only needs the name as a parameter — all other customisation comes from the captured free variables.
Build a make_threshold_checker factory that returns a function which tests whether a value meets or exceeds a fixed threshold. Verify the free variable name via introspection.
def make_threshold_checker(threshold):
def check(value):
return value >= threshold
return check
is_adult = make_threshold_checker(18)
is_senior = make_threshold_checker(65)
passing_grade = make_threshold_checker(60)
print(f"is_adult(17): {is_adult(17)}")
print(f"is_adult(18): {is_adult(18)}")
print(f"is_senior(64): {is_senior(64)}")
print(f"is_senior(65): {is_senior(65)}")
print(f"passing_grade(59): {passing_grade(59)}")
print(f"passing_grade(60): {passing_grade(60)}")
print(f"is_adult free vars: {is_adult.__code__.co_freevars}")Solution
is_adult(17): False
is_adult(18): True
is_senior(64): False
is_senior(65): True
passing_grade(59): False
passing_grade(60): True
is_adult free vars: ('threshold',)
Pattern: specialised predicates from a generic factory
make_threshold_checker is a generic factory for >= comparisons. Each call locks in a specific threshold, producing a self-contained boolean predicate. This is a clean alternative to writing three separate functions (is_adult, is_senior, passing_grade) with duplicated logic.
co_freevars confirms that threshold is the single free variable captured by check. The pattern generalises easily — you could build factories for <, >, ==, or any other comparison operator by parameterising the operator as well.
Expected Output
is_adult(17): False
is_adult(18): True
is_senior(64): False
is_senior(65): True
passing_grade(59): False
passing_grade(60): True
is_adult free vars: ('threshold',)Hints
Hint 1: The returned function captures `threshold` from the enclosing scope and returns a boolean comparison.
Hint 2: Verify the captured variable name using `__code__.co_freevars` on the returned function.
Medium
Build make_counter with increment and reset operations. Then build make_step_counter that advances by a custom step on each call.
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
def reset():
nonlocal count
count = 0
return count
return increment, reset
def make_step_counter(step):
total = 0
def advance():
nonlocal total
total += step
return total
return advance
inc, rst = make_counter()
print(f"count: {inc()}")
print(f"count: {inc()}")
print(f"count: {inc()}")
print(f"count after reset: {rst()}")
print(f"count: {inc()}")
print(f"count: {inc()}")
by_ten = make_step_counter(10)
print(f"step counter: {by_ten()}")
print(f"step counter: {by_ten()}")
print(f"step counter: {by_ten()}")Solution
count: 1
count: 2
count: 3
count after reset: 0
count: 5
count: 10
step counter: 10
step counter: 20
step counter: 30
Why nonlocal is required:
Without nonlocal count, the line count += 1 would be interpreted as count = count + 1, where the left-hand assignment creates a new local variable named count in increment's frame. Python would then fail with UnboundLocalError because it would try to read count (right side) before it is assigned in the local scope.
nonlocal count tells the compiler to emit LOAD_DEREF and STORE_DEREF instead of LOAD_FAST/STORE_FAST, which reads and writes directly to the shared cell object. Both increment and reset get the same cell, so they observe each other's mutations.
make_step_counter captures two things: step (the fixed increment, from the factory argument) and total (the running sum, initialised in the enclosing scope). Only total needs nonlocal because step is never reassigned.
Expected Output
count: 1
count: 2
count: 3
count after reset: 0
count: 5
count: 10
step counter: 10
step counter: 20
step counter: 30Hints
Hint 1: Use `nonlocal count` inside both `increment` and `reset` — both functions need to write to the shared cell.
Hint 2: Build a second factory `make_step_counter(step)` that captures `step` as a free variable, so each call advances by a fixed amount.
Write a log_calls decorator using closures that logs each call (function name, args, return value) into an attached call_log list. Verify the log entries after two calls.
import functools
def log_calls(func):
call_log = []
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
call_log.append({
"args": args,
"kwargs": kwargs,
"result": result,
})
return result
wrapper.call_log = call_log
return wrapper
@log_calls
def add(a, b):
return a + b
result1 = add(3, 4)
print(f"result: {result1}")
result2 = add(10, 20)
print(f"result: {result2}")
print(f"call_log length: {len(add.call_log)}")
print(f"first log entry type: {type(add.call_log[0]).__name__}")Solution
Calling add with args=(3, 4), kwargs={}
add returned: 7
result: 7
Calling add with args=(10, 20), kwargs={}
add returned: 30
result: 30
call_log length: 2
first log entry type: dict
Closure anatomy of the decorator:
log_calls(func) creates a scope with two variables:
func— the original function, captured from the parameter.call_log— an empty list created in the enclosing scope.
wrapper captures both as free variables. Since call_log is a mutable list, wrapper can call .append() without nonlocal — the list itself is not reassigned, just mutated.
wrapper.call_log = call_log is a technique to expose the closure's internal state for inspection. Because wrapper.call_log and call_log point to the same list object, mutations from inside wrapper are immediately visible through add.call_log.
@functools.wraps(func) copies __name__, __qualname__, __doc__, and __annotations__ from func to wrapper, so add.__name__ stays 'add' rather than becoming 'wrapper'.
Expected Output
Calling add with args=(3, 4), kwargs={}
add returned: 7
result: 7
Calling add with args=(10, 20), kwargs={}
add returned: 30
result: 30
call_log length: 2
first log entry type: dictHints
Hint 1: The wrapper function captures `func` and `call_log` from the enclosing scope. `func` is the original function; `call_log` is a shared mutable list.
Hint 2: Use `functools.wraps(func)` on the wrapper to preserve `__name__`, `__doc__`, and other metadata of the original function.
Build a make_rate_limiter factory that returns a wrapper limiting how many times a function can be called. After the limit is hit, return None and track blocked attempts.
def make_rate_limiter(func, max_calls):
calls_made = 0
calls_blocked = 0
def limited(*args, **kwargs):
nonlocal calls_made, calls_blocked
if calls_made < max_calls:
calls_made += 1
return func(*args, **kwargs)
else:
calls_blocked += 1
return None
limited.calls_made = lambda: calls_made
limited.calls_blocked = lambda: calls_blocked
return limited
def fetch_data(url):
return f"data from {url}"
limited_fetch = make_rate_limiter(fetch_data, max_calls=3)
for i in range(1, 6):
result = limited_fetch("https://api.example.com")
allowed = result is not None
extra = "" if allowed else " (limit exceeded)"
print(f"call {i} allowed{extra}: {allowed}")
print(f"calls_made: {limited_fetch.calls_made()}")
print(f"calls_blocked: {limited_fetch.calls_blocked()}")Solution
call 1 allowed: True
call 2 allowed: True
call 3 allowed: True
call 4 allowed (limit exceeded): False
call 5 allowed (limit exceeded): False
calls_made: 3
calls_blocked: 2
Exposing mutable closure state via lambdas:
Because integers are immutable, calls_made and calls_blocked must use nonlocal (unlike a list which could be mutated directly). To expose their current values as attributes on limited, we attach lambdas: limited.calls_made = lambda: calls_made. Each lambda closes over the same cells as limited itself, so calling limited_fetch.calls_made() always reads the current value from the live cell.
Why not just attach the integers directly? Writing limited.calls_made = calls_made at construction time would copy the integer value 0 into the attribute. Subsequent mutations to the cell would not be reflected. The lambda approach always reads from the live cell.
Real-world pattern: This is the conceptual foundation of token-bucket and sliding-window rate limiters used in API gateways. Production versions replace the simple counter with a timestamp-based sliding window.
Expected Output
call 1 allowed: True
call 2 allowed: True
call 3 allowed: True
call 4 allowed (limit exceeded): False
call 5 allowed (limit exceeded): False
calls_made: 3
calls_blocked: 2Hints
Hint 1: Track two counters in the enclosing scope: `calls_made` (successful) and `calls_blocked` (rejected). Both need `nonlocal`.
Hint 2: The limiter function checks `calls_made < max_calls` before allowing the call. Expose both counters as attributes on the returned function for inspection.
Build make_validator closure that accepts a list of (predicate, message) tuples, returns a function that validates a value against all rules, and collects all failures.
def make_validator(*rules):
def validate(value):
failures = [msg for pred, msg in rules if not pred(value)]
return len(failures) == 0, failures
return validate
check_int = make_validator(
(lambda v: v > 0, "must be positive"),
(lambda v: v % 2 == 0, "must be even"),
(lambda v: v < 100, "must be less than 100"),
)
for value in [42, -1, 200, 50]:
passed, failures = check_int(value)
print(f"{value} passes all: {passed}")
if failures:
print(f"{value} failed rules: {failures}")Solution
42 passes all: True
-1 passes all: False
-1 failed rules: ['must be positive', 'must be even']
200 passes all: False
200 failed rules: ['must be less than 100']
50 passes all: True
Closure captures the rules tuple:
validate captures rules — the tuple of (predicate, message) pairs — from the enclosing scope. At validation time, it iterates over all rules and collects failure messages for any predicate that returns False.
This pattern separates rule definition (at construction time) from rule evaluation (at call time). Each call to make_validator produces a self-contained validator with its own immutable rule set captured in the closure.
Composability: You can build specialised validators by mixing and matching predicates without subclassing or configuration objects. Adding a new rule means passing an additional tuple — no existing code changes.
Why *rules (variadic)? It allows calling make_validator(rule1, rule2, rule3) directly rather than make_validator([rule1, rule2, rule3]), which reads more naturally at the call site.
Expected Output
42 passes all: True
-1 passes all: False
-1 failed rules: ['must be positive', 'must be even']
200 passes all: False
200 failed rules: ['must be less than 100']
50 passes all: TrueHints
Hint 1: Each validator is a tuple of (predicate_function, error_message). Build `make_validator` so the returned function applies all predicates in sequence.
Hint 2: Capture the `rules` list in the enclosing scope. The inner function iterates over rules and collects failures.
Hard
Build make_accumulator closure with add, undo, and reset operations. The accumulator must maintain a history list and support single-step undo.
def make_accumulator():
total = 0
history = []
def add(value):
nonlocal total
total += value
history.append(value)
return total
def undo():
nonlocal total
if not history:
return total
last = history.pop()
total -= last
return total
def reset():
nonlocal total
history.clear()
total = 0
return total
def get_history():
return list(history)
def get_total():
return total
return add, undo, reset, get_history, get_total
add, undo, reset, get_history, get_total = make_accumulator()
print(f"sum after adding 10: {add(10)}")
print(f"sum after adding 25: {add(25)}")
print(f"sum after adding -5: {add(-5)}")
print(f"history: {get_history()}")
print(f"running_sum: {get_total()}")
print(f"undo sum: {undo()}")
print(f"history after undo: {get_history()}")
print(f"after reset: {reset()}")Solution
sum after adding 10: 10
sum after adding 25: 35
sum after adding -5: 30
history: [10, 25, -5]
running_sum: 30
undo sum: 25
history after undo: [10, 25]
after reset: 0
Multiple closures sharing one enclosing scope:
All five inner functions (add, undo, reset, get_history, get_total) are defined inside make_accumulator and share the same enclosing scope. This means they all reference the same total cell and the same history list object.
addandundoboth declarenonlocal totalbecause they reassign it (total += valueandtotal -= lastare reassignments).resetalso needsnonlocal totalbecausetotal = 0would otherwise create a new local.historynever needsnonlocal—.append(),.pop(), and.clear()mutate the list in-place without rebinding the variable.get_historyreturnslist(history)— a snapshot copy — to prevent callers from mutating the internal list directly.
Closure vs class: This accumulator is functionally identical to a class with add, undo, reset, history, and total members. The closure approach avoids class boilerplate and is idiomatic when the state is simple and the operations are few.
Expected Output
sum after adding 10: 10
sum after adding 25: 35
sum after adding -5: 30
history: [10, 25, -5]
running_sum: 30
undo sum: 25
history after undo: [10, 25]
after reset: 0Hints
Hint 1: Maintain two pieces of state in the enclosing scope: `total` (the running sum) and `history` (list of all additions). `total` needs `nonlocal`; `history` does not since you only append to it.
Hint 2: Implement `undo` by checking if `history` is non-empty, then subtracting the last element from `total` and popping it from `history`.
Build a minimal event emitter using closures. Support on(event, handler), off(event, handler), emit(event, *data), and a fire-count tracker.
def make_event_emitter():
handlers = {}
counts = {}
def on(event, handler):
if event not in handlers:
handlers[event] = []
handlers[event].append(handler)
def off(event, handler):
if event in handlers:
try:
handlers[event].remove(handler)
except ValueError:
pass
def emit(event, *args):
counts[event] = counts.get(event, 0) + 1
for handler in list(handlers.get(event, [])):
handler(*args)
def handler_count(event):
return len(handlers.get(event, []))
def get_counts():
return dict(counts)
return on, off, emit, handler_count, get_counts
on, off, emit, handler_count, get_counts = make_event_emitter()
def on_login(user):
print(f"user_login fired: {user} logged in")
def on_logout(user):
print(f"user_logout fired: {user} logged out")
on("user_login", on_login)
on("user_logout", on_logout)
emit("user_login", "alice")
emit("user_login", "bob")
emit("user_logout", "alice")
print(f"event counts: {get_counts()}")
print(f"handler_count for user_login: {handler_count('user_login')}")
off("user_login", on_login)
emit("user_login", "charlie")
print(f"removed user_login handler, fires now: {handler_count('user_login')}")Solution
user_login fired: alice logged in
user_login fired: bob logged in
user_logout fired: alice logged out
event counts: {'user_login': 2, 'user_logout': 1}
handler_count for user_login: 1
removed user_login handler, fires now: 0
Shared mutable state across five closures:
All five inner functions share the same handlers dict and counts dict from the enclosing scope. Because dicts are mutable, none of the inner functions need nonlocal — they mutate in place without rebinding.
oninserts intohandlers[event]list.offremoves from it using.remove(), wrapped in a try/except to silently ignore missing handlers.emititerateslist(handlers.get(event, []))— copying the list first — so that a handler can calloffon itself during emission without causing a "list changed size during iteration" error.countstracks how many timesemitwas called per event, regardless of whether any handlers were registered.
Real-world use: This is the exact pattern used by Node.js EventEmitter, browser addEventListener, and Python's signal module at a conceptual level. The closure-based approach keeps all state private and exposes only the four operation functions.
Expected Output
user_login fired: alice logged in
user_login fired: bob logged in
user_logout fired: alice logged out
event counts: {'user_login': 2, 'user_logout': 1}
handler_count for user_login: 1
removed user_login handler, fires now: 0Hints
Hint 1: Use a dict in the enclosing scope mapping event names to lists of handler functions. `emit` iterates the list and calls each handler.
Hint 2: `on` should append a handler to the event's list. `off` should remove it with `.remove()`. Track fire counts in a separate dict.
Build two decorators: retry(max_attempts) and timed. Stack them on a flaky_service function that fails on its first call. Verify the retry and timing output.
import functools
import time
def retry(max_attempts):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
attempt_tracker = [0]
last_exc = None
while attempt_tracker[0] < max_attempts:
attempt_tracker[0] += 1
print(f"[LOG] attempt {attempt_tracker[0]} for {func.__name__}")
try:
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} succeeded on attempt {attempt_tracker[0]}")
return result
except Exception as exc:
last_exc = exc
raise last_exc
return wrapper
return decorator
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"[TIMING] {func.__name__} took {elapsed:.4f}s")
return result
return wrapper
call_count = [0]
@timed
@retry(max_attempts=3)
def flaky_service():
call_count[0] += 1
if call_count[0] < 2:
raise ConnectionError("connection refused")
return "ok"
result = flaky_service()Solution
[LOG] attempt 1 for flaky_service
[LOG] attempt 2 for flaky_service
[LOG] flaky_service succeeded on attempt 2
[TIMING] flaky_service took 0.0001s
(Timing value will vary; what matters is the pattern.)
Decorator stacking order:
@timed is applied last (outermost), so it wraps the already-retried function. The call chain is:
timed_wrapper
retry_wrapper
flaky_service (original)
When flaky_service() is called:
timed_wrapperstarts the clock.timed_wrappercallsretry_wrapper.retry_wrapperattemptsflaky_service— fails on attempt 1.retry_wrapperretries — succeeds on attempt 2.retry_wrapperreturns"ok"totimed_wrapper.timed_wrapperstops the clock and prints the elapsed time.
Closure state in each decorator:
retry'swrappercapturesfuncandmax_attemptsas free variables.attempt_trackeris a local list used as a mutable integer to avoidnonlocal.timed'swrappercapturesfunc.startis a plain local variable — it is recreated fresh on every call.
functools.wraps: Applied to both wrappers so flaky_service.__name__ stays 'flaky_service' throughout the stack. Without it, both the retry log and timing log would print the decorator's wrapper name instead.
Expected Output
[LOG] attempt 1 for flaky_service
[LOG] attempt 2 for flaky_service
[LOG] flaky_service succeeded on attempt 2
[TIMING] flaky_service tookHints
Hint 1: Build `retry(max_attempts)` and `timed` as separate decorator factories. Each uses closures to capture state (`attempts`, timing data).
Hint 2: When stacking decorators `@timed` on top of `@retry(3)`, the outermost decorator (`timed`) wraps the already-retried function. Timing measures the total time including all retry attempts.
