Python Lesson 6 of 71

Lambda Functions & Higher-Order Concepts: Functions as First-Class Objects

You already know how to write a function and call it: def greet(name): ... and then greet("Ada"). This lesson is about the small shift in thinking that unlocks a surprising amount of Python.

Look at those two lines again. greet("Ada")with parentheses — runs the function. But greetwithout parentheses — is not a command at all. It is a thing. An object sitting in memory, exactly as real as the string "Ada" or the number 36, and you can do everything to it that you can do to any other value: give it a second name, drop it in a list, use it as a dictionary value, pass it into another function, or return it from one.

That is what “first-class” means. Functions in Python are first-class objects. Once you see it, sorted(people, key=lambda p: p["age"]) stops being a magic incantation you copy from Stack Overflow and becomes something obvious: you handed sorted a small function, and sorted called it for you.

Everything else here — lambda, higher-order functions, callbacks, dispatch tables — is a consequence of that one fact.


Why this matters

Almost every real Python codebase asks you to hand a function to something else. You sort a list of database rows by one field. You give a web framework a function to run when a URL is hit. You give a button a function to run when it’s clicked. You give pandas a function to apply to a column. In every case, you are not calling your function — you are handing it over, and something else calls it later.

Beginners hit a wall here, and it is almost always the same wall: the parentheses. You write key=by_age() when you meant key=by_age, Python raises a TypeError you don’t recognise, and the fix feels arbitrary. It isn’t. by_age is the function; by_age() is the result of running it. Two completely different values. Get that distinction and half the errors in this lesson vanish.

The mental model to carry: a function name is a variable like any other, and it happens to hold something callable. def doesn’t announce a function to the interpreter — it builds an object and binds a name to it, exactly like x = 5 builds an integer and binds x. There is no separate “function namespace”; there is one namespace, and functions live in it next to your strings and lists.

The payoff is real and immediate: sorting anything by anything in one line, replacing sprawling if/elif chains with a dictionary, and writing code that takes behaviour as a parameter instead of hard-coding it.


Functions are objects, not magic syntax

Let’s prove the claim rather than assert it. Type this in a file or at the python3 prompt:

def shout(text: str) -> str:
    """Return text in caps with an exclamation mark."""
    return text.upper() + "!"

print(type(shout))      # => <class 'function'>
print(shout.__name__)   # => shout
print(shout.__doc__)    # => Return text in caps with an exclamation mark.
print(callable(shout))  # => True

Read that first line of output carefully: <class 'function'>. That is the same shape as <class 'int'> or <class 'str'>. A function is an instance of a class, no more special than a list. callable(shout) is True — that’s the only thing that really distinguishes it from a string: you may put () after it.

Because it is an object, a second name can point at the very same object:

yell = shout            # no parentheses: bind the SAME object to a second name
print(yell("hello"))    # => HELLO!
print(yell is shout)    # => True
print(yell.__name__)    # => shout   <-- the object never learned the new name

That last line is the tell. yell and shout are two labels on one object, and the object’s __name__ was set once, by the def, and never changes. Names point at objects; objects don’t know their names.

Where a function can go. If functions are first-class, every one of these must work — and every one does:

You can… Example Why it works
Bind it to another name yell = shout A name is just a label on an object
Put it in a list steps = [str.strip, str.lower] Lists hold any object
Put it in a dict (as a value) OPS = {"+": add} The dispatch-table trick, below
Use it as a dict key / set member {add: "addition"} Functions are hashable
Pass it into a function sorted(rows, key=by_age) It’s just an argument
Return it from a function return multiply It’s just a return value
Attach attributes to it shout.calls = 0 It has a __dict__, like most objects
Inspect it at runtime shout.__name__, shout.__doc__ It carries its own metadata

A list of functions is not a party trick — it’s a pipeline:

transforms = [str.strip, str.lower, str.title]
value = "  aDA lovelace  "

for f in transforms:
    value = f(value)                     # f is a different function each time round
    print(f"{f.__name__:<6} -> {value!r}")
strip  -> 'aDA lovelace'
lower  -> 'ada lovelace'
title  -> 'Ada Lovelace'

Notice we never wrote str.strip(...) inside the list. We stored the functions and let the loop call them. The sequence of behaviour became data — a list you could reorder, filter, or load from a config file.

The metadata a function carries. Since it’s an object, it has attributes worth knowing:

Attribute What it holds For shout above
__name__ The name the def gave it 'shout'
__qualname__ Its dotted path (shows nesting/classes) 'shout'
__doc__ The docstring 'Return text in caps…'
__module__ Which module defined it '__main__'
__annotations__ Type hints, as a dict {'text': <class 'str'>, 'return': <class 'str'>}
__defaults__ Default values for parameters None (it has none)
__code__ The compiled code object co_varnames('text',)
__closure__ Captured enclosing variables None unless it’s a closure

You won’t touch most of these day to day, but they are the reason debuggers, functools.wraps, and every decorator library work at all — and __name__ is about to prove a point about lambda.


Higher-order functions: take one, return one

A higher-order function is a function that does either of two things: takes a function as an argument, or returns a function. That’s the entire definition. There is no special syntax and nothing to import — it falls straight out of “functions are objects.”

Here is one that takes a function:

def apply_twice(func, value):
    """Call func on value, then call it again on the result."""
    return func(func(value))

def add_three(n):
    return n + 3

print(apply_twice(add_three, 10))   # => 16      (10 -> 13 -> 16)

apply_twice has no idea what func does. It doesn’t care. The behaviour is a parameter, supplied by the caller — that’s the whole point. Note again: we passed add_three, not add_three().

And here is one that returns a function:

def multiplier(factor):
    def multiply(n):
        return n * factor      # `factor` comes from the enclosing scope
    return multiply            # return the function OBJECT, uncalled

double = multiplier(2)
triple = multiplier(3)

