Lambda Expressions - Anonymous Functions at Engineering Depth
Reading time: ~25 minutes | Level: Intermediate → Engineering
Before reading further, predict every output of this program:
funcs = [lambda x: x * i for i in range(3)]
print(funcs[0](10)) # ?
print(funcs[1](10)) # ?
print(funcs[2](10)) # ?
Most developers predict 0, 10, 20.
The actual output:
20
20
20
Every function returns 20. Not 0, 10, 20. All three return the same value.
This is not a bug. It is a precise consequence of when Python evaluates a lambda body - and it is one of the most reliably misunderstood behaviours in the entire language. By the end of this lesson you will be able to explain it exactly, fix it correctly, and recognise the pattern in code you did not write.
What You Will Learn
- What a lambda actually is at the object level
- The difference between compile time (function object creation) and call time (body evaluation)
- Why lambda in a loop captures a variable by reference, not by value
- The default-argument fix and why it works
- When lambda is appropriate and when
defis always the right choice - Practical uses:
sorted(),max(),min(), callback registration - How lambda interacts with
map(),filter(), and other higher-order functions
Prerequisites
- Python Foundation: functions, loops, list comprehensions
- Understanding that functions are objects in Python
Part 1 - What Is a Lambda?
A Lambda Is a Function Object
A lambda expression creates an anonymous function object. When Python encounters a lambda expression, it compiles a new function object and returns it - exactly as it would for a def statement, but without binding the result to a name.
# These two are equivalent
def double(x):
return x * 2
double_lambda = lambda x: x * 2
print(double(5)) # 10
print(double_lambda(5)) # 10
print(type(double)) # <class 'function'>
print(type(double_lambda)) # <class 'function'>
Both are function objects. Both live on the heap. Both can be passed as arguments, stored in data structures, and returned from other functions. The difference is purely syntactic.
The Syntactic Restriction
Lambda is restricted to a single expression. It cannot contain statements, assignments, multiple lines, or return.
# Valid lambdas
add = lambda x, y: x + y
greet = lambda name: f"Hello, {name}"
identity = lambda x: x
constant = lambda: 42
default_arg = lambda x, factor=2: x * factor
# Invalid - these are statements, not expressions
# bad = lambda x: if x > 0: x else: -x # SyntaxError
# bad = lambda x: x = x + 1 # SyntaxError
# bad = lambda x: print(x); return x # SyntaxError
The body of a lambda must be an expression - something that evaluates to a value. Python implicitly returns that value.
__name__ Is Always <lambda>
Every lambda function has the same __name__ attribute:
def square(x):
return x * x
sq_lambda = lambda x: x * x
print(square.__name__) # square
print(sq_lambda.__name__) # <lambda>
:::note Lambda Tracebacks Are Hard to Read
When a lambda raises an exception, the traceback shows <lambda> with no identifying name. In a module with ten lambdas, this tells you almost nothing about which one failed.
Traceback (most recent call last):
File "app.py", line 12, in <lambda>
key=lambda x: x["missing_key"]
KeyError: 'missing_key'
If a function is complex enough to fail in interesting ways, give it a name with def.
:::
Part 2 - Compile Time vs Call Time
This is the key mental model for understanding the opening puzzle.
When Python evaluates a lambda expression, it does two things at completely different moments:
- Compile time (when the
lambdakeyword is encountered): creates the function object, records the parameter list and body bytecode - Call time (when the function is called with
()): evaluates the body expression in the current closure environment
The body x * i is not evaluated when the lambda is created. It is evaluated when the lambda is called. At call time, Python looks up i in the enclosing scope - and finds whatever value i currently holds.
Part 3 - The Loop Closure Trap
Why All Three Lambdas Return 20
funcs = [lambda x: x * i for i in range(3)]
Step by step:
- Loop iteration 0:
i = 0, a lambda object is created. The bodyx * iis recorded but not evaluated. The lambda notes thatiis a free variable to look up later. - Loop iteration 1:
i = 1, another lambda object is created. Same story. - Loop iteration 2:
i = 2, another lambda object is created. Same story. - Loop finishes.
iis now2. It stays2.
Now the calls:
funcs[0](10) # evaluates x * i → 10 * 2 → 20
funcs[1](10) # evaluates x * i → 10 * 2 → 20
funcs[2](10) # evaluates x * i → 10 * 2 → 20
All three lambdas share the same i. They do not each capture the value of i at the time they were created. They capture a reference to the variable i itself. When called, they all look up i in the enclosing scope and find 2.
:::warning Lambda in a Loop Captures Variable by Reference, Not by Value Every lambda created in a loop body captures the loop variable by reference. By the time any of the lambdas are called, the loop has finished and the variable holds its final value.
callbacks = []
for event in ["click", "hover", "focus"]:
callbacks.append(lambda: handle(event))
# All three callbacks will call handle("focus") - the last value of event
This is one of the most common bugs involving lambda. :::
The Default Argument Fix
The correct fix is to capture the current value of the loop variable using a default argument:
funcs = [lambda x, i=i: x * i for i in range(3)]
print(funcs[0](10)) # 0
print(funcs[1](10)) # 10
print(funcs[2](10)) # 20
Default arguments are evaluated at function definition time, not at call time. When lambda x, i=i: x * i is evaluated with i = 0, the default i=i captures the value 0 into the function object's __defaults__. The loop variable can change all it wants - the default is already baked in.
:::tip Capture Loop Variables with Default Arguments
The idiom lambda x, i=i: ... is the standard Python fix for the loop-closure trap. The default value is evaluated immediately when the lambda is defined, not when it is called.
# Broken - all callbacks share the final value of event
callbacks = [lambda: handle(event) for event in events]
# Fixed - each callback captures its own value of event at definition time
callbacks = [lambda e=event: handle(e) for event in events]
:::
Why This Works - Under the Hood
f = lambda x, i=0: x * i
print(f.__defaults__) # (0,)
print(f.__code__.co_varnames) # ('x', 'i')
The default value 0 is stored in f.__defaults__. When f is called without providing i, Python fills it from __defaults__. The value in __defaults__ was captured at definition time and is immutable with respect to the loop variable.
Part 4 - Lambda vs def
When Lambda Is Appropriate
Lambda is appropriate in three categories of use:
1. Sort keys and comparison functions
users = [
{"name": "Charlie", "age": 30},
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 35},
]
# Sort by age
sorted_by_age = sorted(users, key=lambda u: u["age"])
# Sort by name length, then alphabetically
sorted_complex = sorted(users, key=lambda u: (len(u["name"]), u["name"]))
# max/min with key
youngest = min(users, key=lambda u: u["age"])
Lambda is the right choice here. The function is trivial, used once, and inline clarity is more valuable than naming it.
2. Callbacks in functional APIs
from functools import reduce
numbers = [1, 2, 3, 4, 5]
total = reduce(lambda acc, x: acc + x, numbers)
doubled = list(map(lambda x: x * 2, numbers))
evens = list(filter(lambda x: x % 2 == 0, numbers))
3. Simple transformations in event-driven or UI code
# Button click handlers
button.on_click(lambda event: submit_form(event.data))
When to Use def Instead
:::danger Never Assign Lambda to a Variable Name
# WRONG - this is just a worse def
square = lambda x: x ** 2
# RIGHT - use def
def square(x):
return x ** 2
PEP 8 is explicit: assigning a lambda to a name defeats the purpose of lambda entirely. If you need to name it, use def. You get a readable name, a proper docstring slot, and a useful __name__ in tracebacks.
:::
Use def when:
- The function needs a name for reuse across the codebase
- The logic is complex enough to warrant a docstring
- The function is long enough that lambda's single-expression restriction forces awkward workarounds
- You need readable tracebacks (lambda always shows as
<lambda>) - The function needs to handle exceptions or use statements
# Red flag - complex logic crammed into lambda
# Bad
process = lambda x: x["value"] * 1.2 if x.get("type") == "premium" else x["value"]
# Good
def calculate_price(item):
"""Apply premium multiplier to premium items."""
if item.get("type") == "premium":
return item["value"] * 1.2
return item["value"]
Part 5 - Practical Lambda Patterns
sorted() with Multi-Key Sorting
products = [
{"name": "Widget", "price": 9.99, "stock": 50},
{"name": "Gadget", "price": 9.99, "stock": 20},
{"name": "Doohickey", "price": 4.99, "stock": 100},
]
# Sort by price ascending, then by stock descending
sorted_products = sorted(
products,
key=lambda p: (p["price"], -p["stock"])
)
for p in sorted_products:
print(f"{p['name']}: ${p['price']}, stock={p['stock']}")
# Doohickey: $4.99, stock=100
# Gadget: $9.99, stock=20
# Widget: $9.99, stock=50
Lambda with operator Module
The operator module provides pre-built function objects for Python's operators - often faster and more readable than lambda:
import operator
from functools import reduce
nums = [3, 1, 4, 1, 5, 9, 2, 6]
# These pairs are equivalent - operator functions are faster (C implementation)
reduce(lambda a, b: a + b, nums) # slower
reduce(operator.add, nums) # faster
# operator.itemgetter - faster than lambda for dict/sequence access
products = [{"name": "A", "price": 5}, {"name": "B", "price": 2}]
get_price = operator.itemgetter("price")
sorted(products, key=get_price)
# vs
sorted(products, key=lambda p: p["price"])
Lambda as a Default Factory
from collections import defaultdict
# defaultdict takes a callable with no arguments
word_lists = defaultdict(lambda: [])
word_lists["fruits"].append("apple")
word_lists["fruits"].append("banana")
print(dict(word_lists)) # {'fruits': ['apple', 'banana']}
# More idiomatic: defaultdict(list) is equivalent and cleaner
word_lists2 = defaultdict(list)
Engineering Checklist
Before moving on, verify you can answer these without looking:
- At what moment does Python evaluate the body of a lambda - definition time or call time?
- What does
lambda x, i=i: x * iaccomplish thatlambda x: x * idoes not? - What is stored in
f.__defaults__forf = lambda x, y=10: x + y? - What is
f.__name__for any lambdaf? - Name three situations where lambda is the right choice.
- Name three situations where
defis always better than lambda. - What does
operator.itemgetter("price")do and why might it be faster than a lambda?
Key Takeaways
- A lambda is a function object - identical to
defin every way except it is anonymous and restricted to a single expression - Lambda evaluation is split: the function object is created at compile time, but the body is evaluated at call time
- Lambda in a loop captures the loop variable by reference, not by value - all lambdas see the final value of the variable after the loop ends
- The fix is the default argument trick:
lambda x, i=i: x * icaptures the current value ofiinto__defaults__at definition time lambda.__name__is always<lambda>, which makes tracebacks hard to read- Lambda is appropriate for sort keys, simple callbacks, and one-off transformations
- Never assign lambda to a name - that is a worse
def; usedefwhenever the function needs a name, docstring, or multi-line body operator.itemgetter,operator.attrgetter, andoperator.methodcallerare faster C-level alternatives to common lambda patterns
Graded Practice Challenges
Level 1 - Predict the Output
Question 1: What does this print?
f = lambda x, y=10: x + y
print(f(5))
print(f(5, 20))
print(f.__name__)
print(f.__defaults__)
Show Answer
Output:
15
25
<lambda>
(10,)
f(5) uses the default y=10, so 5 + 10 = 15. f(5, 20) overrides the default, so 5 + 20 = 25. All lambdas have __name__ == "<lambda>". The default value 10 is stored in f.__defaults__ as a tuple.
Question 2: What does this print?
ops = {
"add": lambda x, y: x + y,
"mul": lambda x, y: x * y,
"neg": lambda x, _=None: -x,
}
print(ops["add"](3, 4))
print(ops["mul"](3, 4))
print(ops["neg"](5))
Show Answer
Output:
7
12
-5
Each key maps to a lambda. ops["add"](3, 4) calls lambda x, y: x + y with x=3, y=4. ops["neg"](5) calls lambda x, _=None: -x with x=5; the _ parameter with default None is never used.
Question 3: What does this print?
funcs = []
for i in range(4):
funcs.append(lambda x, n=i: x ** n)
results = [f(2) for f in funcs]
print(results)
Show Answer
Output:
[1, 2, 4, 8]
The default-argument trick n=i captures the current value of i at each iteration: 0, 1, 2, 3. Each lambda computes 2 ** n for its captured n. Without n=i, all would return 2 ** 3 = 8.
Question 4: What does this print?
data = [
{"name": "Zara", "score": 88},
{"name": "Ali", "score": 95},
{"name": "Ben", "score": 88},
]
result = sorted(data, key=lambda d: (-d["score"], d["name"]))
print([(r["name"], r["score"]) for r in result])
Show Answer
Output:
[('Ali', 95), ('Ben', 88), ('Zara', 88)]
The sort key is (-score, name). Ali has score 95, so -95 sorts first. Ben and Zara both have score 88, so the tiebreaker is name alphabetically: "Ben" before "Zara".
Question 5: What does this print?
make_adder = lambda n: (lambda x: x + n)
add5 = make_adder(5)
add10 = make_adder(10)
print(add5(3))
print(add10(3))
print(add5(add10(1)))
Show Answer
Output:
8
13
16
make_adder(5) returns a new lambda that closes over n=5. add5(3) returns 3 + 5 = 8. add10(3) returns 3 + 10 = 13. add5(add10(1)) evaluates inner first: add10(1) = 11, then add5(11) = 16. This is a closure factory - covered in depth in Lesson 06.
Level 2 - Debug Challenge
Find and fix all bugs. The intent is a list of functions that each multiply their input by a different factor (0 through 4):
multipliers = [lambda x: x * factor for factor in range(5)]
for i, fn in enumerate(multipliers):
result = fn(10)
print(f"multipliers[{i}](10) = {result}")
# Expected:
# multipliers[0](10) = 0
# multipliers[1](10) = 10
# multipliers[2](10) = 20
# multipliers[3](10) = 30
# multipliers[4](10) = 40
Show Solution
The bug: All lambdas capture factor by reference. After the loop, factor is 4. Every call returns 10 * 4 = 40.
Fixed version - default argument:
multipliers = [lambda x, factor=factor: x * factor for factor in range(5)]
Alternative fix - closure factory:
def make_multiplier(factor):
return lambda x: x * factor
multipliers = [make_multiplier(f) for f in range(5)]
The factory function creates a new scope for each factor, so each lambda closes over a different binding.
Level 3 - Design Challenge
Design a pipeline builder that:
- Accepts a list of transformation functions (lambdas or named functions)
- Returns a callable that applies all transformations in sequence
- Works with any number of steps
- Supports adding steps after construction via an
add_stepmethod
# Target usage:
pipeline = Pipeline()
pipeline.add_step(lambda x: x * 2)
pipeline.add_step(lambda x: x + 10)
print(pipeline.run(5)) # 20
transform = Pipeline([
lambda x: x.strip(),
lambda x: x.lower(),
lambda x: x.replace(" ", "_"),
])
print(transform.run(" Hello World ")) # hello_world
Show Reference Solution
from functools import reduce
class Pipeline:
def __init__(self, steps=None):
self._steps = list(steps) if steps else []
def add_step(self, fn):
self._steps.append(fn)
return self # enable chaining: pipeline.add_step(f).add_step(g)
def run(self, value):
return reduce(lambda acc, fn: fn(acc), self._steps, value)
def __call__(self, value):
"""Make the pipeline itself callable - consistent with single functions."""
return self.run(value)
def compose(self, other):
"""Combine two pipelines into a new one without mutating either."""
return Pipeline(self._steps + other._steps)
def __repr__(self):
return f"Pipeline({len(self._steps)} steps)"
# Numeric pipeline
pipeline = Pipeline()
pipeline.add_step(lambda x: x * 2)
pipeline.add_step(lambda x: x + 10)
print(pipeline.run(5)) # 20
# String pipeline
transform = Pipeline([
lambda x: x.strip(),
lambda x: x.lower(),
lambda x: x.replace(" ", "_"),
])
print(transform.run(" Hello World ")) # hello_world
# Method chaining
result = (Pipeline()
.add_step(lambda x: x ** 2)
.add_step(lambda x: x + 1)
.run(4))
print(result) # 17
Key design decisions:
functools.reduceapplies steps left-to-right with the initial value as the starting accumulatorreturn selfinadd_stepenables fluent method chaining__call__makes Pipeline a first-class callable - drop-in for any single functioncomposeproduces a new Pipeline (no mutation of originals)- Lambdas and named functions both work equally as steps
What's Next
Lesson 02 covers map(), filter(), and reduce() - Python's core higher-order functions for processing sequences.
You will understand why map() returns an iterator rather than a list, what lazy evaluation means in practice, when filter(None, seq) is a useful idiom, and why reduce() was moved out of builtins in Python 3. Lambda will appear throughout as the natural argument to these functions - which is its proper domain.
