Python Closures Intro Practice Problems & Exercises
Practice: Closures Intro
← Back to lessonEasy
Create two simple closure factories and verify that each returned function captures its own independent copy of the enclosing variable.
def make_greeter(name):
def greet():
return f"Hello, {name}!"
return greet
def make_adder(n):
def add(x):
return x + n
return add
greet_alice = make_greeter("Alice")
greet_bob = make_greeter("Bob")
print(f"greet_alice: {greet_alice()}")
print(f"greet_bob: {greet_bob()}")
add5 = make_adder(5)
add10 = make_adder(10)
print(f"add5(3): {add5(3)}")
print(f"add10(3): {add10(3)}")
print(f"add5 and add10 are different objects: {add5 is not add10}")Solution
greet_alice: Hello, Alice!
greet_bob: Hello, Bob!
add5(3): 8
add10(3): 13
add5 and add10 are different objects: True
How it works:
make_greeter("Alice")creates a new scope wherename = "Alice". The innergreetfunction capturesnameas a free variable. Whenmake_greeterreturns, the scope is destroyed, but the value ofnameis preserved in a cell object attached togreet.make_greeter("Bob")creates a completely separate scope withname = "Bob", producing an independent closure.- Same pattern for
make_adder: each call creates a new cell holding the specific value ofn. - The two closures are different function objects (
add5 is not add10isTrue), each with their own__closure__cells.
Expected Output
greet_alice: Hello, Alice!
greet_bob: Hello, Bob!
add5(3): 8
add10(3): 13
add5 and add10 are different objects: TrueHints
Hint 1: A closure is an inner function that captures a variable from its enclosing scope. The captured variable is called a free variable.
Hint 2: Each call to the outer function creates a new scope with its own value, so `make_greeter("Alice")` and `make_greeter("Bob")` produce independent closures.
Classify each variable as local, free, or global. In the code below, determine which category x, y, and z belong to from the perspective of inner.
z = "global_z"
def outer():
x = "enclosing_x"
def inner():
y = "local_y"
return x + y + z
return inner
fn = outer()
# Inspect inner's variable classification
print(f"inner free vars: {fn.__code__.co_freevars}")
print(f"x is free (not local, not global): {'x' in fn.__code__.co_freevars}")
print(f"y is local to inner: {'y' in fn.__code__.co_varnames}")
print(f"z is global: {'z' in globals()}")Solution
inner free vars: ('x',)
x is free (not local, not global): True
y is local to inner: True
z is global: True
Variable classification from inner's perspective:
| Variable | Category | Why |
|---|---|---|
x | Free variable | Defined in outer (enclosing scope), referenced in inner, stored in a closure cell |
y | Local variable | Defined by assignment inside inner, listed in co_varnames |
z | Global variable | Defined at module level, accessed via LOAD_GLOBAL bytecode |
Only x appears in co_freevars because it is the only variable that comes from an enclosing function scope. z is accessed via the global namespace (LEGB rule: the G), not through a closure cell. y is a plain local variable created by assignment within inner.
Expected Output
inner free vars: ('x',)
x is free (not local, not global): True
y is local to inner: True
z is global: TrueHints
Hint 1: A free variable is one that is used inside a function but defined in an enclosing (not global) scope. It is neither a local variable nor a global.
Hint 2: Use `__code__.co_freevars` to see free variables, `__code__.co_varnames` for locals, and check the global namespace with `"z" in globals()`.
Inspect the closure internals of a function that captures two free variables. Also verify that a plain function (no enclosing scope) has __closure__ set to None.
def make_multiplier(factor, label):
def multiply(x):
return x * factor
multiply.label = label
return multiply
triple = make_multiplier(3, "triple")
# Inspect closure
print(f"Has closure: {triple.__closure__ is not None}")
print(f"Number of cells: {len(triple.__closure__)}")
print(f"Free var names: {triple.__code__.co_freevars}")
for i, name in enumerate(triple.__code__.co_freevars):
val = triple.__closure__[i].cell_contents
print(f"cell {i} ({name}): {val}")
print(f"triple(10): {triple(10)}")
# A plain function has no closure
def plain_add(a, b):
return a + b
print(f"No closure (plain function): {plain_add.__closure__ is None}")Solution
Has closure: True
Number of cells: 2
Free var names: ('factor', 'label')
cell 0 (factor): 3
cell 1 (label): triple
triple(10): 30
No closure (plain function): True
Understanding the output:
triple.__closure__is a tuple of two cell objects becausemultiplyreferences two variables from its enclosing scope:factorandlabel.triple.__code__.co_freevarsis('factor', 'label')— these names correspond positionally to the cells in__closure__.triple.__closure__[0].cell_contentsis3(the value offactor), andtriple.__closure__[1].cell_contentsis"triple"(the value oflabel).plain_addhas no enclosing scope variables, so__closure__isNone.
Note that label is captured even though it is only used to set an attribute on the function object, not inside the body of multiply. CPython captures any variable referenced in the inner function's scope during construction.
Expected Output
Has closure: True
Number of cells: 2
Free var names: ('factor', 'label')
cell 0 (factor): 3
cell 1 (label): triple
triple(10): 30
No closure (plain function): TrueHints
Hint 1: `__closure__` is a tuple of cell objects. Each cell has a `cell_contents` attribute holding the captured value.
Hint 2: `__code__.co_freevars` lists the variable names in the same order as the cells in `__closure__`.
Predict the output and explain why one function has a closure and the other does not.
message = "global"
def make_closure():
message = "enclosing"
def fn():
return message
return fn
def make_non_closure():
def fn():
return message
return fn
fn1 = make_closure()
fn2 = make_non_closure()
print(f"fn1(): {fn1()}")
print(f"fn2(): {fn2()}")
print(f"fn1 has closure: {fn1.__closure__ is not None}")
print(f"fn2 has closure: {fn2.__closure__ is None}")Solution
fn1(): enclosing
fn2(): global
fn1 has closure: True
fn2 has closure: False
Why the difference:
-
fn1is a true closure. Insidemake_closure, the localmessage = "enclosing"shadows the global. Whenfnreferencesmessage, Python finds it in the enclosing scope (the E in LEGB) and creates a closure cell.fn1.__closure__contains one cell holding"enclosing". -
fn2is NOT a closure. Insidemake_non_closure, there is no localmessage. Whenfnreferencesmessage, Python finds it in the global scope (the G in LEGB). No closure cell is needed —LOAD_GLOBALhandles this.fn2.__closure__isNone.
The LEGB rule determines where Python looks: Local, then Enclosing, then Global, then Built-in. A closure cell is only created when a variable is found in the Enclosing scope.
Expected Output
fn1(): enclosing
fn2(): global
fn1 has closure: True
fn2 has closure: FalseHints
Hint 1: A closure captures variables from an enclosing function scope. A function that only reads module-level globals does NOT have a closure.
Hint 2: Check `__closure__` — it will be `None` for a function that only accesses globals.
Medium
Predict the output of the buggy loop closure. Then verify that all closures share the same cell object by comparing their __closure__ entries.
funcs = []
for i in range(5):
def f():
return i
funcs.append(f)
print("--- Buggy version ---")
for idx in range(5):
print(f"funcs[{idx}](): {funcs[idx]()}")
print("--- All share same cell? ---")
cells = [f.__closure__[0] for f in funcs]
all_same = all(c is cells[0] for c in cells)
print(f"All closures reference the same cell: {all_same}")Solution
--- Buggy version ---
funcs[0](): 4
funcs[1](): 4
funcs[2](): 4
funcs[3](): 4
funcs[4](): 4
--- All share same cell? ---
All closures reference the same cell: True
Why all return 4:
The for loop does not create a new scope per iteration. There is one variable i in the enclosing scope, and all five closures share a reference to the same cell object holding i. After the loop completes, i == 4. Every closure looks up i at call time, finding 4.
The key insight: closures capture variables by reference, not by value. The five function objects are distinct (funcs[0] is not funcs[1]), but they all point to the same cell. Calling cells[0] is cells[1] confirms this — it is True for every pair.
This is the most common closure bug in Python and appears frequently in interviews. The fix is to create a snapshot of the loop variable at definition time (see the next problem).
Expected Output
--- Buggy version ---
funcs[0](): 4
funcs[1](): 4
funcs[2](): 4
funcs[3](): 4
funcs[4](): 4
--- All share same cell? ---
All closures reference the same cell: TrueHints
Hint 1: All closures created inside the loop capture the same variable `i` — they hold a reference to the cell, not a snapshot of the value.
Hint 2: After the loop finishes, `i` is 4. When any closure is called, it looks up the current value of `i` in the shared cell.
Implement all three fixes for the late binding bug. Each approach should produce functions that correctly return 0, 1, 2, 3, 4 respectively.
from functools import partial
# Fix 1: Default argument capture
fix1 = []
for i in range(5):
def f(i=i):
return i
fix1.append(f)
print("--- Fix 1: default argument ---")
print(" ".join(str(f()) for f in fix1))
# Fix 2: Factory function
def make_f(val):
def f():
return val
return f
fix2 = [make_f(i) for i in range(5)]
print("--- Fix 2: factory function ---")
print(" ".join(str(f()) for f in fix2))
# Fix 3: functools.partial
def return_val(val):
return val
fix3 = [partial(return_val, i) for i in range(5)]
print("--- Fix 3: functools.partial ---")
print(" ".join(str(f()) for f in fix3))Solution
--- Fix 1: default argument ---
0 1 2 3 4
--- Fix 2: factory function ---
0 1 2 3 4
--- Fix 3: functools.partial ---
0 1 2 3 4
How each fix works:
| Fix | Mechanism | Closure involved? |
|---|---|---|
def f(i=i) | Default argument is evaluated at def-time; i becomes a local parameter | No — i is a local, not a free variable |
make_f(val) | Each call creates a new scope with its own val cell | Yes — val is a free variable in a new cell per call |
partial(f, i) | Binds i as a frozen argument at creation time | No — value is stored in the partial object, not a cell |
Which to prefer:
- Default argument — simplest for quick fixes, but the extra parameter can confuse readers. Also changes the function's signature.
- Factory function — most explicit and Pythonic. Clearly communicates intent. Best for production code.
functools.partial— cleanest when the function already exists and you just need to bind an argument.
Expected Output
--- Fix 1: default argument ---
0 1 2 3 4
--- Fix 2: factory function ---
0 1 2 3 4
--- Fix 3: functools.partial ---
0 1 2 3 4Hints
Hint 1: Default argument `def f(i=i):` evaluates `i` at definition time, creating a local parameter with a snapshot value — no closure cell involved.
Hint 2: A factory function `def make_f(val):` creates a new scope per call, each with its own `val` cell. `functools.partial(f, i)` binds `i` immediately.
Demonstrate the difference between assignment with and without nonlocal. Then build a counter with both increment and reset functions that share a cell.
# Without nonlocal — assignment creates a new local
def without_nonlocal():
x = 10
def inner():
x = 20 # new local, does NOT touch outer x
print(f"inner x: {x}")
inner()
print(f"outer x: {x}")
print("--- Without nonlocal ---")
without_nonlocal()
# With nonlocal — assignment modifies the enclosing variable
def with_nonlocal():
x = 10
def inner():
nonlocal x
x = 20 # modifies outer x via shared cell
print(f"inner x: {x}")
inner()
print(f"outer x: {x}")
print("--- With nonlocal ---")
with_nonlocal()
# Practical example: counter with reset
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
def reset():
nonlocal count
count = 0
return increment, reset
print("--- Counter demo ---")
inc, rst = make_counter()
print(inc())
print(inc())
print(inc())
rst()
print(f"After reset: {inc()}")Solution
--- Without nonlocal ---
inner x: 20
outer x: 10
--- With nonlocal ---
inner x: 20
outer x: 20
--- Counter demo ---
1
2
3
After reset: 1
Without nonlocal:
The line x = 20 inside inner creates a brand-new local variable named x on inner's stack frame. This local x shadows the outer x. After inner() returns, outer's x is still 10 because it was never touched.
With nonlocal:
The nonlocal x declaration tells Python's compiler to emit STORE_DEREF instead of STORE_FAST. This writes the value 20 into the shared cell object that both with_nonlocal and inner reference. After inner() returns, the outer x is now 20.
Counter with shared cell:
Both increment and reset declare nonlocal count, so they share the same cell object. When reset sets count = 0, the cell's contents change to 0, and the next call to increment reads 0 from the cell, increments to 1, and returns 1.
Expected Output
--- Without nonlocal ---
inner x: 20
outer x: 10
--- With nonlocal ---
inner x: 20
outer x: 20
--- Counter demo ---
1
2
3
After reset: 1Hints
Hint 1: Without `nonlocal`, assignment `x = 20` inside the inner function creates a new local variable, shadowing the outer one.
Hint 2: With `nonlocal x`, the assignment writes through the shared cell object, modifying the enclosing scope variable.
Build a make_range_validator factory that returns a validation function. The validator should return the value if in range, or raise ValueError otherwise. Inspect the closure cells to verify captured bounds.
def make_range_validator(min_val, max_val):
def validate(value):
if not (min_val <= value <= max_val):
raise ValueError(
f"Value {value} outside [{min_val}, {max_val}]"
)
return value
return validate
validate_age = make_range_validator(0, 150)
validate_pct = make_range_validator(0.0, 1.0)
validate_port = make_range_validator(1, 65535)
# Happy paths
print(f"validate_age(25): {validate_age(25)}")
print(f"validate_pct(0.75): {validate_pct(0.75)}")
print(f"validate_port(8080): {validate_port(8080)}")
# Error paths
for name, fn, bad_val in [
("validate_age", validate_age, 200),
("validate_pct", validate_pct, -0.1),
("validate_port", validate_port, 70000),
]:
try:
fn(bad_val)
except ValueError as e:
print(f"{name}({bad_val}): ERROR - {e}")
# Inspect closure cells
print(f"age free vars: {validate_age.__code__.co_freevars}")
for i, name in enumerate(validate_age.__code__.co_freevars):
val = validate_age.__closure__[i].cell_contents
print(f"age {name} captured: {val}")Solution
validate_age(25): 25
validate_pct(0.75): 0.75
validate_port(8080): 8080
validate_age(200): ERROR - Value 200 outside [0, 150]
validate_pct(-0.1): ERROR - Value -0.1 outside [0.0, 1.0]
validate_port(70000): ERROR - Value 70000 outside [1, 65535]
age free vars: ('max_val', 'min_val')
age min captured: 0
age max captured: 150
How the factory works:
Each call to make_range_validator(min_val, max_val) creates a new scope with its own min_val and max_val cells. The inner validate function captures both as free variables. When the factory returns, these cells are attached to validate.__closure__.
validate_agehas cells holding0and150.validate_pcthas cells holding0.0and1.0.validate_porthas cells holding1and65535.
These are completely independent — modifying one validator's behavior (if it were possible) would not affect the others. This is the power of closures as lightweight, stateful objects.
Note on co_freevars ordering: CPython lists free variables in alphabetical order, so you see ('max_val', 'min_val') rather than the parameter order. The cells in __closure__ match this alphabetical ordering.
Expected Output
validate_age(25): 25
validate_pct(0.75): 0.75
validate_port(8080): 8080
validate_age(200): ERROR - Value 200 outside [0, 150]
validate_pct(-0.1): ERROR - Value -0.1 outside [0.0, 1.0]
validate_port(70000): ERROR - Value 70000 outside [1, 65535]
age free vars: ('max_val', 'min_val')
age min captured: 0
age max captured: 150Hints
Hint 1: A function factory returns a new function customized by its arguments. The returned function captures those arguments as free variables in closure cells.
Hint 2: Each call to the factory creates independent cells. `validate_age` and `validate_port` have completely separate `min_val` and `max_val` values.
Hard
Build a bank account using only closures — no classes. Support deposit, withdraw (with overdraft protection), balance, and history operations. Return a dispatch function that routes operations by name.
def make_account(initial_balance=0):
balance = initial_balance
history = []
def deposit(amount):
nonlocal balance
balance += amount
history.append(f"deposit:{amount}")
return balance
def withdraw(amount):
nonlocal balance
if amount > balance:
return None # overdraft rejected
balance -= amount
history.append(f"withdraw:{amount}")
return balance
def get_balance():
return balance
def get_history():
return list(history) # return a copy
ops = {
"deposit": deposit,
"withdraw": withdraw,
"balance": get_balance,
"history": get_history,
}
def dispatch(operation, *args):
if operation not in ops:
raise ValueError(f"Unknown operation: {operation}")
return ops[operation](*args)
return dispatch
# Usage
account = make_account()
print(f"balance: {account('deposit', 100)}")
print(f"balance: {account('deposit', 150)}")
print(f"balance: {account('withdraw', 50)}")
print(f"history: {account('history')}")
result = account("withdraw", 500)
print(f"Overdraft rejected: {result is None}")
print(f"balance after rejected: {account('balance')}")Solution
balance: 100
balance: 250
balance: 200
history: ['deposit:100', 'deposit:150', 'withdraw:50']
Overdraft rejected: True
balance after rejected: 200
Closure-based encapsulation:
This pattern uses closures to achieve what a class would normally provide:
| Class concept | Closure equivalent |
|---|---|
self.balance | balance variable in enclosing scope |
self.history | history list in enclosing scope |
def deposit(self, amount) | deposit inner function with nonlocal balance |
account.deposit(100) | account("deposit", 100) via dispatch |
Key details:
balancerequiresnonlocalbecausedepositandwithdrawreassign it (balance += amountis reassignment).historydoes NOT requirenonlocalbecause.append()mutates the existing list without reassigning the variable itself.get_historyreturnslist(history)— a copy — to prevent external code from mutating the internal history.- The dispatch dict
opsis itself a closure variable, capturing all four inner functions.
When to use this pattern: When you need 1-3 operations on 1-2 pieces of state and a full class feels like overkill. Decorators, event handlers, and configuration builders are natural fits.
Expected Output
balance: 100
balance: 250
balance: 200
history: ['deposit:100', 'deposit:150', 'withdraw:50']
Overdraft rejected: True
balance after rejected: 200Hints
Hint 1: Use a dict or multiple inner functions to expose different operations. A dispatch function that accepts an operation name is a clean pattern.
Hint 2: All inner functions share the same enclosing scope. Use `nonlocal` to mutate the balance. The history list is mutable, so you can append without `nonlocal`.
Build a memoize decorator using closures. The decorator should cache results, expose the cache for inspection, and track how many times the original function was actually called (cache misses).
def memoize(func):
cache = {}
call_count = [0] # mutable container to track actual calls
def wrapper(*args):
if args not in cache:
call_count[0] += 1
cache[args] = func(*args)
return cache[args]
wrapper.cache = cache
wrapper.call_count = call_count
return wrapper
@memoize
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
# Compute fibonacci values
print(f"fib(10): {fib(10)}")
print(f"fib(30): {fib(30)}")
print(f"Call count: {fib.call_count[0]}")
print(f"Cache size: {len(fib.cache)}")
print(f"Cache contains fib(10): {(10,) in fib.cache}")
print(f"fib(10) from cache: {fib(10)}")Solution
fib(10): 55
fib(30): 832040
Call count: 31
Cache size: 31
Cache contains fib(10): True
fib(10) from cache: 55
How the closure-based memoization works:
memoize(fib)creates a new scope withcache = {}andcall_count = [0].wrappercaptures both as free variables in its closure cells.- On each call,
wrapperchecks if the arguments are already incache. If yes, return the cached result (cache hit). If no, call the original function, store the result, increment the counter (cache miss). wrapper.cacheandwrapper.call_countexpose the closure internals for debugging.
Why call count is 31 (not 31+):
fib(10)computesfib(0)throughfib(10)— 11 unique calls.fib(30)needsfib(11)throughfib(30)— 20 more unique calls (0-10 are already cached).- Total: 31 unique function invocations. The second
fib(10)call is a pure cache hit.
Why call_count uses a list: We use call_count = [0] instead of call_count = 0 to avoid needing nonlocal. Mutating call_count[0] += 1 modifies the list contents without reassigning the variable itself. Using nonlocal call_count with a plain integer would also work.
This is exactly how functools.lru_cache works conceptually, minus the LRU eviction policy.
Expected Output
fib(10): 55
fib(30): 832040
Call count: 31
Cache size: 31
Cache contains fib(10): True
fib(10) from cache: 55Hints
Hint 1: The cache dict lives in the closure of the wrapper function. Each decorated function gets its own independent cache.
Hint 2: Track call count using a mutable container (list with one element) or `nonlocal` to count actual function invocations vs cache hits.
Demonstrate that closures keep captured objects alive and that deleting the closure allows garbage collection. Then show that when two closures share a cell, the captured object stays alive until ALL closures referencing it are deleted.
import weakref
import gc
class HeavyData:
"""A class we can weak-reference to track GC."""
def __init__(self, size):
self.items = list(range(size))
def __len__(self):
return len(self.items)
# --- Closure keeps data alive ---
print("--- Closure keeps data alive ---")
def make_accessor(data):
def get_size():
return len(data)
return get_size
obj = HeavyData(1000)
weak = weakref.ref(obj)
accessor = make_accessor(obj)
del obj # remove the direct reference
gc.collect()
print(f"Data alive while closure exists: {weak() is not None}")
print(f"Data size via closure: {accessor()}")
del accessor # remove the closure
gc.collect()
print(f"After deleting closure, data collected: {weak() is None}")
# --- Shared cells between closures ---
print("--- Shared cells between closures ---")
def make_dual_accessor(data):
def get_item(i):
return data.items[i]
def get_length():
return len(data)
return get_item, get_length
obj2 = HeavyData(1000)
weak2 = weakref.ref(obj2)
get_item, get_length = make_dual_accessor(obj2)
del obj2
gc.collect()
print(f"get_item(0): {get_item(0)}")
print(f"get_length(): {get_length()}")
del get_item
gc.collect()
print(f"Deleted get_item, get_length still works: {get_length() == 1000}")
print(f"Data still alive (held by get_length): {weak2() is not None}")
del get_length
gc.collect()
print(f"Deleted get_length too, data collected: {weak2() is None}")Solution
--- Closure keeps data alive ---
Data alive while closure exists: True
Data size via closure: 1000
After deleting closure, data collected: True
--- Shared cells between closures ---
get_item(0): 0
get_length(): 1000
Deleted get_item, get_length still works: True
Data still alive (held by get_length): True
Deleted get_length too, data collected: True
Closure lifetime and garbage collection:
-
Single closure:
make_accessor(obj)capturesdata(which points to ourHeavyDataobject) in a closure cell. Afterdel obj, the only remaining reference to theHeavyDatainstance is inside the closure cell. The weak reference confirms the object is still alive. Only when wedel accessor(destroying the closure and its cells) can theHeavyDatabe garbage collected. -
Shared cell between two closures:
make_dual_accessorreturns two closures that share the same cell fordata. Afterdel obj2, the object survives because both closures hold it via the shared cell. Afterdel get_item, one closure is gone butget_lengthstill holds the cell — the object survives. Only afterdel get_lengthremoves the last reference to the cell is theHeavyDatacollected.
Production implications:
- Closures stored in long-lived structures (event registries, global caches, class attributes) keep their captured objects alive indefinitely.
- If a closure captures a large object (a dataset, a database connection, a loaded ML model), that object cannot be freed until every closure referencing its cell is deleted.
- Mitigation strategies: capture only the specific fields you need (not the whole object), use
weakref.reffor objects that should not be kept alive by the closure alone, and explicitly remove closures from registries when done.
Expected Output
--- Closure keeps data alive ---
Data alive while closure exists: True
Data size via closure: 1000
After deleting closure, data collected: True
--- Shared cells between closures ---
get_item(0): 0
get_length(): 1000
Deleted get_item, get_length still works: True
Data still alive (held by get_length): True
Deleted get_length too, data collected: TrueHints
Hint 1: A closure holds a reference to its cell objects. As long as any closure referencing a cell is alive, the value in that cell cannot be garbage collected.
Hint 2: Use `weakref.ref` to observe when the underlying object is actually collected. When the weak reference returns `None`, the object has been garbage collected.
