PythonMastery
intermediate 16 min read · lesson 7 of 13 in Python Intermediate

Lambda & Functional Programming

1 · The lesson

read

A 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

python
square = lambda x: x * x
print(square(5))                # 25

Read it: "a function of x that returns x * x". Equivalent to:

python
def square(x):
    return x * x

A few rules:

  • The body is one expression. No statements, no return keyword (the expression's value is implicitly returned).
  • Multiple parameters work fine: lambda x, y: x + y.
  • Default arguments and *args/**kwargs work too: lambda x, y=2: x ** y.
  • The result is a regular function object — same type as one defined with def.

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.

python
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:

python
# 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.

python
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

python
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:

python
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.

python
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.

python
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.

python
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.

python
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.

python
# 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.

python
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.

python
# 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

python
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

python
# 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

python
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.

python
# 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:

python
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.

python
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:

python
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 original x. 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
python
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:

python
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:

python
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 of lambda x: x.lower() whenever possible.
  • map and filter are valid but a comprehension is usually clearer in Python.
  • functools.reduce exists; reach for it only when there's no built-in equivalent.
  • functools.partial pre-binds arguments — cleaner than a wrapper lambda.
  • functools.lru_cache memoises 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 lambda in a loop captures the variable, not its current value. Use param=value defaults 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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.