Lambda & Functional Programming
1 · The lesson
readA lambda is an anonymous, single-expression function. It's the same machinery as def with two restrictions — no name, no statements — and one upside: you can write it inline where it's used. Combined with Python's higher-order tools (sorted, map, filter, functools.partial, functools.reduce, functools.lru_cache), lambdas open the door to a clean functional style.
This lesson is about knowing when to reach for them, when to walk past them, and the trade-offs versus a named def.
1. The Shape of a Lambda
square = lambda x: x * x print(square(5)) # 25
Read it: "a function of x that returns x * x". Equivalent to:
def square(x): return x * x
A few rules:
- The body is one expression. No statements, no
returnkeyword (the expression's value is implicitly returned). - Multiple parameters work fine:
lambda x, y: x + y. - Default arguments and
*args/**kwargswork too:lambda x, y=2: x ** y. - The result is a regular function object — same
typeas one defined withdef.
The moment you'd need two lines, an if/else statement (the ternary expression a if cond else b is fine), or a docstring, use def instead. We'll cover when in Section 10.
2. Where Lambdas Earn Their Keep — key=
The single best use of lambda is inside the key= argument of sorted, min, max, and heapq. You need a tiny throwaway function that extracts the sort field; a lambda is exactly that.
people = [
{"name": "Linus", "age": 28},
{"name": "Alice", "age": 35},
{"name": "Charlie", "age": 22},
]
sorted(people, key=lambda p: p["age"])
# [{'name': 'Charlie', ...}, {'name': 'Linus', ...}, {'name': 'Alice', ...}]
min(people, key=lambda p: p["age"]) # Charlie
max(people, key=lambda p: p["age"]) # Alice
# Sort tuples by the second element, descending
pairs = [("apple", 3), ("banana", 1), ("cherry", 2)]
sorted(pairs, key=lambda pair: pair[1], reverse=True)
# [('apple', 3), ('cherry', 2), ('banana', 1)]Composite sort keys — return a tuple to sort by multiple fields:
# Sort by age descending, then by name ascending sorted(people, key=lambda p: (-p["age"], p["name"]))
setup added so this can run · defines people
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) people = _AutoMock('people')
The negative sign flips the numeric order. For strings, Python doesn't let you negate — wrap them in a reverse=True on sorted or use a tuple trick.
3. map — Transform an Iterable
map(fn, iterable) returns an iterator that applies fn to each item. It's the functional cousin of a list comprehension.
nums = [1, 2, 3, 4] # With map + lambda list(map(lambda n: n * n, nums)) # [1, 4, 9, 16] # Equivalent comprehension — usually clearer [n * n for n in nums] # [1, 4, 9, 16] # Where map shines — passing a named function or method directly list(map(str.upper, ["hi", "there"])) # ['HI', 'THERE'] list(map(int, ["1", "2", "3"])) # [1, 2, 3]
map returns a lazy iterator — wrap it in list() to materialise, or feed it to sum, max, etc. The rule of thumb: if you'd write lambda x: <expr>, prefer a comprehension. If you're passing an already-named function (str.upper, int, len), map is fine and arguably clearer.
4. filter — Keep Items That Pass a Predicate
nums = [-3, -1, 0, 2, 5] list(filter(lambda n: n > 0, nums)) # [2, 5] # Comprehension version — usually clearer [n for n in nums if n > 0] # [2, 5]
Same advice as map: with a lambda, the comprehension reads better. With a named predicate, filter is fine:
list(filter(str.isdigit, ["1", "a", "2", "b"])) # ['1', '2']
One special case: filter(None, iter) drops every falsy value (0, "", None, [], ...). Handy for cleaning user input.
list(filter(None, ["a", "", "b", None, "c"])) # ['a', 'b', 'c']
5. functools.reduce — Fold an Iterable to One Value
reduce(fn, iterable, initial) applies fn cumulatively, left to right.
from functools import reduce # Sum (don't actually use reduce for this — use sum()) reduce(lambda a, b: a + b, [1, 2, 3, 4], 0) # 10 # Product — there's no built-in for this pre-3.8 reduce(lambda a, b: a * b, [1, 2, 3, 4], 1) # 24 # Build a dict from a list of (k, v) updates from operator import or_ reduce(or_, [{"a": 1}, {"b": 2}, {"c": 3}], {}) # {'a': 1, 'b': 2, 'c': 3}
reduce is powerful but often the least readable option. For sums, mins, maxes, and joins, use the built-ins (sum, min, max, str.join). For products, use math.prod (Python 3.8+). Reach for reduce only when the operation is genuinely custom and there's no clearer loop.
6. functools.partial — Pre-Bind Arguments
partial(fn, *args, **kwargs) returns a new function with some arguments already filled in.
from functools import partial # Build a print-without-newline shortcut write = partial(print, end="") write("Hello, ") write("world.\n") # Hello, world. # Specialise a generic function def power(base, exponent): return base ** exponent square = partial(power, exponent=2) cube = partial(power, exponent=3) print(square(7)) # 49 print(cube(3)) # 27 # Common pattern — configuring a callback import json pretty_dump = partial(json.dumps, indent=2, sort_keys=True) print(pretty_dump({"b": 2, "a": 1}))
A partial is roughly "this function, with these defaults locked in". It's clearer than a one-line lambda and carries the original function's metadata (name, signature) through to debuggers and help().
7. functools.lru_cache — Memoisation Free of Charge
Decorate a pure function with @lru_cache and Python caches its return values keyed on the arguments. Repeat calls with the same args don't run the body — they just look up the cached result.
from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2) print(fib(100)) # instant — without lru_cache, this is ~10^21 calls
Without the cache, fib(100) is exponentially slow because each call branches into two more. With the cache, each fib(k) runs at most once and the whole tree collapses to linear time.
Rules: the function must be pure (same inputs → same output, no side effects) and its arguments must be hashable (no lists or dicts — use tuples and frozensets). maxsize=None means unbounded; pass an integer to cap memory.
Python 3.9+ adds @functools.cache — the same thing without the size cap, slightly faster.
8. Functions Are First-Class Objects
In Python, functions are values like any other — you can store them in lists, dicts, pass them around, return them, attach attributes to them. This is what makes higher-order programming possible at all.
# Functions in a list operations = [str.upper, str.lower, str.title] for op in operations: print(op("Hello World")) # Dispatch table — a dict mapping names to behaviour def add(a, b): return a + b def sub(a, b): return a - b def mul(a, b): return a * b ops = {"+": add, "-": sub, "*": mul} def calculate(a, op, b): return ops[op](a, b) print(calculate(5, "+", 3)) # 8 print(calculate(5, "*", 4)) # 20
The dispatch-table pattern is the Pythonic replacement for long if/elif chains keyed on a string. Cleaner, more extensible, and trivially testable in isolation.
9. The Pipeline Pattern
Functions that take functions and return functions are the building blocks of pipelines — small transformations composed into bigger ones.
def pipeline(*fns): def apply(x): for fn in fns: x = fn(x) return x return apply clean = pipeline(str.strip, str.lower, lambda s: s.replace(" ", "-")) print(clean(" Hello World ")) # hello-world
setup added so this can run · defines fns
fns = ["alpha", "beta", "gamma"]
Each function in the pipeline receives the previous one's output. The result is composable, readable left-to-right (the order things actually happen), and easy to extend with more steps. You'll write pipeline-like helpers for free in the Your Turn section.
10. Lambda vs def — When to Use Which
Use a lambda when:
- It lives inline as a
key=, callback, or one-shot transformation. - It's a single short expression.
- Naming it would add noise rather than clarity (e.g.
key=lambda p: p[1]).
Use a def when:
- The function has a name worth remembering — if you'd put it in a docstring, it deserves a
def. - The body is more than one expression or you want type hints.
- You'll call it from multiple places.
- A test will reference it by name.
The strongest signal: if you find yourself writing name = lambda x: ..., that's a def masquerading as a lambda. Just write the def.
# Don't square = lambda x: x * x # Do def square(x): return x * x
Both produce a function. The def version has a proper __name__ (debuggers and tracebacks show square, not <lambda>), supports a docstring, and PEP 8 explicitly recommends it.
Common Mistakes
1. Using a lambda when a bound method or built-in would do
words = ["Banana", "apple", "Cherry"] # Reaching for lambda by reflex sorted(words, key=lambda w: w.lower()) # Cleaner — pass the method itself sorted(words, key=str.lower)
str.lower is already a function that takes a string and returns its lowered form. Wrapping it in a lambda adds a layer for no reason. Same for len, int, abs, str.strip, and most one-argument built-ins.
2. Assigning a lambda to a name
# Bad — pep8 (E731) explicitly warns against this add = lambda a, b: a + b # Good def add(a, b): return a + b
If the function is worth a name, it's worth a def. The def form is the same length, gets a proper __name__, and can grow into more lines without rewriting.
3. map/filter when a comprehension is clearer
nums = [1, 2, 3, 4, 5] # Functional, but most Python readers parse the comprehension faster list(map(lambda n: n * 2, filter(lambda n: n > 2, nums))) # Idiomatic [n * 2 for n in nums if n > 2]
Map and filter are fine, but in Python the comprehension is the more native idiom. Use map/filter only when you're piping an already-named function or working with extremely long iterables where laziness matters.
4. The late-binding closure trap
This is the classic gotcha. Lambdas (and closures generally) capture variables by reference, not by value.
# What you'd expect: fns = [lambda: i for i in range(3)] print([f() for f in fns]) # naive guess: [0, 1, 2] # actual: [2, 2, 2]
By the time you call any of these lambdas, the loop has finished and i is 2. Every lambda looks up the same i in the enclosing scope and sees 2.
The fix — bind the value at lambda-definition time using a default argument:
fns = [lambda i=i: i for i in range(3)] print([f() for f in fns]) # [0, 1, 2]
i=i evaluates the right-hand i now (at function-definition time, inside the comprehension) and binds it as a default to the parameter i. Defaults are evaluated once and frozen. Same trap exists with nested defs in loops — the cure is the same.
🎯 Your Turn — Build pipeline
Implement a pipeline(*fns) higher-order function. It takes any number of single-argument functions and returns a new function that, when called with x, threads x through each function left-to-right.
clean = pipeline(str.strip, str.lower, lambda s: s.replace(" ", "-")) print(clean(" Hello World ")) # 'hello-world' double_then_str = pipeline(lambda n: n * 2, str) print(double_then_str(21)) # '42'
setup added so this can run · defines pipeline
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def pipeline(*_a, **_kw): print('-> pipeline() called') return _AutoMock('pipeline()')
Skeleton:
def pipeline(*fns): # TODO 1: define an inner function that takes x # TODO 2: walk through fns, replacing x with fn(x) each step # TODO 3: return x after the loop # TODO 4: return the inner function (no parens!) ...
Hint 1 — Threading state through a loop
Inside the inner function, start with the originalx. For each function in fns, reassign x = fn(x). After the loop, x holds the final result. Return x.
Hint 2 — Returning a function
pipeline doesn't compute anything itself — it builds a new function and hands it back. The body of pipeline ends with return inner (no parentheses; you want the function object, not the result of calling it).
Show full solution
def pipeline(*fns): """Compose functions left-to-right into a single callable.""" def apply(x): for fn in fns: x = fn(x) return x return apply clean = pipeline(str.strip, str.lower, lambda s: s.replace(" ", "-")) print(clean(" Hello World ")) # 'hello-world' double_then_str = pipeline(lambda n: n * 2, str) print(double_then_str(21)) # '42'
setup added so this can run · defines fns
fns = ["alpha", "beta", "gamma"]
A functional one-liner using reduce:
from functools import reduce def pipeline(*fns): return lambda x: reduce(lambda acc, fn: fn(acc), fns, x)
setup added so this can run · defines fns
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) fns = _AutoMock('fns')
Same behaviour, half the code, twice the head-scratching. Pick the loop version for shared code; the reduce version is fun to know exists.
Extending it — a pipeline that supports multi-argument first function:
def pipeline(*fns): def apply(*args, **kwargs): result = fns[0](*args, **kwargs) for fn in fns[1:]: result = fn(result) return result return apply joined = pipeline(lambda a, b: f"{a}-{b}", str.upper) print(joined("hello", "world")) # 'HELLO-WORLD'
setup added so this can run · defines fns, args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) fns = ["alpha", "beta", "gamma"] args = _AutoMock('args') kwargs = _AutoMock('kwargs')
You've just built a tiny functional library. This pattern shows up in data-pipeline frameworks (pandas .pipe, Spark transforms, every ETL tool), event handlers, and middleware stacks.
What You Learned
- A lambda is a one-expression anonymous function. Same machinery as
def, lighter syntax. - Lambdas shine inside
key=,map,filter, and callbacks where naming would add noise. - Use a named function or bound method (
str.lower,len) instead oflambda x: x.lower()whenever possible. mapandfilterare valid but a comprehension is usually clearer in Python.functools.reduceexists; reach for it only when there's no built-in equivalent.functools.partialpre-binds arguments — cleaner than a wrapper lambda.functools.lru_cachememoises pure functions for free; great for recursive computations.- Functions are first-class — store them in lists, dicts (dispatch tables), pass them, return them.
- Late binding in closures: a
lambdain a loop captures the variable, not its current value. Useparam=valuedefaults to bind eagerly. - Lambda assigned to a name? Just write the
def.
Next: Decorators — the syntactic frame around higher-order functions you've just learned to write.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.