print(double(7), triple(7))   # => 14 21
print(double.__name__)        # => multiply
print(type(double))           # => <class 'function'>

multiplier is a factory: it builds and hands back a brand-new function each time it’s called. The returned multiply remembers the factor it was born with — a function bundled with the variables it captured is called a closure. Two calls, two independent closures, two different remembered factors. (Closures come back to bite us at the end of this lesson, so hold that thought.)

You already use higher-order functions. The stdlib is full of them:

Higher-order function Takes a function as What it does
sorted(it, key=f) key Returns a new sorted list
list.sort(key=f) key Sorts the list in place, returns None
min(it, key=f) / max(it, key=f) key Smallest / largest by that key
map(f, it) 1st positional Lazily applies f to every item
filter(f, it) 1st positional Lazily keeps items where f(item) is truthy
functools.reduce(f, it) 1st positional Folds a sequence to one value
functools.partial(f, *a) 1st positional Returns a new function with args pre-filled
Decorators (@app.route) The decorated function Take a function, return a replacement

A first look at map and filter. These are the two classic higher-order functions, and they’re worth meeting now — with an honest caveat.

nums = [1, 2, 3, 4, 5, 6]

squares = map(lambda n: n * n, nums)
print(squares)          # => <map object at 0x104cbf7f0>   <-- NOT a list!
print(list(squares))    # => [1, 4, 9, 16, 25, 36]
print(list(squares))    # => []   <-- exhausted: it was a one-shot iterator

Two surprises for beginners, both important. map does not return a list — it returns a lazy iterator that produces values only when you ask. And once you’ve walked it, it’s spent; the second list() gets nothing. filter behaves identically:

evens = filter(lambda n: n % 2 == 0, nums)
print(list(evens))      # => [2, 4, 6]

Now the caveat. In modern Python, most developers would write both of those as comprehensions, and most style guides agree they read better:

print([n * n for n in nums])            # => [1, 4, 9, 16, 25, 36]
print([n for n in nums if n % 2 == 0])  # => [2, 4, 6]
Task map/filter Comprehension Which usually wins
Transform with a lambda map(lambda n: n*n, xs) [n*n for n in xs] Comprehension — no lambda noise
Transform with an existing function map(str.strip, xs) [s.strip() for s in xs] map — genuinely tidy, no lambda needed
Filter filter(lambda n: n > 0, xs) [n for n in xs if n > 0] Comprehension — reads like English
Transform and filter map(f, filter(g, xs)) [f(n) for n in xs if g(n)] Comprehension — nesting gets ugly fast
Huge / infinite data Lazy, memory-cheap [...] builds it all map/filter (or a generator expression)
Feed straight to sum/max sum(map(int, xs)) sum(int(x) for x in xs) Tie — both are lazy and fine

The rule of thumb: map(some_named_function, xs) is lovely; map(lambda …, xs) is a comprehension wearing a disguise.

rows = ["  ada \n", " linus  ", "grace\t"]
print(list(map(str.strip, rows)))   # => ['ada', 'linus', 'grace']

Comprehensions and generators get a full lesson of their own later in this course. For now, know that map/filter exist, that they’re lazy, and that you’ll read them in other people’s code.


lambda: a function in a single expression

A lambda is a function written inline, in one expression, without a name. The syntax is lambda <parameters>: <single expression>, and the value of that expression is automatically returned — there is no return keyword.

square = lambda n: n * n

print(square(5))        # => 25
print(type(square))     # => <class 'function'>
print(square.__name__)  # => <lambda>

Look at the middle line: <class 'function'>. A lambda is not a different kind of thing. It creates exactly the same type of object def creates. The only differences are that it’s an expression (so it can appear inline, mid-argument-list), it’s limited to one expression, and — third line — it never gets a real name. It is called <lambda> forever.

These two are near-identical:

def square(n):
    return n * n

square = lambda n: n * n     # same object type, worse __name__

The one hard rule: a lambda body is an expression, not a statement. An expression produces a value (n * n, x > 0, f(a), a if b else c). A statement does something (return, if:, for:, x = 5, raise, import). Lambdas take only the former, and Python rejects the latter at parse time:

f = lambda n: return n
  File "/home/you/demo.py", line 1
    f = lambda n: return n
                  ^^^^^^
SyntaxError: invalid syntax

That is a SyntaxError — your file never even ran. Here’s what does and doesn’t fit:

You want… In a lambda Why Do this instead
return x SyntaxError The expression is the return value lambda: x
x = 5 (assignment) SyntaxError Assignment is a statement Use a def, or := where it truly fits
n += 1 SyntaxError Augmented assignment is a statement lambda n: n + 1
if x: … else: … (block) SyntaxError The statement form is a block lambda x: "pos" if x > 0 else "neg"
for/while loop SyntaxError Loops are statements Use a def, or a comprehension
raise ValueError(...) SyntaxError raise is a statement Use a def
import os SyntaxError import is a statement Import at module top
Call a function A call is an expression lambda x: print(x)
Conditional expression Ternary is an expression lambda n: "even" if n % 2 == 0 else "odd"
Default / *args params Same parameter rules as def lambda a, b=10: a + b

Note the if row twice: the statement if x: ... is banned, but the conditional expression a if cond else b is perfectly legal, because it produces a value.

PEP 8: don’t assign a lambda to a name. PEP 8 is explicit: always use a def statement instead of binding a lambda to an identifier. Linters enforce it — ruff and flake8 flag it as E731 (lambda-assignment). This is not pedantry, and here is the concrete reason. Take this file:

divide = lambda a, b: a / b

print(divide(1, 0))
Traceback (most recent call last):
  File "/home/you/demo.py", line 3, in <module>
    print(divide(1, 0))
          ^^^^^^^^^^^^
  File "/home/you/demo.py", line 1, in <lambda>
    divide = lambda a, b: a / b
                          ~~^~~
