Python
Programming
Lambdas
Loops
Comprehensions

Creating functions or lambdas in a loop or comprehension

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Creating functions or lambdas in a loop is valid Python, but it surprises people because closures use late binding. The function does not store the current loop value unless you bind it explicitly. If you understand that rule, the fix is straightforward and the pattern becomes safe to use.

Why Loop-Created Closures Surprise People

Consider this code:

python
1funcs = []
2for i in range(5):
3    funcs.append(lambda: i)
4
5print([f() for f in funcs])

Many people expect [0, 1, 2, 3, 4], but the output is [4, 4, 4, 4, 4]. Each lambda closes over the same variable name i, and that name is looked up when the lambda is called, not when it was created.

The same issue appears in comprehensions:

python
funcs = [lambda: i for i in range(5)]
print([f() for f in funcs])

This is not a lambda-specific quirk. Nested def functions behave the same way because the rule comes from Python closure semantics.

Bind the Current Value with a Default Argument

The most common fix is to bind the current loop value into a default argument.

python
1funcs = []
2for i in range(5):
3    funcs.append(lambda i=i: i)
4
5print([f() for f in funcs])

Now the output is [0, 1, 2, 3, 4]. The reason is that default argument expressions are evaluated when the function object is created, so each lambda gets its own stored value.

The comprehension version works the same way:

python
funcs = [lambda i=i: i for i in range(5)]
print([f() for f in funcs])

This is compact and idiomatic when the function body is short.

Use a Factory Function for Clearer Intent

For more complex logic, a small function factory is usually easier to read than a lambda with default arguments.

python
1def make_multiplier(n):
2    def multiply(x):
3        return x * n
4    return multiply
5
6funcs = [make_multiplier(i) for i in range(1, 6)]
7print([f(10) for f in funcs])

This pattern makes the captured value explicit and gives the inner behavior a meaningful name. It is especially useful in callback-heavy systems or test helpers where readability matters more than saving one line.

functools.partial Is Often a Better Fit

If your real goal is pre-binding arguments to an existing function, functools.partial is often cleaner than a closure.

python
1from functools import partial
2
3
4def power(base, exp):
5    return base ** exp
6
7funcs = [partial(power, exp=i) for i in range(1, 5)]
8print([f(2) for f in funcs])

This avoids closure mechanics entirely and communicates intent directly: you are specializing a function by fixing one argument.

Callback Code Is Where This Bug Usually Appears

Late-binding issues often stay hidden until callbacks run later than the loop that created them. That is why the bug shows up frequently in GUI code, asynchronous scheduling, and event registration.

python
1callbacks = []
2for name in ["a", "b", "c"]:
3    callbacks.append(lambda name=name: print(name))
4
5for callback in callbacks:
6    callback()

When the function call happens long after setup, it can look as if the callback system is broken. In reality, the closure simply captured the wrong thing.

Mutable Objects Still Need Care

Binding a loop value early does not magically freeze mutable objects. If you capture a list or dictionary and then mutate it later, all closures that reference that object will see the updated state.

python
1items = []
2funcs = []
3for _ in range(3):
4    funcs.append(lambda items=items: list(items))
5    items.append("x")
6
7print([f() for f in funcs])

The default argument stores the object reference, not a deep copy. If you need a snapshot, copy the data explicitly.

Choose the Most Obvious Binding Style

The best approach depends on the situation:

  • default arguments for small local closures
  • factory functions for more complex behavior
  • 'partial for argument pre-binding to existing functions'

The right goal is not showing that you know closure tricks. The right goal is writing code where the binding behavior is obvious to the next reader.

Common Pitfalls

  • Expecting loop-created lambdas to capture the current value automatically.
  • Forgetting that nested def functions follow the same late-binding rule.
  • Using clever one-liners when a small factory function would be clearer.
  • Assuming default arguments freeze mutable objects instead of storing references.
  • Debugging the callback system instead of checking closure binding semantics first.

Summary

  • Closures created in loops use late binding by default.
  • Use lambda i=i: ... when you need to bind the current loop value immediately.
  • Use factory functions when the closure body deserves a name or more structure.
  • Use functools.partial when the real goal is argument pre-binding.
  • Be extra careful with mutable captured objects and delayed callbacks.

Course illustration
Course illustration

All Rights Reserved.