Skip to main content

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 def is 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:

  1. Compile time (when the lambda keyword is encountered): creates the function object, records the parameter list and body bytecode
  2. 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:

  1. Loop iteration 0: i = 0, a lambda object is created. The body x * i is recorded but not evaluated. The lambda notes that i is a free variable to look up later.
  2. Loop iteration 1: i = 1, another lambda object is created. Same story.
  3. Loop iteration 2: i = 2, another lambda object is created. Same story.
  4. Loop finishes. i is now 2. It stays 2.

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:

  1. At what moment does Python evaluate the body of a lambda - definition time or call time?
  2. What does lambda x, i=i: x * i accomplish that lambda x: x * i does not?
  3. What is stored in f.__defaults__ for f = lambda x, y=10: x + y?
  4. What is f.__name__ for any lambda f?
  5. Name three situations where lambda is the right choice.
  6. Name three situations where def is always better than lambda.
  7. 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 def in 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 * i captures the current value of i into __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; use def whenever the function needs a name, docstring, or multi-line body
  • operator.itemgetter, operator.attrgetter, and operator.methodcaller are 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:

  1. Accepts a list of transformation functions (lambdas or named functions)
  2. Returns a callable that applies all transformations in sequence
  3. Works with any number of steps
  4. Supports adding steps after construction via an add_step method
# 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.reduce applies steps left-to-right with the initial value as the starting accumulator
  • return self in add_step enables fluent method chaining
  • __call__ makes Pipeline a first-class callable - drop-in for any single function
  • compose produces 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.

© 2026 EngineersOfAI. All rights reserved.