ZeroDivisionError: division by zero

Read the second frame: in <lambda>. In a real traceback, five levels deep, with three lambdas in play, every one of them is called <lambda> and you cannot tell which blew up. Write it as a def and the same crash says in divide:

  File "/home/you/demo.py", line 2, in divide
    return a / b
           ~~^~~
ZeroDivisionError: division by zero

The logic is simple: the entire benefit of a lambda is that it’s anonymous and inline. The moment you give it a name, you’ve thrown away the benefit and kept every drawback — a worse traceback, no docstring, no clean place for type hints, no multi-line body. You have written a def badly.

So when is a lambda actually right? Here is the honest table:

Situation Use Why
A tiny key/callback, used once, right there lambda sorted(rows, key=lambda r: r["age"]) — naming it would be noise
It fits in one short expression and reads instantly lambda The reader’s eye never leaves the line
You’re assigning it to a name def PEP 8 / E731 — you gain nothing, lose the traceback
It needs a return, if: block, loop, or raise def Physically impossible in a lambda
It needs a docstring or type hints def Lambdas can’t carry either usefully
It’s reused in more than one place def Give it one name and one home
It’s longer than ~one comfortable line def If you’re squinting, it’s too long
You need a good name to explain why def def is_overdue(inv): documents itself
An existing function already does it Neither key=str.lower, key=len — don’t wrap it
Getting an item/attribute from each record operator key=itemgetter("age") — see below

That last row matters more than people expect, and it’s next.


The workhorse: sorting with key=

If you use first-class functions for one thing in your first year of Python, it will be key=. This is where the whole idea earns its keep.

Take some records — a list of dicts, the shape you get back from a database, a CSV, or a JSON API:

people = [
    {"name": "Ada",   "age": 36, "city": "London"},
    {"name": "Linus", "age": 54, "city": "Portland"},
    {"name": "Grace", "age": 45, "city": "New York"},
    {"name": "Guido", "age": 68, "city": "San Jose"},
]

by_age = sorted(people, key=lambda p: p["age"])
print([p["name"] for p in by_age])   # => ['Ada', 'Grace', 'Linus', 'Guido']

What just happened: sorted walked the list, and for each record it called your little function to ask “what value should I sort this one on?” Your lambda answered 36, 54, 45, 68, and sorted ordered the records by those answers. You supplied the policy; sorted supplied the mechanism.

This is the exact flow the diagram below draws. Follow it left to right: def builds a function object and binds it to a name (that’s all a def does); you hand sorted the bare name — no parentheses, because you’re not calling it, you’re giving it away; sorted calls it once per record to build a sort key; and you get a new list back. The bottom-right box is the same trick filed away instead of handed over — a dict mapping a name to a function, which we build in the next section.

Left-to-right flow showing a Python function object created by def in the namespace, passed by bare name as key= into the higher-order function sorted, invoked once per record to produce sort keys, stable-sorted, and returned as a new list — plus a dispatch dict routing an operator name to a function object, with the key=by_age() parentheses mistake flagged as a TypeError

The badges mark the six things worth remembering: def builds an object (1); you pass the bare name, never the call (2); adding () calls it too early and raises TypeError (3); sorted is a higher-order function because it accepts your callable (4); the key runs once per item, not once per comparison (5); and a dict of functions routes a command with no if/elif (6).

That fifth badge is a real, checkable claim. Prove it:

calls = []

def by_age(record):
    calls.append(record["name"])   # spy on every call
    return record["age"]

sorted(people, key=by_age)

print(len(people))   # => 4
print(len(calls))    # => 4        <-- exactly one call per record
print(calls)         # => ['Ada', 'Linus', 'Grace', 'Guido']   (original order)

Four records, four calls, made in the original order before any sorting happens. Python does a decorate–sort–undecorate: compute all the keys first, then sort those. Two consequences worth knowing: an expensive key function costs you O(n) calls (not O(n log n) comparisons), and the key must return something orderable or you’ll get a TypeError.

min and max take the same key= — and return the whole record, not the key:

print(max(people, key=lambda p: p["age"])["name"])   # => Guido
print(min(people, key=lambda p: p["age"])["name"])   # => Ada

reverse=True flips the order, and it’s better than sorting and calling .reverse():

print([p["name"] for p in sorted(people, key=lambda p: p["age"], reverse=True)])
# => ['Guido', 'Linus', 'Grace', 'Ada']

The key= family, precisely:

Call Signature Returns Note
sorted(iterable, *, key=None, reverse=False) key is keyword-only A new list Works on any iterable — tuples, sets, dict keys, generators
list.sort(*, key=None, reverse=False) key is keyword-only None Lists only; sorts in place
max(iterable, *, key=None, default=…) keyword-only One item ValueError on empty unless default= given
min(iterable, *, key=None, default=…) keyword-only One item Same
map(func, iterable) func is positional Lazy iterator Wrap in list() to see it
filter(func, iterable) func is positional Lazy iterator filter(None, xs) drops falsy items

“Keyword-only” is not trivia — it’s a trap. Pass the function positionally and you get a bewildering error:

sorted(people, lambda p: p["age"])
# TypeError: sorted expected 1 argument, got 2

You must write key=. (nums.sort(lambda x: -x) fails likewise with TypeError: sort() takes no positional arguments.)

sorted() vs .sort() — the classic beginner trap. They are not interchangeable:

nums = [3, 1, 2]
result = nums.sort()
print(result)   # => None        <-- .sort() returns NOTHING
print(nums)     # => [1, 2, 3]   <-- it changed the list itself

nums2 = [3, 1, 2]
result2 = sorted(nums2)
print(result2)  # => [1, 2, 3]   <-- a brand-new list
print(nums2)    # => [3, 1, 2]   <-- the original is untouched
sorted(x) x.sort()
Returns A new sorted list None
Original list Unchanged Mutated in place
Works on Any iterable (str, tuple, set, dict, generator) Lists only
Memory Builds a second list Cheaper — no copy
Chainable? Yes: sorted(x)[0] Nox.sort()[0] raises TypeError
Use when You need a sorted copy, or the input isn’t a list You own the list and don’t need the original

