The Closure Problem AI Keeps Getting Wrong: Root Cause and Fixes
Hello!
Today's topic is the "closure problem."
Have you ever defined a function inside a loop in Python?
If so, you may have run into some rather puzzling behavior.
In this article, we explain the "closure problem" — something every Python programmer runs into at least once — in a way that beginners can follow.
What is a closure?
First of all, what exactly is a "closure"?
A closure is a mechanism by which a function remembers, and carries with it, the variables from the scope in which it was defined.
Breaking that down a bit, there are two key points:
- The inner function can use the outer function's variables
- Even after the outer function finishes, those variables stay alive
Let's compare an ordinary function with one that uses a closure.
Comparison with an ordinary function
First, the ordinary function:
def add(x, y):
return x + y
print(add(3, 5)) # 8
print(add(3, 7)) # 10
→ You have to pass both x and y every single time.
A function using a closure
Here is the version that uses a closure:
def make_adder(x):
def add(y):
return x + y # Can access the outer x!
return add
add_3 = make_adder(3) # Create a function that "remembers" 3
print(add_3(5)) # 8
print(add_3(7)) # 10
make_adder has finished executing, but the inner add still remembers x=3, so
we were able to create a dedicated "add 3" function.
Here is a picture of how the closure works:
make_adder(3)
└─ add(y): return x + y
↑
x=3 is captured inside (the closure)
→ The mental image is "a function that carries its environment along with it."
Now let's look at a practical example: a counter.
A practical example: a counter
def make_counter(name):
count = 0
def increment():
nonlocal count
count += 1
print(f"{name}: count {count}")
return count
return increment
coffee_counter = make_counter("coffee")
tea_counter = make_counter("tea")
coffee_counter() # coffee: count 1
coffee_counter() # coffee: count 2
tea_counter() # tea: count 1
As you can see, each counter remembers its own independent count as it runs, soholding multiple pieces of state effortlessly is where closures shine.
What is the closure problem?
Closures are handy — so what exactly is the "closure problem"?
Let's look at how the problem actually shows up.
An example of the problem
Look at the code below and run it in your head.
Right — the loop variable i takes the values 0, 1, and 2.
Next, we append the click_handler function, which references the variable i, to a list called buttons. Note that we are storing the function itself, not the result of calling it.
buttons = []
for i in range(3):
def click_handler():
print(f"Button {i} was clicked")
buttons.append(click_handler)
buttons[0]() # Button 2 was clicked ← should have been 0
buttons[1]() # Button 2 was clicked ← should have been 1
buttons[2]() # Button 2 was clicked
buttons[0] holds the very first click_handler function we appended, so we invoke it with buttons[0]() (a little tricky, perhaps).
And the result of running it? It prints "Button 2 was clicked."
Wait — it was the first one we appended, so shouldn't it say "Button 0 was clicked"?
That reaction is exactly the "closure problem."
In this sample, every call ends up printing "Button 2."
Why does this happen?
- Python closures capture the variable itself (a reference)
- The value is not copied; instead, the variable i itself is what gets referenced.
- When the loop ends,
i=2has become 2, so every function ends up seeing2.
That is what is going on.
An example from a real project
import asyncio
async def send_requests():
tasks = []
endpoints = ["server0", "server1", "server2"]
for i, endpoint in enumerate(endpoints):
async def make_request(endpoint=endpoint, i=i): # ← fixed
print(f"Sending request: {endpoint} (server {i})")
# Put the actual API call here
tasks.append(make_request()) # Create the coroutine and add it
await asyncio.gather(*tasks)
For instance, the code above looks like it should work, but when you run it...
Sending request: server2 (server 2)
Sending request: server2 (server 2)
Sending request: server2 (server 2)
Every request ends up going to the last server (server2).
That is a truly nasty bug.
Why does it become a bug?
- A new
make_requestis defined on every loop iteration, butevery function references the sameiandendpointvariables. - The functions run only after the loop has finished
- By that point,
i=2,endpoint="server2"holds the final values - As a result, everything comes out as "server2"
In other words, this is the problem of closures lazily evaluating the loop variable. This lazy evaluation is the very essence of the closure problem.
Solutions
So how do we solve it?
Here are several approaches.
Let's look at each solution, referring back to the example below.
buttons = []
for i in range(3):
def click_handler():
print(f"Button {i} was clicked")
buttons.append(click_handler)
buttons[0]() # Button 2 was clicked ← should have been 0
buttons[1]() # Button 2 was clicked ← should have been 1
buttons[2]() # Button 2 was clicked
Code containing the closure problem
Method 1: Use a default argument
With this method, you pass the loop variable as a default value for a function parameter, freezing the value at function-definition time.
For example, you write it like this:
buttons = []
for i in range(3):
def click_handler(button_id=i):
print(f"Button {button_id} was clicked")
buttons.append(click_handler)
This way, even if i changes later, button_id holds the value it had at definition time.
Its appeal is brevity, but it adds a parameter that does not belong to the function's true signature, which makes the code's intent a little harder to read.
Method 2: Use a closure factory (recommended)
With this method, you define a "function that returns a function" (a factory) and capture the loop variable inside it.
def make_click_handler(button_id):
def handler():
print(f"Button {button_id} was clicked")
return handler
buttons = [make_click_handler(i) for i in range(3)]
In this pattern, make_click_handler creates a function that holds each individual value, so the correct value is used even after the loop ends.
The intent — "create a function with this value frozen in" — is explicit, and misuse fails fast with an error, making this the most recommended method from a safety and maintainability standpoint.
Method 3: lambda + default argument
With this method, you pass the loop variable as a default argument to an anonymous lambda function, generating the function on the spot.
buttons = [lambda x=i: print(f"Button {x} was clicked") for i in range(3)]
The upside is extreme brevity, but readability suffers, and the moment the logic gets complicated it becomes hard to follow.
It is handy for small scripts and throwaway code, but best avoided in production code and team development.
Method 4: Use functools.partial
With this method, you use functools.partial to create "a new function with some arguments pre-applied."
from functools import partial
def click_handler(button_id):
print(f"Button {button_id} was clicked")
buttons = [partial(click_handler, i) for i in range(3)]
partial(click_handler, i) generates a function with button_id=i fixed in place, which is then stored in the list.
The intent is clear and the approach is close to functional programming, but it can be a little hard for beginners to grasp, and it requires an extra import.
Comparison of the solutions
| Method | Approach | Pros | Cons | Recommendation |
|---|---|---|---|---|
| Method 1: Default argument | Pass the loop variable as a default argument, freezing its value at that point | - Simple and short - A Pythonic idiom | - An extra parameter appears in the function - Accidental overrides are easy to miss - Can confuse type checkers | ★3 |
| Method 2: Closure factory | Define a "function that returns a function" and capture the value in a closure | - The value is safely encapsulated - Clear intent, highly readable - Misuse fails fast with an error - Plays well with type checkers | - Slightly longer code - Requires an extra function definition | ★5 |
| Method 3: lambda + default argument | Create an anonymous function with a lambda, passing the loop variable as a default argument | - The shortest to write - Handy for small tasks | - Poor readability - Unsuited to complex logic - Same default-argument drawbacks as Method 1 | ★2 |
| Method 4: functools.partial | Use partial to generate a new function with arguments pre-applied | - Clear intent, functional-programming style - Arguments are fixed cleanly | - Slightly hard for beginners - Requires an extra import | ★3 |
Patterns useful in real work
We have now seen how closures work and the typical pitfall. Next, let me introduce closure patterns that are useful in real projects.
Closures really shine in situations like these:
- When you want simple state management
- When you want to generate behavior dynamically and stay flexible
- When a full class would be overkill, but you want a small "remembering mechanism"
As concrete examples, let's look at two patterns: a "progress reporter" and "dynamic validation." Both are applications of Method 2, the closure factory!
Progress reporter
In this pattern, we dynamically generate a function that tracks the progress of a task.
def create_progress_reporter(server_name, total_tasks):
completed = 0
def report_progress(task_name):
nonlocal completed
completed += 1
percent = (completed / total_tasks) * 100
print(f"[{server_name}] {task_name} done ({percent:.1f}%)")
return completed == total_tasks
return report_progress
create_progress_reporterreturns areport_progressfunction that remembers the outercompleted, so it can update the progress on every call.- You can create an independent progress counter per server, which is convenient for monitoring multiple tasks in parallel.
Practical uses
Easily track per-server task progress, file-processing progress, batch-job completion rates, and more.
→ Normally this would require an external class or state management, but closures let you express it concisely.
Dynamic validation
Next is a pattern that generates handlers with different validation logic for each input field.
def create_validation_handler(field_name, validator_func):
def handler(event):
value = event.get('value')
if not validator_func(value):
print(f"Validation error: invalid value for {field_name}: {value}")
return False
print(f"✓ {field_name}: OK")
return True
return handler
- By capturing the field name (
field_name) and the validation logic (validator_func) in the closure, you can create an independent handler for each field. - For example, checks like "does the email contain @?", "is the age between 0 and 150?", and "is the name non-empty?" can each be turned into a dedicated function.
Practical uses
Form-input validation, API input checks, log-data validation, and so on.
→ You can loop over a list of fields and auto-generate the handlers, which eliminates duplication and greatly improves maintainability.
Tips for AI pair programming
Finally, let me share some tips for getting AI code generators to handle the closure problem correctly.
These days, programming with code-generation AI such as ChatGPT, Claude, and Gemini is the norm — and as this article's title suggests, AI quite often produces code containing the closure problem.
An example of AI getting it wrong
handlers = []
for i, item in enumerate(items):
def handler():
process(i, item) # Danger! Every handler ends up using the last i
handlers.append(handler)
Cases like this — "define a function inside a loop and use the loop variable directly" — are a pattern AI gets wrong surprisingly often.
AI also tends to favor "short code that runs," so even when no bug results, hard-to-read or hard-to-modify code can easily slip in.
So before letting AI write your code, you need to give it proper guidance.
Crafting instructions for AI
The guidance itself is simple.
When having AI write code, adding
"use a closure factory to avoid the closure problem"
to your prompt improves the results considerably.
Example of a good instruction
Write code that attaches event handlers to multiple buttons.
To avoid the closure problem, use the closure factory pattern.
An even more specific instruction
When defining functions inside a for loop, always implement them with a
closure factory to avoid the lazy-evaluation problem with loop variables.
Instructions at this level are enough to raise the quality of the generated code.
Checkpoints for code review
In addition, when reviewing AI-generated code, checking the following will give you peace of mind:
- Does any function defined inside a loop reference
ioritemdirectly? - Are closures being created in asynchronous code (
async def)? - Is a default argument being used as a quick fix (
def handler(x=i): ...)?
If something looks dangerous, ask for a fix: "rewrite this with a closure factory."
Incidentally, if it is faster to have AI do this review too, instruct the reviewer AI to check exactly the points above.
A prompt template
So it is handy to keep a copy-paste "closure-problem protection prompt" like the one below for whenever you use AI:
When you need to define functions inside a loop:
1. Use the closure factory pattern
2. Always pass loop variables as function arguments
3. Capture the values in the inner function
Follow these rules strictly in your implementation.
Summary
The closure problem is a pitfall that quietly eats your time the first time you hit it, but with the right knowledge there is nothing to fear.
In particular, making the closure factory a habit lets you routinely write (and have AI write) safe, clear code.
The next time you define a function inside a loop, we hope the methods introduced here serve you well.
See you next time!