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

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

Read it: "a function of x that returns x * x". This is 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, with the same type as one defined with def.

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.

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)]

To sort by several fields, return a tuple from the key function:

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

python
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

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

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

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

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

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

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

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

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

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

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 recommends it.


Common Mistakes

1. Using a lambda when a bound method or built-in would do

python
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

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

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

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 is to store the value when the lambda is created, 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 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.

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: Passing a value 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 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
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 · 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:

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

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

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