Python’s docs say it best — list.sort is documented as “Sort the list in ascending order and return None.” That None is deliberate: it’s a signal that the method mutated its receiver. Which is why this is such a common bug:

for n in nums.sort():
    print(n)
# TypeError: 'NoneType' object is not iterable

Sorting by two things at once — return a tuple from your key. Tuples compare element by element, so this sorts by team ascending, then commits descending:

staff = [
    {"name": "ada",   "team": "platform", "age": 36, "commits": 812},
    {"name": "linus", "team": "kernel",   "age": 54, "commits": 1290},
    {"name": "grace", "team": "platform", "age": 45, "commits": 1290},
    {"name": "guido", "team": "kernel",   "age": 68, "commits": 640},
]

print([r["name"] for r in sorted(staff, key=lambda r: (r["team"], -r["commits"]))])
# => ['linus', 'guido', 'grace', 'ada']

The -r["commits"] is the trick for “descending” inside a multi-key sort — but it only works on numbers. -r["name"] raises TypeError: bad operand type for unary -: 'str'. To sort by one string ascending and another descending, use the stability property instead: sort twice, least-significant key first.

Sorting is stable, and that’s a guarantee, not an accident. Records with equal keys keep their original relative order:

print([r["name"] for r in sorted(staff, key=lambda r: r["commits"])])
# => ['guido', 'ada', 'linus', 'grace']

linus and grace both have 1290 commits, and linus stays first because he came first in the input. (CPython’s sort is Timsort: O(n log n) worst case, O(n) on already-sorted data.)

Use an existing function when one exists. The most common case is case-insensitive sorting:

words = ["banana", "Apple", "Cherry", "date"]
print(sorted(words))                  # => ['Apple', 'Cherry', 'banana', 'date']
print(sorted(words, key=str.lower))   # => ['Apple', 'banana', 'Cherry', 'date']

The default sort compares raw code points, so every capital letter sorts before every lowercase one. key=str.lower fixes it — and note there’s no lambda: str.lower is already a one-argument function, so just pass it.

operator.itemgetter / attrgetter: the cleaner alternative. A lambda that does nothing but reach into a record is boilerplate. The operator module has purpose-built callables:

from operator import itemgetter, attrgetter

# Instead of: key=lambda p: p["age"]
print([p["name"] for p in sorted(people, key=itemgetter("age"))])
# => ['Ada', 'Grace', 'Linus', 'Guido']

# Multi-key, without building a tuple by hand:
print([r["name"] for r in sorted(staff, key=itemgetter("team", "name"))])
# => ['guido', 'linus', 'ada', 'grace']

itemgetter("team", "name") returns a tuple of both fields — exactly the multi-key trick, spelled shorter. attrgetter is the same idea for objects:

from dataclasses import dataclass

@dataclass
class Server:
    host: str
    cpu: float

servers = [Server("web-02", 71.5), Server("web-01", 12.0), Server("db-01", 93.2)]
print([s.host for s in sorted(servers, key=attrgetter("cpu"), reverse=True)])
# => ['db-01', 'web-02', 'web-01']
Need With lambda With operator Verdict
One dict field lambda p: p["age"] itemgetter("age") operator — less noise
Two dict fields lambda p: (p["a"], p["b"]) itemgetter("a", "b") operator — much cleaner
One tuple/list index lambda t: t[1] itemgetter(1) operator
One object attribute lambda s: s.cpu attrgetter("cpu") operator
A nested attribute lambda n: n.meta.region attrgetter("meta.region") operator — dotted paths work
Descending on a number lambda p: -p["age"] lambda (or reverse=True)
Any computation lambda p: p["a"] / p["b"] lambdaoperator only fetches
A method call per item lambda s: s.strip() methodcaller("strip") Either; str.strip is simplest
Speed on big lists Python-level call C-level — measurably faster operator

The dividing line is simple: operator fetches, lambda computes. If your lambda body is just p["x"] or p.x, reach for operator.

Recipes worth memorising:

Goal Code
Sort dicts by a field sorted(rows, key=itemgetter("age"))
…descending sorted(rows, key=itemgetter("age"), reverse=True)
Sort by two fields sorted(rows, key=itemgetter("team", "name"))
Field A up, field B down (numeric) sorted(rows, key=lambda r: (r["a"], -r["b"]))
Case-insensitive strings sorted(words, key=str.lower)
By string length sorted(words, key=len)
Sort a dict’s keys sorted(d)
Sort a dict by its values sorted(d.items(), key=itemgetter(1))
Objects by attribute sorted(objs, key=attrgetter("cpu"))
Biggest record by a field max(rows, key=itemgetter("commits"))
Top 3 sorted(rows, key=..., reverse=True)[:3]
Sort in place, no copy rows.sort(key=itemgetter("age"))

Callbacks and dispatch tables

Now the payoff — the two patterns you’ll actually write at work.

A callback is a function you hand to someone else so they can call it when the moment comes. You’ve been writing them all section: key= is a callback. Here it is stripped bare:

def each_line(lines, callback):
    """Hand every line to whatever the caller wants done with it."""
    for n, line in enumerate(lines, start=1):
        callback(n, line)

each_line(["alpha", "beta"], lambda n, text: print(f"{n:>3}: {text}"))
  1: alpha
  2: beta

each_line owns the looping; the caller owns the doing. Swap the lambda for a database-writing function and the loop never changes. This is exactly how web frameworks (@app.route), GUI buttons (on_click=), schedulers, and test fixtures all work.

A dispatch table is the real workhorse: a dict mapping a name to a function. Say you’re writing a small calculator. The instinct is a chain of ifs:

def calculate(a, op, b):          # the version you should NOT write
    if op == "+":
        return a + b
    elif op == "-":
        return a - b
    elif op == "*":
        return a * b
    elif op == "/":
        return a / b
    else:
        raise ValueError(f"unknown operator {op!r}")

Because functions are values, that whole chain collapses into a lookup:

def add(a, b): return a + b
def sub(a, b): return a - b
def mul(a, b): return a * b
def div(a, b): return a / b

# The VALUES are function objects, not calls. No () anywhere here.
OPS = {"+": add, "-": sub, "*": mul, "/": div}

def calculate(a, op, b):
    action = OPS.get(op)                       # look up the function
    if action is None:
        raise ValueError(f"unknown operator {op!r}; try one of {sorted(OPS)}")
    return action(a, b)                        # NOW call it

print(OPS["+"])            # => <function add at 0x104e2f060>
print(OPS["+"].__name__)   # => add
print(calculate(6, "+", 7))  # => 13
print(calculate(6, "*", 7))  # => 42
print(calculate(6, "/", 4))  # => 1.5

The two-step is the thing to internalise: OPS[op] fetches the function; OPS[op](a, b) fetches it and calls it. And the unknown case stays friendly because the dict knows its own keys:

calculate(1, "^", 2)
# ValueError: unknown operator '^'; try one of ['*', '+', '-', '/']
Aspect if/elif chain Dict dispatch
Add a case Edit the function, add a branch Add one dict entry
Lookup cost O(n) — checks each branch in turn O(1) hash lookup
List valid options Hand-maintain a second list sorted(OPS) — always correct
Register from elsewhere Impossible without editing OPS["%"] = mod at runtime, or via a plugin
Reuse one handler Copy the branch OPS["x"] = OPS["*"] — alias it
Test one case Call through the whole chain Test add directly
Default behaviour Final else: OPS.get(op, unknown)
Readability at 20 cases 60 lines of branches A 20-line table you can read at a glance
Best when Conditions are ranges/complex (if x > 100) Dispatch is on an exact value

That last row is the honest boundary. Dispatch tables key off an exact value. If your conditions are ranges or compound expressions, if/elif (or match) is the right tool.

A tidy variant supplies the default in the lookup itself:

def unknown(a, b):
    raise ValueError("unsupported operation")

action = OPS.get(op, unknown)   # no None-check needed
return action(a, b)

⚠️ The keys must be hashable. A natural-looking attempt to register aliases fails immediately:

OPS = {["+", "add"]: add}
# TypeError: unhashable type: 'list'

Lists are mutable, so they can’t be dict keys. Use a tuple (("+", "add") is hashable) or, more usefully, add one entry per alias: OPS["+"] = OPS["add"] = add. Amusingly, the functions themselves are hashable, so the reverse table works fine: {add: "addition", sub: "subtraction"}.


The late-binding trap — and functools.partial

This is the nastiest gotcha in the lesson, and unlike most bugs it does not crash — it silently returns wrong data. Try to predict the output before reading on:

makers = [lambda: i for i in range(3)]
print([f() for f in makers])
[2, 2, 2]

Not [0, 1, 2]. All three lambdas return 2.

Why. A closure captures the variable, not the variable’s value at creation time. The lambda body says “look up i” — and it does that lookup when it’s called, not when it’s created. By the time you call them, the loop has long finished and i is sitting at its final value, 2. All three lambdas share the same i, and they all read 2. This is called late binding. A plain for loop behaves identically:

makers = []
for i in range(3):
    makers.append(lambda: i)
print([f() for f in makers])   # => [2, 2, 2]

In a toy example it’s obvious. In real code it isn’t. Here’s the same bug wearing work clothes — one “column picker” per index:

row = ["web-01", "healthy", "12.0"]
pickers = [lambda r: r[n] for n in range(3)]
print([pick(row) for pick in pickers])
# => ['12.0', '12.0', '12.0']       <-- expected ['web-01', 'healthy', '12.0']

No traceback. No warning. Just three copies of the last column, quietly wrong, all the way into your report.

The fix: bind the value now, at creation time. Three ways, all producing [0, 1, 2]:

# 1 — default argument (the idiomatic one-liner)
fixed = [lambda i=i: i for i in range(3)]
print([f() for f in fixed])            # => [0, 1, 2]

# 2 — functools.partial: pre-fills the argument immediately
from functools import partial
def identity(n): return n
fixed = [partial(identity, i) for i in range(3)]
print([f() for f in fixed])            # => [0, 1, 2]

# 3 — a factory: each call gets its own fresh variable
def make_getter(n):
    return lambda: n
fixed = [make_getter(i) for i in range(3)]
print([f() for f in fixed])            # => [0, 1, 2]

Fix 1 works because default arguments are evaluated once, at definition time. lambda i=i: i reads “make a parameter i whose default is the current value of the loop’s i”. The odd-looking i=i is deliberate idiom, not a typo.

Fix Code When to use Trade-off
Default argument lambda i=i: i The quick, standard fix Adds a parameter a caller could override
functools.partial partial(f, i) You already have a named function Needs the function to take the value as an arg
Factory function make_getter(i) Clearest for anything non-trivial Three extra lines
Bind in the call f(i) immediately You didn’t need to defer at all Not always possible

functools.partial, briefly. partial takes a function plus some arguments and returns a new callable with those arguments already filled in:

from functools import partial

def connect(host, port, timeout):
    return f"{host}:{port} (timeout={timeout}s)"

local = partial(connect, "localhost", 5432)   # freeze the first two arguments

print(local(timeout=3))     # => localhost:5432 (timeout=3s)
print(type(local))          # => <class 'functools.partial'>
print(local.func.__name__)  # => connect
print(local.args)           # => ('localhost', 5432)

It’s especially handy for key=, which demands a one-argument callable — partial can shrink a two-argument function down to fit:

