Lesson 14 of 25

Lambda Functions

Functions Are Values

Before lambdas make sense, one idea has to land: in Python a function is an ordinary object. len is a value in the same way 5 is. You can assign it to another name, put it in a list, store it in a dictionary, pass it to another function, or return it from one. The parentheses are not part of the function — they are the act of calling it.

A function that takes another function as an argument is called a higher-order function, and you have already used several. sorted(names, key=str.lower) passes the method str.lower to sorted, which calls it once per item to decide what to compare. Nothing magic is happening; sorted received a value that happens to be callable.

This is what makes lambda useful. Sometimes the function you want to pass is one line long and will never be used again, and giving it a name and three lines of def is more ceremony than the idea deserves. A lambda is a function written as an expression, so it can sit directly in the argument list where it is needed.

It is also worth knowing where this leads. Storing functions in a dictionary gives you a dispatch table — a clean replacement for a long if/elif chain. Returning a function from a function gives you decorators, the subject of Lesson 22. All of it rests on the single fact that functions are values.

Example
def shout(text):
    return text.upper() + "!"

# A function is a value: assign it, store it, pass it
yell = shout
print(yell("hello"))          # HELLO!
print(type(shout))            # <class 'function'>

# In a list
operations = [str.upper, str.lower, str.strip]
for op in operations:
    print(repr(op("  Asha  ")))

# Passed to a higher-order function
names = ["meera", "Asha", "ravi", "Zoya"]
print(sorted(names))
# ['Asha', 'Zoya', 'meera', 'ravi']  — every capital sorts before every lowercase
print(sorted(names, key=str.lower))
# ['Asha', 'meera', 'ravi', 'Zoya']  — what a human expects
print(sorted(names, key=len))         # shortest first

# In a dictionary: a dispatch table instead of a long if/elif chain
def add(a, b): return a + b
def sub(a, b): return a - b

ops = {"+": add, "-": sub}
print(ops["+"](7, 3))         # 10
print(ops["-"](7, 3))         # 4

# The parentheses are the call, not the function
print(shout)                  # <function shout at 0x...>
print(shout("hi"))            # HI!
Notes
  • This is also the source of a quiet bug. if is_valid: where is_valid is a function is always true, because a function object is not falsy. You meant if is_valid():, and Python cannot tell the difference for you.

lambda: A Function Written as an Expression

The syntax is lambda parameters: expression. There is no name, no def, no colon-and-indent block, and no return — the expression's value is what gets returned. lambda x: x ** 2 is a complete function that squares its argument.

The body must be a single expression, which is the real constraint. An expression produces a value; a statement does something. So an if block, a for loop, an assignment, a try, a raise or a return cannot appear inside a lambda. A conditional expression can, because it produces a value — lambda n: "even" if n % 2 == 0 else "odd" is legal. When you find yourself wanting more, that is the language telling you to write a def.

Assigning a lambda to a name is legal and PEP 8 specifically advises against it. square = lambda x: x ** 2 and def square(x): return x ** 2 produce almost the same thing, except that the def version knows its own name. That matters when something goes wrong: a traceback from a named function says square, while one from a lambda says <lambda>, which tells you nothing about where to look in a file with a dozen of them. A def can also carry a docstring, and a lambda cannot.

So the honest rule is narrow: use a lambda when the function is short, obvious, and passed straight into something else that will call it. Everywhere else, use def. Lambdas are a convenience for argument lists, not a shorter way to write functions in general.

Example
# The same function, two ways
def square(x):
    return x ** 2

print((lambda x: x ** 2)(5))     # 25 — defined and called on the spot

# Several parameters, and defaults, work as usual
print((lambda a, b=10: a + b)(5))        # 15

# A conditional EXPRESSION is fine — it produces a value
parity = lambda n: "even" if n % 2 == 0 else "odd"
print(parity(7))                 # odd

# Statements are not allowed
# lambda x: total = x + 1        # SyntaxError — assignment is a statement
# lambda x: print(x); x + 1      # not a single expression either

# Why PEP 8 says not to name a lambda
square_l = lambda x: x ** 2
print(square.__name__)           # square
print(square_l.__name__)         # <lambda>   — unhelpful in a traceback
print(square.__doc__, square_l.__doc__)   # None None (a lambda cannot have one)

# Where a lambda genuinely belongs: inline, as an argument
students = [
    {"name": "Meera", "marks": 85},
    {"name": "Asha", "marks": 92},
    {"name": "Ravi", "marks": 78},
]
print(sorted(students, key=lambda s: s["marks"])[0]["name"])   # Ravi
  • lambda params: expression — no name, no return
  • The body must be one expression; statements are not allowed
  • A conditional expression (a if cond else b) is allowed
  • Naming a lambda is legal but discouraged — use def instead
  • A lambda has no docstring and shows as <lambda> in tracebacks
  • Use it when it is short, obvious, and passed straight to another function
