Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·20 MIN

lambda: the same thing as def except for two — and the famous loop bug is neither of them

lambda and def share a type, share the bytecode of the body, and have call times measurement cannot tell apart. The difference is the name, and that the body must be an expression. The famous loop bug has nothing to do with lambda: a plain def breaks in exactly the same way.

Full technical treatment

TL;DR

A lambda is an ordinary function written as an expression. It belongs where the function is small and needed right at the call site — as an argument to sorted, say — and where giving it a separate name would get in the reader's way rather than help. The object that comes out is the same one def makes: same type, same bytecode for the body.

Hence the main consequence: choosing between lambda and def is about readability, not about behaviour. The differences live in the grammar and the metadata: a lambda's body has to be an expression, and its name is synthetic — <lambda> instead of a real one, which is what tracebacks show. The famous loop bug — [lambda: i for i in range(3)] yielding [2, 2, 2]is not about lambda: a plain def in a loop breaks the same way, the FAQ says so outright, and the reason is that the three functions point at one cell (verified — there is exactly one, and it holds 2).

Beyond that is what separates knowing from having read. Call times for def and lambda are indistinguishable: 22.7 ns against 22.7 ns (3.13.7). A lambda cannot be annotated, and what forbids that is the grammar itself rather than the "expressions only" rule (PEP 3107). The cost in key=: lambda and def are indistinguishable (7.97 against 7.98 µs), while operator.itemgetter is one and a half times faster than either in that measurement. The choice is not between lambda and def.

Where to start
Before this lesson it is enough to understand
  • a function is an object like a number or a string: it can be put in a variable and passed to another function as an argument;
  • an expression is evaluated and yields a value, while a statement does something: x + 1 is an expression, return x a statement;
  • sorted(data, key=…) is an ordinary call that is handed a function.
You do not need to know in advance
  • closures, cells, __closure__, late binding;
  • operator.itemgetter, the walrus :=, type annotations and bytecode.

Base: an ordinary function written as an expression

A function in Python can be passed as an argument, and the standard library relies on that constantly. sorted, min, max and filter take a function because what they need is not a finished algorithm but a rule: which value to compare by, what counts as a match.

Usually such a function is declared first and passed afterwards:

PYTHON
def second(pair):
    return pair[1]
 
rows.sort(key=second)

A lambda is a way to write exactly the same function as an expression and put it where it is needed:

PYTHON
rows.sort(key=lambda pair: pair[1])

Both forms produce a function and both do the same thing. The difference is that in the second one the function has no separate name and no separate place in the file.

Which answers the question of when to write it that way. When the function is small, needed in exactly one place — the call itself — and a separate name would get in the way: it makes the reader look away, find the definition and come back, all for something that fitted on half a line.

And the converse rule, from the same reasoning: as soon as a name is needed — the function is called from several places, it needs a type annotation, its name should show up in a traceback when it fails — write a def. PEP 8 says the same thing when it objects to f = lambda ...: there the name appears to exist, yet the traceback does not have it.

That is already enough to answer the basic interview question: a lambda is the same function written as an expression, and it belongs where a separate name would hurt readability. Everything below is about how exactly lambda differs from def (there are few differences, and speed is not among them), about the famous loop bug that is not about lambda, and about what the measurements actually show.

Mechanism 1: one type, one bytecode, one timing

language contractLanguage guarantee: a lambda produces an object of the same function type a def does. The differences are grammar and metadata.

Start with what three lines settle.

PYTHON
def named(x):
    return x + 1
 
anon = lambda x: x + 1

type(named) is type(anon) is True; both are function. The bytecode of the body matches instruction for instruction:

RESUME  LOAD_FAST  LOAD_CONST  BINARY_OP  RETURN_VALUE

Call time is 22.7 ns for def against 22.7 ns for lambda on 3.13.7. The gap is smaller than the run-to-run spread; in repeated runs the order swaps. The claim is exactly that: there is nothing to measure.

The reference describes lambda in a single line of grammar and says the expression lambda arguments: expression “yields a function object” — the same object def makes.

Mechanism 2: difference one — the name