def nth_char(s, n):
    return s[n]

words = ["kiwi", "apple", "fig"]
print(sorted(words, key=partial(nth_char, n=1)))   # => ['kiwi', 'fig', 'apple']
partial(f, x) lambda: f(x)
When is x read? Immediately, at partial() time Later, when called — late binding!
Introspectable? Yes — .func, .args, .keywords No — opaque <lambda>
Picklable (multiprocessing) Yes (if f is) No — lambdas can’t be pickled
Readability Clear with a well-named f Fine for tiny inline cases
Speed Slightly faster (C implementation) One extra Python frame

That first row is the whole reason partial fixes late binding: it evaluates its arguments when you build it.


Hands-on lab

Everything here is the standard library — there is nothing to pip install. A virtual environment is still good practice, so let’s start one out of habit.

Step 1 — Set up.

mkdir -p ~/py-lab/firstclass && cd ~/py-lab/firstclass
python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
python --version                   # => Python 3.12.x  (3.12+ recommended)

What just happened: you made an isolated environment. On Windows use python rather than python3; on macOS/Linux both usually work once the venv is active.

Step 2 — Prove a function is an object. Create records.py:

"""Shared data + helpers for the lab."""

STAFF = [
    {"name": "ada",   "team": "platform", "age": 36, "commits": 812},
    {"name": "linus", "team": "kernel",   "age": 54, "commits": 1290},
    {"name": "grace", "team": "platform", "age": 45, "commits": 1290},
    {"name": "guido", "team": "kernel",   "age": 68, "commits": 640},
]


def by_age(record):
    """Return the sort key for one record: its age."""
    return record["age"]


if __name__ == "__main__":
    print(type(by_age))
    print(by_age.__name__)
    print(callable(by_age))
    print(by_age(STAFF[0]))
python records.py
<class 'function'>
by_age
True
36

What just happened: by_age is an object of <class 'function'> that knows its own name — and it’s callable, so by_age(STAFF[0]) returns 36.

Step 3 — Sort the same records nine ways. Create sorting.py:

from operator import itemgetter

from records import STAFF, by_age


def names(rows):
    """Pull just the names out, so the output stays readable."""
    return [r["name"] for r in rows]


print("1 by age        :", names(sorted(STAFF, key=by_age)))
print("2 by age desc   :", names(sorted(STAFF, key=by_age, reverse=True)))
print("3 by name       :", names(sorted(STAFF, key=lambda r: r["name"])))
print("4 by commits    :", names(sorted(STAFF, key=itemgetter("commits"))))
print("5 team, -commits:", names(sorted(STAFF, key=lambda r: (r["team"], -r["commits"]))))
print("6 itemgetter x2 :", names(sorted(STAFF, key=itemgetter("team", "name"))))
print("7 oldest        :", max(STAFF, key=by_age)["name"])
print("8 fewest commits:", min(STAFF, key=itemgetter("commits"))["name"])
print("9 original list :", names(STAFF))
python sorting.py
1 by age        : ['ada', 'grace', 'linus', 'guido']
2 by age desc   : ['guido', 'linus', 'grace', 'ada']
3 by name       : ['ada', 'grace', 'guido', 'linus']
4 by commits    : ['guido', 'ada', 'linus', 'grace']
5 team, -commits: ['linus', 'guido', 'grace', 'ada']
6 itemgetter x2 : ['guido', 'linus', 'ada', 'grace']
7 oldest        : guido
8 fewest commits: guido
9 original list : ['ada', 'linus', 'grace', 'guido']

What just happened: one dataset, six orderings, one line each — the only thing that changed was the function you handed over. Line 4 shows stability (linus before grace, both on 1290, original order kept). Line 9 proves sorted never touched STAFF.

Now break it on purpose. Add this line, run it, then delete it:

print(sorted(STAFF))
# TypeError: '<' not supported between instances of 'dict' and 'dict'

What just happened: with no key=, Python tried to compare two dicts directly. Dicts have no <. This traceback means “you forgot key=.”

Step 4 — Build a dispatch table. Create dispatch.py:

"""A mini calculator routed by a dict of name -> function."""


def add(a, b):
    return a + b


def sub(a, b):
    return a - b


def mul(a, b):
    return a * b


def div(a, b):
    if b == 0:
        raise ValueError("cannot divide by zero")
    return a / b


# The dispatch table: the VALUES are function objects, not calls. No () here.
OPS = {"+": add, "-": sub, "*": mul, "/": div}


def calculate(a, op, b):
    """Look the operator up, then call whatever we found."""
    action = OPS.get(op)
    if action is None:
        raise ValueError(f"unknown operator {op!r}; try one of {sorted(OPS)}")
    return action(a, b)


if __name__ == "__main__":
    print("OPS['+']        :", OPS["+"].__name__)
    print("6 + 7           :", calculate(6, "+", 7))
    print("6 - 7           :", calculate(6, "-", 7))
    print("6 * 7           :", calculate(6, "*", 7))
    print("6 / 4           :", calculate(6, "/", 4))
    print("known operators :", sorted(OPS))
    try:
        calculate(1, "^", 2)
    except ValueError as exc:
        print("bad operator    :", exc)
python dispatch.py
OPS['+']        : add
6 + 7           : 13
6 - 7           : -1
6 * 7           : 42
6 / 4           : 1.5
known operators : ['*', '+', '-', '/']
bad operator    : unknown operator '^'; try one of ['*', '+', '-', '/']

What just happened: zero if/elif branches for the routing. OPS["+"] handed back the add object (its __name__ proves it), and action(a, b) called it. Now add a modulo operator — notice you only touch the table:

def mod(a, b):
    return a % b

OPS["%"] = mod
print(calculate(7, "%", 4))   # => 3

Step 5 — Reproduce the late-binding bug, then fix it. Create latebind.py:

"""Reproduce the late-binding bug, then fix it three ways."""