Notes
  • If a built-in already does the job, prefer it to a lambda. key=len beats key=lambda s: len(s), and map(int, parts) beats map(lambda x: int(x), parts) — same behaviour, less to read.

The Real Home of the Lambda: key=

If lambdas earn their place anywhere, it is in the key argument of sorted(), min(), max() and list.sort(). The key function is called once per item and its result is what gets compared, so it turns "sort these" into "sort these by whatever I say".

That single idea covers most real sorting. Sort records by a field; sort names case-insensitively; sort files by size; find the student with the highest marks rather than the alphabetically last name. In every case you are not writing the comparison — you are naming the thing to compare on, and Python does the rest.

Returning a tuple from the key function gives multi-level sorting for free, because tuples compare position by position. key=lambda s: (-s["marks"], s["name"]) sorts by marks descending and breaks ties alphabetically. The minus sign reverses a numeric field only, which is what you need when the two levels sort in opposite directions — reverse=True would flip both.

One shortcut worth knowing: max(scores, key=scores.get) finds the key with the largest value in a dictionary, passing a bound method rather than a lambda. And for the very common "pull out field n" case, operator.itemgetter says the same thing as a lambda with slightly less noise, which is why you will see it in other people's code.

Example
students = [
    {"name": "Meera", "marks": 85, "branch": "CSE"},
    {"name": "Asha",  "marks": 92, "branch": "ECE"},
    {"name": "Ravi",  "marks": 85, "branch": "CSE"},
]

# Sort by one field
for s in sorted(students, key=lambda s: s["marks"], reverse=True):
    print(s["name"], s["marks"])

# min and max take the same key
top = max(students, key=lambda s: s["marks"])
print(top["name"])                    # Asha

# A tuple key sorts on several levels at once
ranked = sorted(students, key=lambda s: (-s["marks"], s["name"]))
print([s["name"] for s in ranked])    # ['Asha', 'Meera', 'Ravi']

# Sorting text the way a human reads it
names = ["meera", "Asha", "ravi", "Zoya"]
print(sorted(names, key=str.lower))   # ['Asha', 'meera', 'ravi', 'Zoya']

# The largest value in a dictionary
scores = {"Maths": 92, "Physics": 88, "English": 95}
print(max(scores, key=scores.get))    # English

# itemgetter says the same thing as a lambda
from operator import itemgetter
print(sorted(students, key=itemgetter("marks"))[0]["name"])   # Meera
Notes
  • The key function runs once per item, not once per comparison, so an expensive key is computed n times rather than n log n times. That makes key= the efficient way to sort by something costly to calculate.

map() and filter()

map(function, iterable) applies the function to every item. filter(function, iterable) keeps the items for which the function returns something truthy. Both are older than comprehensions and both are still in the language, so you need to be able to read them even if you rarely write them.

The detail that trips people up in Python 3 is that neither returns a list. They return lazy iterators, which produce values only as they are asked for. Printing one shows something like <map object at 0x...> rather than the results. Wrap it in list() to see them — and be aware that an iterator is consumed once, so looping over the same map object a second time gives nothing at all.

For most work a comprehension says the same thing more directly. [x ** 2 for x in numbers if x % 2 == 0] reads left to right in one pass; the equivalent nested map(lambda..., filter(lambda..., numbers)) has to be unpicked from the inside out. Modern Python style prefers the comprehension, and so should you by default.

There is one case where map genuinely wins: when you already have a named function to apply, so no lambda is needed. list(map(int, input().split())) is idiomatic and clear, and map(str.strip, lines) reads better than the comprehension. The rule of thumb is that map with a named function is fine, and map with a lambda is usually a comprehension in disguise.

Example
numbers = [1, 2, 3, 4, 5, 6]

# They return lazy iterators, not lists
print(map(lambda x: x ** 2, numbers))          # <map object at 0x...>
print(list(map(lambda x: x ** 2, numbers)))    # [1, 4, 9, 16, 25, 36]

evens = filter(lambda x: x % 2 == 0, numbers)
print(list(evens))       # [2, 4, 6]
print(list(evens))       # []  — the iterator is already used up

# Comprehensions usually read better
print([x ** 2 for x in numbers if x % 2 == 0])          # [4, 16, 36]
print(list(map(lambda x: x ** 2,
               filter(lambda x: x % 2 == 0, numbers)))) # same, harder to read

# Where map earns its place: an existing named function
parts = "87 92 78".split()
print(list(map(int, parts)))          # [87, 92, 78]

lines = ["  Asha ", " Ravi"]
print(list(map(str.strip, lines)))    # ['Asha', 'Ravi']