A lambda's __name__ is <lambda>, and that shows up where it matters most. Two functions that fail identically:

PYTHON
def boom_named(x): return int(x)
boom_anon = lambda x: int(x)
 
boom_named("test")   # ValueError — and the same for boom_anon

In a traceback they look different:

last frame (def):    boom_named
last frame (lambda): <lambda>

Hence PEP 8's recommendation, which rests on a reason rather than on taste: “Always use a def statement instead of an assignment statement that binds a lambda expression directly to an identifier” — because “the name of the resulting function object is specifically 'f' instead of the generic '<lambda>'. This is more useful for tracebacks”.

Note that the rule covers only the f = lambda ... case. Passing a lambda as an argument — sorted(data, key=lambda x: x[1]) — is not what PEP 8 objects to: there was never a name to lose.

Mechanism 3: difference two — the body must be an expression

The reference: “lambda forms can only contain expressions, not statements”. Compiling each form shows exactly what does not get through:

formresult
lambda x: (x := 1)compiles — the walrus is an expression
lambda x: x = 1SyntaxError
lambda x: return xSyntaxError
lambda x: raise ValueErrorSyntaxError
lambda x: assert xSyntaxError
lambda x -> int: xSyntaxError — return annotations are not allowed

The last row is about something else: what forbids the annotation is not the “expressions only” rule but the grammar of lambda itself, and PEP 3107 says so outright. In practice it matters most: a lambda cannot be annotated. If a function needs a type, it is a def, and not for reasons of taste.

Mechanism 4: the error that does not crash

And now code that does not crash, does not warn — and returns the wrong thing.

PYTHON
fns = [lambda: i for i in range(3)]
[f() for f in fns]        # [2, 2, 2], not [0, 1, 2]

This is not about lambda. The FAQ closes its explanation with a sentence people tend not to reach: “Note that this behaviour is not peculiar to lambdas, but applies to regular functions too”. Running it confirms — a loop with a plain def gives the same [2, 2, 2]:

PYTHON
defs = []
for i in range(3):
    def make():
        return i
    defs.append(make)
[f() for f in defs]       # [2, 2, 2] — lambda has nothing to do with it

The reason is visible in memory: the three functions share one closure cell. A cell is a separate object CPython creates for a variable more than one scope looks at; a function keeps its references to them in __closure__. Verified — the set of cell ids has exactly one element, and the cell holds 2.

Outside a teaching example it looks like this: three checks built in a loop all get one threshold — the last one. [lambda v: v > t for t in (10, 100, 1000)] passes 3 values instead of 6, with no exception anywhere.

The workaround is a default argument, because it is evaluated when the function is created:

PYTHON
fixed = [lambda i=i: i for i in range(3)]   # [0, 1, 2]

Such functions have __closure__ of None: there is no closure at all, the value lives in the function itself.

How a comprehension differs from a loop

There is a detail here the FAQ does not cover: its example uses a for loop, and the variable behaves differently in a comprehension — one cell either way, but a different lifetime.

variable visible afterwardschanging it changes the result
for x in range(3): …yes, x == 2yes: x = 100 → all three return 100
[… for y in range(3)]nonothing to change — no y outside

In a loop the functions hold a reference to a variable that outlives the loop and can be reassigned later. A comprehension has its own scope and leaks nothing — but the three functions still share one cell, and the result is the same.

Deeper: what it actually costs

measured observationbench/lambda/cost.py, CPython 3.11–3.14. Measured on one machine; the absence of a difference is what matters, not the absolute figures.

Three ways to fetch the same key; the table gives the time of one sort of a 200-element list of tuples, best of seven runs of 20,000 sorts each.

way3.13.73.14.7
key=lambda x: x[1]7.97 µs7.67 µs
key=named (the same def)7.98 µs7.71 µs
key=operator.itemgetter(1)5.30 µs5.40 µs

Read the columns separately: the builds these numbers come from differ by more than the language version. What to compare is rows within one column.

The first two rows are indistinguishable — as expected: it is the same kind of object either way. itemgetter is roughly 1.5× faster than either, and that gap is stable between runs.

The reason is not syntax: itemgetter is a callable written in C, and no interpreter frame is created per element. The same holds for attrgetter and methodcaller.

The “one and a half times” has a boundary, and naming it matters more than the number. What was measured is one operation — sorting a 200-element list of tuples by one item; in this lesson's own practice run, on different data and a different machine, the same move gave more than two times. What carries over is not the multiple but the direction: the win comes from a Python-level call disappearing, and its size depends on how much of the time that call took. On a ten-element sort nothing changes at all.

So the practical conclusion is written from the boundary: the "lambda or def" argument costs nothing either way — the measurement shows no difference between them. If a sort is genuinely hot, look towards operator, and look with a measurement on your own data rather than by choosing between two ways of writing the same thing.

Deeper: version history

VersionChangeWhat it means for your code
3.0PEP 3107 gives annotations to def and withholds them from lambda — a recorded decision rather than an oversight: “Lambda's syntax does not support annotations”. Since then, a function that needs a type has to be a def, and that is enforced by the compiler, not by convention.
3.8PEP 572: := is an expression rather than a statement, which is why it works inside a lambda. It is the only way to “assign” anything in a lambda body: lambda x: (y := x) compiles, lambda x: y = x does not.
3.12Comprehensions are inlined into the calling code (PEP 709), but they kept their own scope: the comprehension variable is still invisible from outside — checked on 3.11, 3.12, 3.13 and 3.14 with the same result.

How to answer in an interview

The short answer: a lambda is the same function written as an expression. The object it builds is of the same type as a def's, with the same bytecode for the body; you write one where the function is needed right at the call site and a separate name would hurt readability.

That is enough to answer correctly. Beyond it is what you add if the interviewer digs.

If the interviewer digs deeper

First, name the actual differences. There are two, both in grammar and metadata: the name (<lambda> instead of a real one — which is what shows up in a traceback) and the requirement that the body be an expression. A third is grammatical: a lambda cannot be annotated, and it is the grammar that forbids it, not the "expressions only" rule. Which also frames PEP 8: it objects not to lambda but to f = lambda ..., because that form takes the name out of the traceback.

Second, the trap they are waiting for: the famous [lambda: i for i in range(3)] yielding [2, 2, 2] has nothing to do with lambda — a plain def in a loop breaks the same way, and the FAQ says so outright. The reason is that the three functions point at one cell.

Third, if the conversation turns to speed, name the real choice: lambda and def in key= are indistinguishable (7.97 against 7.98 µs), while operator.itemgetter is one and a half times faster than either in that measurement. The multiple belongs to the measurement; the direction is what carries over — the win comes from a Python-level call disappearing.

Next they ask

Next they ask

If it is the same as def, what is lambda for?

Short answer

There are exactly two differences, and neither is speed: it has no name of its own, and its body is an expression rather than a set of statements. The object type, the bytecode and the time are the same for lambda and def.

Next they ask

The famous lambda-in-a-loop trap — is that about lambda?

Short answer

No, it is about closures: what is captured is the variable, not the value, and a def in the same loop behaves identically. lambda only makes the mistake shorter to write.

Common misconceptions

Claim

lambda is faster than def because it is shorter

Actually

It is neither faster nor slower: it is an object of the same type. type(named) is type(anon) is True, the body's bytecode matches instruction for instruction, and the call takes 22.7 ns against 22.7 ns on 3.13.7 — inside the spread. In repeated runs the order of those two swaps.

Claim

the [lambda: i for i in range(3)] bug is a lambda quirk

Actually

The FAQ says the opposite outright: “this behaviour is not peculiar to lambdas, but applies to regular functions too”. Verified: a loop with a plain def gives the same [2, 2, 2]. What happens is that a function closes over a variable rather than a value — and lambda is merely the shortest way to make a function inside a loop.

Claim

the value simply did not get saved in time

Actually

There was no value to save: what was saved is a reference. The three functions share EXACTLY ONE closure cell — verified by id — and it holds the last value. The lambda i=i: i workaround does not “copy” anything; it works because a default argument is evaluated when the function is created. The fixed variant has __closure__ of None.

Claim

PEP 8 bans lambda

Actually

One case is banned — binding a lambda to a name: “Always use a def statement instead of an assignment statement that binds a lambda expression directly to an identifier”. The reason is stated there and is checkable: the traceback will read <lambda> instead of a name. Passing a lambda as an argument is not what PEP 8 objects to.

Claim

anything goes in a lambda, just on one line

Actually

Nothing that is a statement goes. Verified by compiling: =, return, raise and assert are all SyntaxError; only an expression gets through — including the walrus :=. Separately and importantly: a return annotation is a SyntaxError too, so a typed function must be a def.

Claim

key=lambda is the bottleneck of a sort

Actually

The bottleneck is not lambda but the fact that Python-level code fetches the key. Measured: key=lambda 7.97 µs, key=def 7.98 (indistinguishable), key=itemgetter(1) 5.30. The 1.5× win in that measurement comes from moving to operator, not from choosing between two ways of writing a function; on other data the multiple differs and the direction does not.

Claim

a comprehension variable behaves like a loop variable

Actually

Not quite, and the difference shows up while debugging. After for x in range(3) the variable survives outside: x == 2, and writing x = 100 later makes all three functions return 100. A comprehension has its own scope — there is no such variable outside at all. The result is the same either way: [2, 2, 2].

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

On the left, the famous lambda-in-a-loop bug; on the right, the same loop with a plain def. What does this code print?
lambdas = [lambda: i for i in range(3)]

defs = []
for i in range(3):
  def made():
      return i
  defs.append(made)

print([f() for f in lambdas])
print([f() for f in defs])

Practice · estimate

Sorting a thousand dicts by one key: key=lambda row: row['k'] against key=operator.itemgetter('k'). How many times faster is itemgetter?
times

Knowledge check

Question 1 of 4

What does [f() for f in [lambda: i for i in range(3)]] return?

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Sources & further reading

6 SOURCES

  1. The language reference — lambda expressionsOfficial documentation. A one-line grammar: `lambda [varargslist] ": " expression`. The same page carries the two restrictions this lesson checks by compiling: “The lambda's body is restricted to a single expression” and “lambda forms can only contain expressions, not statements”.https://docs.python.org/3.14/reference/expressions.html#lambda
  2. Programming FAQ — why lambdas defined in a loop return the same resultOfficial documentation. Answers exactly the question this lesson's loop section is about, and ends with the sentence people tend not to reach: “Note that this behaviour is not peculiar to lambdas, but applies to regular functions too”. Verified by running it: a plain def in a loop gives the same result.https://docs.python.org/3.14/faq/programming.html
  3. PEP 8 — on binding a lambda to a namePEP. “Always use a def statement instead of an assignment statement that binds a lambda expression directly to an identifier”, with the reason stated right there: “the name of the resulting function object is specifically 'f' instead of the generic '<lambda>'. This is more useful for tracebacks”. Verified: the last traceback frame really does read `<lambda>`.https://peps.python.org/pep-0008/
  4. PEP 3107 — Function AnnotationsPEP. Collin Winter and Tony Lownds, Final, Python 3.0. The absence of annotations on lambda is a recorded decision, not a side effect: “Lambda's syntax does not support annotations. The syntax of lambda could be changed to support annotations, by requiring parentheses around the parameter list”. Verified by compiling: `lambda x -> int: x` is a SyntaxError on all four versions.https://peps.python.org/pep-3107/
  5. PEP 572 — Assignment ExpressionsPEP. Chris Angelico, Tim Peters, Guido van Rossum; Final, Python 3.8. The walrus is an EXPRESSION: “The value of such a named expression is the same as the incorporated expression, with the additional side-effect that the target is assigned that value”. Which is why it works inside a lambda while ordinary assignment does not.https://peps.python.org/pep-0572/
  6. operator — itemgetter and attrgetterOfficial documentation. The module the practical conclusion about `key=` reduces to. Measured: sorting with `itemgetter(1)` is one and a half times faster than with an equivalent `lambda` — and that gap is stable between runs, unlike the gap between `lambda` and `def`.https://docs.python.org/3.14/library/operator.html