from functools import partial

# --- THE BUG -----------------------------------------------------------------
buggy = [lambda: i for i in range(3)]
print("buggy   :", [f() for f in buggy])

# The same bug in work clothes: one 'column picker' per index.
row = ["web-01", "healthy", "12.0"]
pickers_buggy = [lambda r: r[n] for n in range(3)]
print("pickers :", [pick(row) for pick in pickers_buggy])

# --- FIX 1: bind now with a default argument ---------------------------------
fixed_default = [lambda i=i: i for i in range(3)]
print("default :", [f() for f in fixed_default])

# --- FIX 2: bind now with functools.partial ----------------------------------
def identity(n):
    return n

fixed_partial = [partial(identity, i) for i in range(3)]
print("partial :", [f() for f in fixed_partial])

# --- FIX 3: a factory gives each closure its own variable ---------------------
def make_getter(n):
    return lambda: n

fixed_factory = [make_getter(i) for i in range(3)]
print("factory :", [f() for f in fixed_factory])

# The realistic picker, fixed:
pickers_fixed = [lambda r, n=n: r[n] for n in range(3)]
print("pickers2:", [pick(row) for pick in pickers_fixed])
python latebind.py
buggy   : [2, 2, 2]
pickers : ['12.0', '12.0', '12.0']
default : [0, 1, 2]
partial : [0, 1, 2]
factory : [0, 1, 2]
pickers2: ['web-01', 'healthy', '12.0']

What just happened: the first two lines are the bug — and neither raised an exception. pickers returned three copies of the last column: plausible-looking, completely wrong data. The three fixes all bind the value at creation time instead of call time. Compare pickers and pickers2 and burn the difference into memory: lambda r: r[n] vs lambda r, n=n: r[n].


Common mistakes and troubleshooting

Symptom / traceback Cause Fix
TypeError: '<' not supported between instances of 'dict' and 'dict' sorted(rows) with no key= — Python tried to compare whole dicts Say what to sort on: sorted(rows, key=itemgetter("age"))
TypeError: by_age() missing 1 required positional argument: 'record' You passed key=by_age() — the () called it immediately Drop the parentheses: key=by_age
TypeError: sorted expected 1 argument, got 2 Key passed positionally: sorted(rows, by_age) key is keyword-only: sorted(rows, key=by_age)
SyntaxError: invalid syntax pointing at return/=/if inside a lambda A statement in a lambda body — only expressions allowed Use a def, or the ternary a if cond else b
SyntaxError: 'return' outside function log = lambda m: print(m); return m — the ; ended the lambda Use a def for anything multi-part
TypeError: 'NoneType' object is not iterable after .sort() .sort() sorts in place and returns None Iterate the list itself, or use sorted(x) for a new list
AttributeError: 'NoneType' object has no attribute … Same cause: you stored rows = rows.sort() rows.sort() alone, or rows = sorted(rows)
AttributeError: 'tuple' object has no attribute 'sort' .sort() is a list method sorted(my_tuple) returns a new list
Lambdas in a loop all return the last value; no error Late binding — the closure reads the variable at call time lambda i=i: i, partial(f, i), or a factory function
TypeError: unhashable type: 'list' building a dict A mutable object used as a dict key Use a tuple key, or one entry per alias
E731 do not assign a lambda expression, use a def (ruff/flake8) f = lambda x: … — PEP 8 violation Rewrite as def f(x): …
KeyError: 'salary' from inside sorted(...) The key function raised; the field doesn’t exist on every record key=lambda r: r.get("salary", 0)
TypeError: '<' not supported between instances of 'str' and 'int' Key returns mixed types across records Coerce in the key: key=lambda r: str(r["x"])
TypeError: bad operand type for unary -: 'str' The -value descending trick on a string reverse=True, or sort twice (least-significant key first)
ValueError: max() iterable argument is empty max() on an empty list Pass default=: max(rows, key=…, default=None)
map/filter result is empty the second time They’re one-shot lazy iterators results = list(map(f, xs)) once, then reuse

Three of these cost beginners the most hours:

1. f versus f() — the parentheses mean “call it now.” This is the first-class-functions bug. by_age is the function object; by_age() runs it and evaluates to its return value. When a higher-order function asks for a function, give it the bare name. The tell is a TypeError complaining about missing arguments at the exact moment you’re passing something to key=, map, or a callback parameter — it means Python tried to run your function right there, with no arguments, instead of handing it over. Say it out loud when you type: “key=by_age — no parens, I’m not calling it.”

2. Late binding returns wrong answers, silently. Every other mistake here raises an exception. This one doesn’t. If you build functions in a loop — pickers, handlers, callbacks, one per column or per button — and they all behave like the last one, this is why. The closure captured the variable, and by call time that variable has moved on. The instant you see lambda inside a for or a comprehension that closes over the loop variable, add =i and bind it now.

3. sorted() returns; .sort() mutates. Any Python function whose job is to change something in place returns None by convention — a deliberate design choice so you can’t accidentally chain a destructive call. So rows = rows.sort() doesn’t sort rows, it destroys it: the list gets sorted in place, then the name is rebound to None, and your next line dies with a confusing NoneType error pointing somewhere else entirely. When you see NoneType right after a sort, this is always the reason.


Cheat-sheet