# map can walk several iterables at once
print(list(map(lambda a, b: a * b, [1, 2, 3], [10, 20, 30])))   # [10, 40, 90]
  • map(f, items) — apply f to every item; returns a lazy iterator
  • filter(f, items) — keep items where f(item) is truthy; also lazy
  • filter(None, items) — keep the truthy items themselves
  • Wrap in list() to see or reuse the results; an iterator runs out after one pass
  • Prefer a comprehension when a lambda would be needed; prefer map with a named function
Notes
  • Laziness is a feature, not just a quirk. map over a million-line file processes one line at a time and never builds the whole result in memory. A list comprehension over the same file builds all of it. Lesson 21 covers generators, which is the same idea made explicit.

reduce(), and Why You Rarely Need It

reduce(function, iterable) collapses a whole sequence into a single value. It takes the first two items, applies the function, takes that result together with the third item, and keeps going. Summing [1, 2, 3, 4] becomes 1+2, then 3+3, then 6+4, giving 10. It lives in functools — it was deliberately moved out of the built-ins in Python 3, which tells you something about how often it is the right answer.

For the common cases, a built-in already exists and is clearer: sum(), max(), min(), any(), all(), and "".join() for strings. Reaching for reduce to add numbers is a way of making simple code look clever, and reviewers will say so.

Where it does fit is an accumulation with no built-in — multiplying a sequence, intersecting a list of sets, or merging a list of dictionaries. Even then, an explicit loop is often just as short and easier to follow, and math.prod covers the multiplication case since Python 3.8.

Two practical details. The optional third argument is a starting value, and supplying it is usually right: it fixes the result type and it stops reduce from raising TypeError on an empty sequence. And the function you pass must take exactly two arguments — the running result and the next item — which is a common source of confusion the first time you use it.

Example
from functools import reduce

numbers = [1, 2, 3, 4]

print(reduce(lambda a, b: a + b, numbers))     # 10
print(sum(numbers))                            # 10 — say it this way instead

# A starting value: safer and clearer
print(reduce(lambda a, b: a + b, [], 0))       # 0
# print(reduce(lambda a, b: a + b, []))        # TypeError: empty sequence
                                               # with no initial value

# A case with no built-in: product
print(reduce(lambda a, b: a * b, numbers, 1))  # 24

import math
print(math.prod(numbers))                      # 24 — Python 3.8+, clearer

# Intersecting several sets
groups = [{1, 2, 3}, {2, 3, 4}, {3, 2}]
print(reduce(lambda a, b: a & b, groups))      # {2, 3}

# The same thing as a plain loop — often the better choice
result = groups[0]
for g in groups[1:]:
    result = result & g
print(result)                                  # {2, 3}
Notes
  • functools holds several other tools worth knowing later: partial for pre-filling arguments, lru_cache for caching results, and wraps which you will meet in the decorators lesson.

Late Binding: the Lambda-in-a-Loop Trap

Build a list of lambdas inside a loop and every one of them behaves identically — and not the way you expect. [lambda: i for i in range(3)] gives three functions that all return 2, not one each returning 0, 1 and 2.

The reason is late binding. A lambda does not capture the value of i at the moment it was created; it captures the variable i and looks it up when it is called. By the time you call any of them, the loop has finished and i holds its final value. All three functions read the same variable and all three see the same answer.

This is not a lambda quirk — a nested def behaves the same way, because both create closures over the enclosing scope. It bites in practice when you build a list of callbacks, attach handlers to buttons in a loop, or construct validators from a list of rules.

The standard fix uses default arguments, which are evaluated when the function is defined: lambda i=i: i captures the current value into the parameter. It looks strange the first time, and it is the accepted idiom. functools.partial(func, i) does the same job more explicitly and is easier to read when the function has real arguments.

Note the pleasing symmetry with Lesson 13. Default arguments being evaluated once, at definition time, is what causes the mutable-default bug — and it is exactly what makes them the cure here. One rule, two consequences, in opposite directions.

Example
# The trap
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])      # [2, 2, 2]  — not [0, 1, 2]

# The same thing with a nested def — not a lambda quirk
def make_all():
    out = []
    for i in range(3):
        def show():
            return i
        out.append(show)
    return out

print([f() for f in make_all()])  # [2, 2, 2]

# Fix 1: a default argument captures the value now
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])      # [0, 1, 2]

# Fix 2: functools.partial — more explicit
from functools import partial

def power(exponent, base):
    return base ** exponent

funcs = [partial(power, e) for e in range(3)]
print([f(2) for f in funcs])     # [1, 2, 4]

# Why it matters in real code
rules = ["marks", "attendance"]
checks = [lambda row, field=field: field in row for field in rules]
print([c({"marks": 87}) for c in checks])   # [True, False]
Notes
  • The giveaway symptom is a set of callbacks that all behave like the last item in the loop. If you see that, look for a closure that captured the loop variable instead of its value.
Ask AI