DEV BOX

'26/11/13 UPDATE

PYTHON / FIELD NOTES

Why do lambdas made in a loop all return the last value?

A Python function reads outside names when it is called, not when it is made. Real runs on CPython 3.9.6, 3.11.17 and 3.12.14 show lambda, def, comprehensions and threads using the last value, then set out the fixes with a default argument, a factory function and partial, and where each can bite.

If you build one handler per menu name in a loop and every menu runs the last one, start where the code assumes a function holds a value. A Python function reads outside names when it is called. After this note you can explain why all the results match and decide whether to fix it with a default argument, a factory function or partial.

The examples were run on 2026-11-01 on macOS 27.0.1 with CPython 3.9.6, 3.11.17 and 3.12.14, and the three versions printed the same output, apart from the unsorted thread lines described below. The pictures show 3.9.6. Behaviour was checked in the Python 3.15.2 documentation (updated 2026-10-31), and versions from 3.13 on were not run. The menu names and values are made up.

A function looks up a name, not a value

handlers = {}
for name in ["save", "load", "quit"]:
    handlers[name] = lambda: f"run {name}"

print(handlers["save"]())
print(handlers["load"]())
print(handlers["quit"]())

All three lines printed run quit. The name inside the lambda is not computed when the function is made. The Python FAQ covers the same case and explains that the variable is read when the lambda is called, not when it is defined. The execution model page also says free variables are resolved at run time. By the time of the three calls the loop has finished, and a for statement does not delete its target name afterwards, so name still holds "quit".

CPython 3.9.6 output. menu.py prints run quit three times. when.py: loop lambda, loop def and comprehension all [2, 2, 2]. after i = 100 gives [100, 100, 100]. module closure None, names ('i',). inside function [2, 2, 2]. same cell True. cell_contents 2. gen, call as made [0, 1, 2]. gen, collect first [2, 2, 2].
Actual output: functions made in a loop read the value at the time they are called.

The reason no new variable appears for each pass can also be read from the documentation. The execution model page lists a module, a function body and a class definition as blocks, and lists for among the constructs that bind names. So however many passes run, the loop only puts a new value into the same name. In when.py, which uses numbers, the lambda gave [2, 2, 2] and a def gave the same. Setting i = 100 after the loop made the functions already built return [100, 100, 100]. That could not happen if the value had been copied.

Inside a function they share one cell

The example above is at module level, so __closure__ was None and the lambda looked up the global name i. Made inside a function, it looks slightly different.

def build():
    made = []
    for k in range(3):
        made.append(lambda: k)
    return made

Here the three functions had cells in __closure__, the first and third cells were the same object (is gave True), and cell_contents was 2. The comprehension [lambda: n for n in range(3)] also gave [2, 2, 2]. The language reference says a comprehension runs in a separate nested scope, and this reads as every function using the one n in that scope. Python 3.12 brought in a change that inlines comprehensions (PEP 709). The PEP does not address this case directly, and the output on 3.12.14 matched 3.9.6.

A generator confirms that the time of the call decides the answer. The expression is the same.

gen = (lambda: g for g in range(3))
print("gen, call as made ", [f() for f in gen])
gen = (lambda: g for g in range(3))
print("gen, collect first", [f() for f in list(gen)])

A generator advances only as far as it is asked. The first line called each function as soon as it arrived and gave [0, 1, 2]. The second called them after receiving all three and gave [2, 2, 2].

Timing can hide the mistake

This can be harder to spot in code that runs later, such as threads or callbacks. threads.py starts three threads in a loop, each adding a value to a results list. Below are the line with a delay and the line passing the value through args.

t = threading.Thread(target=lambda: (time.sleep(0.05), results.append(i)))
t = threading.Thread(target=work, args=(i,))
CPython 3.9.6 threads.py output. lambda, no delay [0, 1, 2]. lambda, 50 ms delay [2, 2, 2]. args=(i,), 50 ms [0, 1, 2]. Those three results are sorted before printing. The unsorted line, args=(i,), no sort, gives [1, 0, 2]. Over 60 unsorted runs, 14 came out in order and all 60 had the right values. Node.js v24.21.0 loop.mjs output. var [ 3, 3, 3 ]. let [ 0, 1, 2 ].
Actual output: the same lambda on a thread gives different results depending on when it reads the name, next to JavaScript's var and let.

The lambda with no delay gave [0, 1, 2] on all three versions. It looks as if each thread read i before the loop moved on, but that is inferred from the output, from one run on each version. The documentation for start() only goes as far as arranging for it to run in a separate thread. With a 50 ms delay added, the same code gave [2, 2, 2]. That is why a passing test with short work is no reassurance. args=(i,) computes the call arguments during the loop and gave [0, 1, 2] even with the delay. These lists went through sorted(results) before printing; without the sort, the same three values can come out in whatever order the threads finished. Run 60 times unsorted, the order 0, 1, 2 came up 14 times on 3.9.6, 23 on 3.11.17 and 17 on 3.12.14, and the three values were right every time.

If you are used to JavaScript this can be more confusing. In the same shape of loop, var gave [ 3, 3, 3 ] and let gave [ 0, 1, 2 ]. MDN explains that let in a for makes a new scope and new variables for each iteration. Python's for has no such behaviour, so, in the author's comparison, it is closer to var.

Three ways to keep each loop's value

from functools import partial

by_default = [lambda i=i: i for i in range(3)]

def make(value):
    return lambda: value

by_factory = [make(i) for i in range(3)]

def identity(value):
    return value

by_partial = [partial(identity, i) for i in range(3)]
CPython 3.9.6 fixes.py output. default arg [0, 1, 2]. __defaults__ (0,) (2,). override 99. factory [0, 1, 2]. same cell False. partial [0, 1, 2]. event + i=i gives click. event, *, i=i gives 1. factory event and partial event give TypeError. same list [[0, 1, 2], [0, 1, 2], [0, 1, 2]]. copied list [[0], [0, 1], [0, 1, 2]]. factory list and partial list both [[0, 1, 2], [0, 1, 2], [0, 1, 2]].
Actual output: the results of a default argument, a factory function and partial, and where each can bite.

All three gave [0, 1, 2]. The default argument is the fix the FAQ gives, and as the language reference says, a default is computed once when the function definition runs. __defaults__ kept (0,) and (2,) per function. The cost is that an argument from the caller overwrites it: by_default[0](99) was 99, and where a callback passes one argument, "click" went into i. Making it keyword-only with lambda event, *, i=i: i kept the 1. "click" is a made-up value.

The factory function made a new cell on each call (is gave False), and partial bound a value to a function that already exists. Where a callback passes one argument, the lambda: value above and partial(identity, i) raised TypeError, because neither takes a further argument; unlike with the default argument, that failure shows at once. What all three hold, though, is the object and not a copy. Passing the same list row while changing it in the loop gave [[0, 1, 2], [0, 1, 2], [0, 1, 2]] with all three. Passing a copy with lambda r=list(row): r gave [[0], [0, 1], [0, 1, 2]].

DEV NOTES

Pick a way to hold the value during the loop

All three compute the value during the loop and keep it

  • Default argument

    lambda event, *, i=i: i

    • The shortest
    • An argument from the caller overwrites it
    • Keyword-only blocks positional arguments
  • Factory function

    make(i)

    • Each call makes a new cell
    • No spare argument stays in the signature
  • functools.partial

    partial(identity, i)

    • Binds a value to a function you already have
    • identity here is only a demo function
  • The trap they share

    lambda r=list(row): r

    • What they hold is the object, not a copy
    • Copy a value that changes, such as a list

All four boxes were run on 2026-11-01 on CPython 3.9.6, 3.11.17 and 3.12.14. The way of choosing is the author's own.

Three ways to keep the value from each loop, what each is like, and the trap they share.

Some tools were checked in documentation only. Ruff has rule B023, from flake8-bugbear, and Pylint has the message cell-var-from-loop (W0640). Neither was run, so how much they catch in real code is unknown.

Find each lambda and def inside a loop in your code and ask whether that function can still be called after the loop ends. If so, do not read the loop variable in the function body; pass it when the function is made. This check is the author's own, drawn from the runs above.

THE END
RSS feedBack to the library
COMMENTS BOX

Add your perspective.

Share a question, another approach, or something you have tried.

Newest first

Checking sign-in…

Loading comments…