Syntax / call What it does
f The function object — passing it does not call it
f() Calls it now; evaluates to the return value
type(f) <class 'function'> — proof it’s an object
f.__name__ Its name ('<lambda>' for lambdas)
callable(f) True if you can put () after it
lambda x: expr Anonymous one-expression function; expr is returned
lambda x, y=2: expr Lambdas take defaults, *args, **kwargs — same as def
lambda: x Zero-argument lambda
lambda x: a if c else b Ternary is an expression — legal in a lambda
sorted(it, key=f) New sorted list; key is keyword-only
sorted(it, key=f, reverse=True) Descending
it.sort(key=f) Sorts a list in place; returns None
min(it, key=f) / max(it, key=f) Smallest / largest item by that key
max(it, key=f, default=None) Safe on an empty iterable
key=lambda r: (r["a"], -r["b"]) Multi-key: A ascending, B (numeric) descending
key=str.lower Case-insensitive string sort — no lambda needed
key=len Sort by length
itemgetter("age") lambda p: p["age"], but C-fast and clearer
itemgetter("a", "b") Returns a tuple — instant multi-key
itemgetter(1) lambda t: t[1] for tuples/lists
attrgetter("cpu") lambda o: o.cpu
attrgetter("meta.region") Nested attributes via a dotted path
methodcaller("strip") lambda s: s.strip()
map(f, it) Lazy iterator of f(item) — wrap in list()
filter(f, it) Lazy iterator of items where f(item) is truthy
filter(None, it) Drops falsy items
reduce(f, it, init) Folds to a single value (from functools import reduce)
partial(f, a) New callable with a pre-filled now
p.func / p.args / p.keywords Introspect a partial
OPS = {"+": add} Dispatch table — no () on the values
OPS[op](a, b) Look the function up and call it
OPS.get(op, default_fn) Dispatch with a fallback handler
lambda i=i: i The late-binding fix — bind the value now

The three rules that prevent most bugs:

  1. Parentheses mean call it now. Passing a function? Bare name.
  2. sorted() gives you a new list; .sort() gives you None.
  3. A lambda inside a loop that uses the loop variable is broken until you write =i.

Interview and exam questions

Q: What does “functions are first-class objects” mean in Python? A: A function is an ordinary object — an instance of <class 'function'> — so it can be bound to a name, stored in lists/dicts/sets, passed as an argument, returned from another function, and given attributes. def doesn’t declare anything special; it builds a function object and binds a name to it, exactly as x = 5 builds an int and binds x.

Q: What is a higher-order function? Name three from the standard library. A: One that takes a function as an argument and/or returns a function. Examples: sorted (takes key=), map and filter (take a function first), functools.reduce, functools.partial (returns a new function), min/max, and every decorator.

Q: What’s the difference between f and f()? A: f is the function object itself; f() calls it and evaluates to whatever it returns. When a higher-order function wants a function, pass f. Passing f() calls it immediately and hands over the result — typically raising TypeError: f() missing 1 required positional argument.

Q: What can’t go in a lambda, and why? A: Statements. A lambda body must be a single expression, so return, assignment, if:/for:/while: blocks, raise, and import are all SyntaxErrors. Expressions are fine — including function calls and the ternary a if cond else b. Anything needing a statement needs a def.

Q: PEP 8 says never assign a lambda to a name. Why not? A: Because you lose the lambda’s only advantage (being anonymous and inline) and keep all its drawbacks. Concretely, the traceback frame shows <lambda> instead of the name, so you can’t tell which function failed; you also can’t add a docstring, clean type hints, or a multi-line body. ruff/flake8 flag it as E731. def f(x): ... is strictly better.

Q: sorted(x) vs x.sort() — what’s the difference? A: sorted(x) returns a new list and leaves x alone, and works on any iterable. x.sort() sorts the list in place and returns None. The classic bug is x = x.sort(), which sets x to None. Returning None from in-place operations is a deliberate Python convention.

Q: How many times does sorted call your key function? A: Exactly once per element, before sorting — decorate–sort–undecorate. So the key costs O(n) calls, not O(n log n). Python’s sort is Timsort: O(n log n) worst case, O(n) on nearly-sorted input, and stable (equal keys keep their original relative order).

Q (coding): Sort a list of dicts by team ascending, then commits descending. A: Return a tuple from the key and negate the numeric field:

sorted(staff, key=lambda r: (r["team"], -r["commits"]))

Only works because commits is numeric — you can’t negate a string. For a string field descending, exploit stability and sort twice, least-significant key first:

rows = sorted(staff, key=itemgetter("name"), reverse=True)
rows = sorted(rows, key=itemgetter("team"))

Q: When would you use operator.itemgetter over a lambda? A: Whenever the lambda only fetches something: itemgetter("age") beats lambda p: p["age"] — it’s clearer, it’s implemented in C so it’s faster on big lists, and itemgetter("a", "b") gives multi-key sorting for free. Use a lambda when you need to compute (e.g. lambda p: p["a"] / p["b"]). Rule: operator fetches, lambda computes.

Q (coding): What does [lambda: i for i in range(3)] produce when each is called, and why? A: [2, 2, 2], not [0, 1, 2]. The closures capture the variable i, not its value, and they look it up when called — by which time the loop has finished and i is 2. This is late binding. Fix by binding at creation time: [lambda i=i: i for i in range(3)], [partial(identity, i) for i in range(3)], or a factory function returning the lambda. The danger is that it raises no exception — it just returns wrong data.

Q: What is a dispatch table and why prefer it to if/elif? A: A dict mapping a value to a function: OPS = {"+": add, "-": sub}, called with OPS[op](a, b). It’s O(1) instead of O(n), adding a case is one dict entry rather than a new branch, valid options are just sorted(OPS), handlers can be registered at runtime or aliased, and each handler is testable on its own. Prefer if/elif/match when the conditions are ranges or compound tests rather than an exact value.

Q: What does functools.partial do, and how does it differ from a lambda? A: partial(f, x) returns a new callable with x already supplied. The key difference: partial evaluates its arguments immediately, whereas lambda: f(x) looks x up when called — which is exactly why partial sidesteps late binding. partial objects are also introspectable (.func, .args) and picklable, so they survive multiprocessing; lambdas can’t be pickled.


Key takeaways

pythonfunctionslambdahigher-order-functionsfirst-class-functionssortingsortedclosurescallbacksdispatch-tablefunctoolsoperatoritemgetterpep8
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments