Lambda & Functional Programming
1 · The lesson
readA lambda is an anonymous, single-expression function. It creates the same kind of function object as def, with two restrictions (no name, no statements) and one advantage: you can write it inline, right where it's used. Together with Python's higher-order tools (sorted, map, filter, functools.partial, functools.reduce, functools.lru_cache), lambdas let you write in a clean functional style.
This lesson shows when to use them, when not to, and how they compare with 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". This is 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, with the same
typeas one defined withdef.
If you 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 Are Most Useful: key=
The most common good use of lambda is the key= argument of sorted, min, max, and heapq. There you need a tiny function, used only once, that returns 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)]To sort by several fields, return a tuple from the key function:
# Sort by age descending, then by name ascending sorted(people, key=lambda p: (-p["age"], p["name"]))
setup added so this can run · re-runs 1 earlier block on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'people = [\n {"name": "Linus", "age": 28},\n {"name": "Alice", "age": 35},\n {"name": "Charlie", "age": 22},\n]\n\nsorted(people, key=lambda p: p["age"])\n# [{\'name\': \'Charlie\', ...}, {\'name\': \'Linus\', ...}, {\'name\': \'Alice\', ...}]\n\nmin(people, key=lambda p: p["age"]) # Charlie\nmax(people, key=lambda p: p["age"]) # Alice\n\n# Sort tuples by the second element, descending\npairs = [("apple", 3), ("banana", 1), ("cherry", 2)]\nsorted(pairs, key=lambda pair: pair[1], reverse=True)\n# [(\'apple\', 3), (\'cherry\', 2), (\'banana\', 1)]', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
The minus sign reverses the numeric order. You can't negate a string, so this doesn't work for text fields. For those, use reverse=True on sorted, or sort twice: first by the secondary key, then by the main key. Python's sort is stable, so items with equal main keys keep the order from the first sort.
3. map: Transform an Iterable
map(fn, iterable) returns an iterator that applies fn to each item. It does the same job as a list comprehension, in functional style.
nums = [1, 2, 3, 4] # With map + lambda list(map(lambda n: n * n, nums)) # [1, 4, 9, 16] # The same with a comprehension, which is usually clearer [n * n for n in nums] # [1, 4, 9, 16] # map is best when you pass 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 get a list, or pass it to sum, max, etc. Rule of thumb: if you would write lambda x: <expr>, use a comprehension instead. If you are passing a function that already has a name (str.upper, int, len), map is fine and often clearer.
4. filter: Keep Items That Pass a Test
nums = [-3, -1, 0, 2, 5] list(filter(lambda n: n > 0, nums)) # [2, 5] # Comprehension version, which is usually clearer [n for n in nums if n > 0] # [2, 5]
The advice is the same as for 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, [], ...). This is useful for cleaning user input.
list(filter(None, ["a", "", "b", None, "c"])) # ['a', 'b', 'c']
5. functools.reduce: Combine an Iterable Into One Value
reduce(fn, iterable, initial) applies fn cumulatively, left to right.
from functools import reduce # Sum (in real code, use sum() for this, not reduce) reduce(lambda a, b: a + b, [1, 2, 3, 4], 0) # 10 # Product (before Python 3.8 there was no built-in for this) reduce(lambda a, b: a * b, [1, 2, 3, 4], 1) # 24 # Merge a list of dicts (the | operator on dicts needs Python 3.9+) from operator import or_ reduce(or_, [{"a": 1}, {"b": 2}, {"c": 3}], {}) # {'a': 1, 'b': 2, 'c': 3}
reduce can do a lot, but it is 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+). Use reduce only when the operation is really custom and a loop would not be clearer.
6. functools.partial: Fix Some Arguments in Advance
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 # A common pattern: fix some arguments of a library function import json pretty_dump = partial(json.dumps, indent=2, sort_keys=True) print(pretty_dump({"b": 2, "a": 1}))
A partial means roughly "this function, with these arguments already fixed". It is clearer than a one-line lambda. Its repr also shows the original function and the fixed arguments, which helps when you debug.
7. functools.lru_cache: Memoisation in One Line
Decorate a pure function with @lru_cache and Python caches its return values keyed on the arguments. When you call it again with the same arguments, the body doesn't run. Python returns 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 takes about 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, so the time grows only linearly with n.
Rules: the function must be pure (same inputs → same output, no side effects) and its arguments must be hashable (no lists or dicts, so use tuples and frozensets). maxsize=None means no size limit. Pass an integer to limit memory use.
Python 3.9+ adds @functools.cache: the same thing without a size limit, and slightly faster.
8. Functions Are First-Class Objects
In Python, functions are values like any other. You can store them in lists and dicts, pass them to other functions, return them, and add attributes to them. This is what makes higher-order functions possible.
# Functions in a list operations = [str.upper, str.lower, str.title] for op in operations: print(op("Hello World")) # Dispatch table: a dict that maps names to functions 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
A dispatch table is the Pythonic replacement for a long if/elif chain that checks a string. It is cleaner, easier to extend, and each function is easy to test on its own.
9. The Pipeline Pattern
Functions that take functions and return functions are the building blocks of pipelines: small transformations combined 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. You read the steps from left to right, in the order they run, and you can easily add more steps. You will write this pipeline function yourself 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 deserves a name. If you would write a docstring for it, use 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 clearest sign: if you write name = lambda x: ..., you really want a def. 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 recommends it.
Common Mistakes
1. Using a lambda when a bound method or built-in would do
words = ["Banana", "apple", "Cherry"] # Using a lambda out of habit 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 it in lower case. Wrapping it in a lambda adds an extra function call for no reason. The same is true for len, int, abs, str.strip, and most one-argument built-ins.
2. Assigning a lambda to a name
# Bad: style checkers flag this as E731, a PEP 8 rule 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] # This works, but most Python readers understand the comprehension faster list(map(lambda n: n * 2, filter(lambda n: n > 2, nums))) # The usual Python style [n * 2 for n in nums if n > 2]
map and filter work, but in Python the comprehension is the more common style. Use map/filter when you are passing a function that already has a name, or when you need lazy evaluation on very long iterables. (A generator expression is also lazy.)
4. Late binding in closures
This is a classic mistake. Lambdas (and closures in general) capture the variable itself, not its value at that moment.
# 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 is to store the value when the lambda is created, 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 then kept. The same problem happens with nested defs in loops, and the fix 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. When that function is called with x, it passes x through each function from 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: Passing a value 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 returns it. The body of pipeline ends with return inner (no parentheses, because 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 · re-runs 2 earlier blocks on this page first
fns = ["alpha", "beta", "gamma"] # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ "# What you'd expect:\nfns = [lambda: i for i in range(3)]\nprint([f() for f in fns]) # naive guess: [0, 1, 2]\n # actual: [2, 2, 2]", 'fns = [lambda i=i: i for i in range(3)]\nprint([f() for f in fns]) # [0, 1, 2]', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
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 · re-runs 2 earlier blocks on this page first
# 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') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ "# What you'd expect:\nfns = [lambda: i for i in range(3)]\nprint([f() for f in fns]) # naive guess: [0, 1, 2]\n # actual: [2, 2, 2]", 'fns = [lambda i=i: i for i in range(3)]\nprint([f() for f in fns]) # [0, 1, 2]', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
It behaves the same and is shorter, but it is harder to understand. Use the loop version in code that other people read. The reduce version is just good to know.
Extending it: a pipeline whose first function can take several arguments:
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 have built a tiny functional library. This pattern appears in data-pipeline tools (pandas .pipe, Spark transforms, many ETL tools), event handlers, and middleware stacks.
What You Learned
- A lambda is a one-expression anonymous function. It creates the same kind of function as
def, with shorter syntax. - Lambdas are most useful 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, but use it only when there's no built-in equivalent.functools.partialfixes some arguments in advance. It is cleaner than a wrapper lambda.functools.lru_cachememoises pure functions with one line, and it is very useful 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 store the current value. - Lambda assigned to a name? Just write the
def.
Review: Decorators, from earlier in this path, use the @ syntax to apply the kind of higher-order functions you wrote in this lesson.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.