Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 6

Functions

Function definition, the argument system, scope, function objects themselves, and treating functions as data (functional programming).

6.1 Function Fundamentals

A function bundles a block of code under a name, so it can be run (called) as many times as needed without repeating the code. Defined with the def keyword:

def function_name(parameters):
    """optional docstring"""
    # body
    return value   # optional
Term Meaning
Parameter the name listed in the function's own definition
Argument the actual value passed in at the call site
Return value what the function call evaluates to — None if there's no return (or a bare return)
In [1]4 lines
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))   # Hello, Alice! -> the return value is what the call evaluates to
Hello, Alice!

A function with no return statement still returns something — None:

In [2]5 lines
def say_hi(name):
    print(f"Hi, {name}!")   # this PRINTS, it doesn't return anything

result = say_hi("Bob")   # Hi, Bob!
print(result)               # None -> no return statement means the call evaluates to None
Hi, Bob!
None

Multiple return values — a return statement can list several values separated by commas; Python packs them into a tuple (5.5) automatically, which the caller can then unpack (5.5) directly into separate names:

In [3]8 lines
def minmax(nums):
    return min(nums), max(nums)   # comma-separated values -> packed into a 2-tuple automatically

result = minmax([3, 1, 4, 1, 5])
print(result)   # (1, 5) -> a plain tuple, same as any other multi-value return

lo, hi = minmax([3, 1, 4, 1, 5])   # unpacked directly into two names at the call site
print(lo, hi)   # 1 5
(1, 5)
1 5

6.2 Arguments and Parameters

Python offers several ways to pass arguments into a function — by position, by name, or by collecting an arbitrary number of them. Each is covered in its own sub-section below.

6.2.1 Positional and Keyword Arguments

A positional argument is matched to a parameter by its position/order in the call. A keyword argument is matched by explicitly naming the parameter (name=value) — order no longer matters between keyword arguments. Both can be mixed in one call, but positional arguments must come first:

In [4]7 lines
def describe(name, age):
    return f"{name} is {age} years old"

print(describe("Alice", 30))              # all positional
print(describe(name="Alice", age=30))       # all keyword
print(describe(age=30, name="Alice"))       # keyword order doesn't matter
print(describe("Alice", age=30))            # mixed: positional first, then keyword -> fine
Alice is 30 years old
Alice is 30 years old
Alice is 30 years old
Alice is 30 years old

Why keyword arguments must come last — once an argument is passed by name, Python can no longer tell which "slot" a later unnamed value should fill; the language forbids this outright, as a SyntaxError caught before the code even runs (not a runtime TypeError like the other mistakes below):

In [5]6 lines
# wrapped in exec() only so this SyntaxError can be caught and printed instead of
# stopping the whole notebook — normally you'd just see this error directly
try:
    exec('describe(name="Alice", 30)')
except SyntaxError as e:
    print("SyntaxError:", e)   # positional argument follows keyword argument
SyntaxError: positional argument follows keyword argument (<string>, line 1)

What if a keyword argument names a parameter already filled positionally? — the first positional argument already claimed name; naming it again as a keyword doesn't overwrite it, it conflicts with it:

In [6]4 lines
try:
    describe("Alice", name="Bob")   # "Alice" already filled `name` positionally
except TypeError as e:
    print("TypeError:", e)   # got multiple values for argument 'name'
TypeError: describe() got multiple values for argument 'name'

Two more common mistakes: a keyword that doesn't match any parameter, and a required parameter left unfilled:

In [7]9 lines
try:
    describe("Alice", 30, city="NYC")   # `describe` has no `city` parameter at all
except TypeError as e:
    print("TypeError:", e)   # unexpected keyword argument 'city'

try:
    describe("Alice")   # `age` is required and never supplied
except TypeError as e:
    print("TypeError:", e)   # missing 1 required positional argument: 'age'
TypeError: describe() got an unexpected keyword argument 'city'
TypeError: describe() missing 1 required positional argument: 'age'

6.2.2 Variable-Length Positional Arguments (*args)

A parameter prefixed with * collects any number of extra positional arguments into a tuple (5.5) — the conventional name is args, but any name works:

In [8]7 lines
def total(*numbers):
    print(numbers, type(numbers))   # numbers is a tuple of whatever was passed
    return sum(numbers)

print(total(1, 2, 3))   # (1, 2, 3) <class 'tuple'>   then   6
print(total())            # () <class 'tuple'>          then   0  -> zero extra args is fine too
print(total(5))            # (5,) <class 'tuple'>        then   5
(1, 2, 3) <class 'tuple'>
6
() <class 'tuple'>
0
(5,) <class 'tuple'>
5

Any parameter listed after *args becomes keyword-only — *args greedily absorbs every remaining positional argument, so there's no positional "slot" left for anything that follows it; it can only be filled by name:

In [9]9 lines
def f(*args, greeting):
    print(args, greeting)

f(1, 2, 3, greeting="hi")   # (1, 2, 3) hi -> greeting MUST be passed by name

try:
    f(1, 2, 3, "hi")   # "hi" just gets swallowed into args instead
except TypeError as e:
    print("TypeError:", e)   # missing 1 required keyword-only argument: 'greeting'
(1, 2, 3) hi
TypeError: f() missing 1 required keyword-only argument: 'greeting'

6.2.3 Variable-Length Keyword Arguments (**kwargs)

*args only absorbs positional overflow — an unrecognized keyword argument still raises TypeError, same as it would without *args at all:

In [10]4 lines
try:
    total(1, 2, x=5)   # total(*numbers) has nowhere to put a keyword argument
except TypeError as e:
    print("TypeError:", e)   # unexpected keyword argument 'x'
TypeError: total() got an unexpected keyword argument 'x'

A parameter prefixed with ** collects any number of extra keyword arguments into a dict (5.4) — keyed by the argument name, valued by what was passed. The conventional name is kwargs:

In [11]9 lines
def show(**info):
    print(info, type(info))   # info is a dict of every keyword argument received
    for key, value in info.items():
        print(key, "=", value)

show(name="Alice", age=30)
# {'name': 'Alice', 'age': 30} <class 'dict'>
# name = Alice
# age = 30
{'name': 'Alice', 'age': 30} <class 'dict'>
name = Alice
age = 30

In practice — this is how Flask/Django handlers and decorators stay generic. A Flask route function, a Django view, or the wrapper inside almost any decorator accepts *args, **kwargs specifically so it can forward whatever arguments the real function/handler needs without the framework or decorator having to know that signature in advance.

All five parameter kinds can appear together in one signature, and only in this order: positional-only, positional-or-keyword, *args, keyword-only, **kwargs:

Kind Syntax Meaning
Positional-only before a / in the signature can only be passed positionally — never by keyword
Positional-or-keyword the default, no / or * involved can be passed either way (6.2.1)
*args *name collects extra positional arguments (6.2.2)
Keyword-only after *args (or a bare *) can only be passed by keyword (6.2.2)
**kwargs **name collects extra keyword arguments (6.2.3)

A / in the parameter list marks everything before it as positional-only — mirroring how */*args marks everything after it as keyword-only. It's rarer to write by hand, but it shows up throughout the standard library (e.g. len(obj, /)) to lock down an argument as an implementation detail, not part of the public API:

In [12]5 lines
def combo(a, b, /, *args, c, d=10, **kwargs):   # `/` makes a and b positional-only
    print(a, b, args, c, d, kwargs)

combo(1, 2, 3, 4, c=5, e=6, f=7)   # 1 2 (3, 4) 5 10 {'e': 6, 'f': 7}
combo(1, 2, c=5)                      # 1 2 () 5 10 {}  -> *args and **kwargs can both be empty
1 2 (3, 4) 5 10 {'e': 6, 'f': 7}
1 2 () 5 10 {}
In [13]4 lines
try:
    combo(a=1, b=2, c=3)   # a and b are positional-only -> naming them is not allowed
except TypeError as e:
    print("TypeError:", e)   # missing 2 required positional arguments: 'a' and 'b'
TypeError: combo() missing 2 required positional arguments: 'a' and 'b'

Unpacking at the call site — */** aren't only for collecting overflow arguments inside a def (6.2.2/6.2.3); the same symbols also run in reverse, spreading an existing iterable/mapping out into separate arguments at the call site. This works on any call, not just ones defined with *args/**kwargs, and ** even works outside a call entirely, to merge dicts (5.4) into a new one:

In [14]11 lines
nums = [1, 2, 3]
print(*nums)   # 1 2 3 -> the list is unpacked into 3 separate positional arguments

def add3(a, b, c):
    return a + b + c

print(add3(*nums))   # 6 -> same unpacking, now feeding a real function's parameters

d1 = {"a": 1}
d2 = {"b": 2}
print({**d1, **d2})   # {'a': 1, 'b': 2} -> ** unpacks a dict's key/value pairs into a new one
1 2 3
6
{'a': 1, 'b': 2}

6.2.4 Default Values, Aliasing, and the Mutable-Default Gotcha

a. Default parameter values — a parameter can be given a default with param=value in the def line. If the caller doesn't supply that argument, the default is used instead; if they do, the default is simply ignored. This has already been used earlier in this chapter (in the "combo" example above, and again in 6.8.1's lambdas) without being introduced on its own — here it is directly. A parameter with a default must come after every parameter without one (among positional-or-keyword parameters), the same ordering rule that keeps d=10 after c in the combo signature above:

In [15]5 lines
def make_greeting(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(make_greeting("Alice"))          # Hello, Alice! -> greeting uses its default
print(make_greeting("Bob", "Hi"))        # Hi, Bob! -> explicit value overrides the default
Hello, Alice!
Hi, Bob!

b. Argument-passing / aliasing semantics — Python passes a reference to the same object into a function, not a copy of it (2.4 covers mutability itself). What happens next splits into two very different cases:

  • calling a mutating method on the parameter (lst.append(...)) changes the object itself — and since the caller's variable and the parameter both refer to that same object, the caller sees the change too.
  • reassigning the parameter name (lst = [...]) doesn't touch the object at all — it just points the local name at a different object. The caller's variable still refers to the original, untouched one.

In short: mutating in place is visible outside the function; reassigning the parameter name is not.

In [16]6 lines
def append_item(lst, item):
    lst.append(item)   # mutates the SAME list object the caller passed in, in place

nums = [1, 2, 3]
append_item(nums, 4)
print(nums)   # [1, 2, 3, 4] -> the caller's own list changed, since both names pointed to it
[1, 2, 3, 4]
In [17]6 lines
def replace_list(lst):
    lst = [99, 100]   # this REBINDS the local name `lst` to point at a brand-new list

nums = [1, 2, 3]
replace_list(nums)
print(nums)   # [1, 2, 3] -> unchanged; only the local name moved, the caller's object never did
[1, 2, 3]

Common mistake — the mutable-default-argument trap. A default value (6.2.4.a) is evaluated exactly once, at def time, not fresh on every call — for an immutable default (a number, a string, None) that difference is invisible, but for a mutable default like [] it means every call that doesn't supply its own items shares that same list object, and mutations to it pile up across calls:

In [18]6 lines
def add_item(x, items=[]):   # DANGER: the [] default is created ONCE, when def runs
    items.append(x)
    return items

print(add_item(1))   # [1]
print(add_item(2))   # [1, 2] -> the SAME list from the first call, not a fresh one!
[1]
[1, 2]
In [19]7 lines
def add_item_fixed(x, items=None):
    items = items if items is not None else []   # a fresh list, built fresh on EVERY call
    items.append(x)
    return items

print(add_item_fixed(1))   # [1]
print(add_item_fixed(2))   # [2] -> NOT [1, 2] this time; no state carried over between calls
[1]
[2]

In practice — a genuinely common real bug. A Django/Flask view function or a data-processing helper with a mutable default argument that's supposed to start empty "per call" but silently accumulates state across requests is a real, recurring bug category in production code — often not noticed until unrelated requests start mysteriously seeing each other's data.

6.5 Recursion

A recursive function is one that calls itself, breaking a problem down into a smaller version of the same problem. Every recursive function needs two parts:

  • a base case — the condition where it stops calling itself and returns directly
  • a recursive case — where it calls itself with a smaller/simpler input, moving toward the base case

Without a reachable base case, the calls never stop (see the gotcha below).

In [20]6 lines
def factorial(n):
    if n == 0:          # base case: 0! is defined as 1, no further calls needed
        return 1
    return n * factorial(n - 1)   # recursive case: n! = n * (n-1)!

print(factorial(5))   # 120 -> 5 * 4 * 3 * 2 * 1
120

Each call waits for the one it made to finish before it can return — visible if the calls print on their way down:

In [21]12 lines
def countdown(n):
    if n <= 0:            # base case
        print("Liftoff!")
        return
    print(n)
    countdown(n - 1)        # recursive case

countdown(3)
# 3
# 2
# 1
# Liftoff!
3
2
1
Liftoff!

A function can make more than one recursive call per invocation — the Fibonacci sequence is the classic example (each term is the sum of the two before it):

In [22]6 lines
def fibonacci(n):
    if n <= 1:           # base case: fib(0) = 0, fib(1) = 1
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)   # two recursive calls per invocation

print([fibonacci(i) for i in range(8)])   # [0, 1, 1, 2, 3, 5, 8, 13]
[0, 1, 1, 2, 3, 5, 8, 13]

In practice — walking a nested structure. Traversing a filesystem directory tree, recursively resolving a nested JSON config that references other config blocks, or walking a product-category hierarchy in an e-commerce catalog are all naturally recursive problems — each level is "the same problem, on a smaller piece," exactly the shape this section's base-case/recursive-case pattern is built for.

Common mistake — a base case that's missing, unreachable, or never gets closer (e.g. calling with the same value instead of a smaller one) means the calls never stop. Python defends against this with a call-stack limit rather than hanging forever:

In [23]10 lines
def bad_recursion(n):
    return bad_recursion(n)   # calls itself with the SAME n -> never reaches a base case

try:
    bad_recursion(1)
except RecursionError as e:
    print("RecursionError:", e)   # maximum recursion depth exceeded

import sys
print(sys.getrecursionlimit())   # 1000 -> the default call-depth ceiling that was hit above
RecursionError: maximum recursion depth exceeded
1000

Going deeper — Python has no tail-call optimization. In some languages, a tail-recursive call (one that's the very last thing a function does, with nothing left to compute after it returns — countdown's call to itself above is an example) gets rewritten into a plain loop, reusing the same stack frame instead of growing the stack. CPython deliberately does no such rewriting: every recursive call, tail-position or not, keeps its own full stack frame alive until it actually returns, which is exactly why bad_recursion above hit sys.getrecursionlimit() rather than looping forever in constant memory. For processing large or unbounded sequences, 14's generators are the idiomatic lazy alternative — they produce one value at a time without needing a deep call stack at all.

6.6 Advanced Function Concepts

6.6.1 Type Hinting

Python is dynamically typed (1.1.2) — type hints (also called annotations) don't change that. They're optional, not enforced at runtime: hints attached to parameters and the return value that document what type is expected/produced, checked by external tools (a type checker like mypy) or an IDE, not by the interpreter itself.

def function_name(param: Type) -> ReturnType:
    ...
Position Hint
Parameter param: Type, right after the parameter name
Return value -> Type, after the parameter list, before the colon
In [24]5 lines
def greet(name: str) -> str:
    return f"Hello, {name}!"

print(greet("Alice"))   # Hello, Alice!
print(greet(42))          # Hello, 42! -> hints are NOT enforced; an int still runs fine
Hello, Alice!
Hello, 42!

Why use them anyway — hints make a function's contract explicit, for both humans and tools: an IDE can flag greet(42) as suspicious, offer correct autocomplete, and a type checker can catch mismatches before the code ever runs — all without adding a single runtime check. Python stores the hints on the function object itself, inspectable via __annotations__:

In [25]1 line
print(greet.__annotations__)   # {'name': <class 'str'>, 'return': <class 'str'>}
{'name': <class 'str'>, 'return': <class 'str'>}

Beyond the basic types (2.2), hints can describe containers and "this or None":

Hint Meaning
list[int] a list of ints (5.1)
dict[str, int] a dict (5.4) mapping str keys to int values
X \| None, or Optional[X] from the typing module either an X, or None
In [26]13 lines
from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    users = {1: "Alice", 2: "Bob"}
    return users.get(user_id)

print(find_user(1))    # Alice
print(find_user(99))    # None -> matches the "or None" part of Optional[str]

def process(items: list[int]) -> dict[str, int]:
    return {"count": len(items), "total": sum(items)}

print(process([1, 2, 3]))   # {'count': 3, 'total': 6}
Alice
None
{'count': 3, 'total': 6}

In practice — FastAPI reads these hints at runtime, not just for documentation. Most frameworks treat type hints as documentation only (exactly as described above), but FastAPI is a notable exception: it inspects a route function's parameter hints directly to validate and parse incoming request data (backed by Pydantic, chapter 20) — making the hints part of the actual runtime behavior, not just an IDE/mypy aid.

Going deeper — generic type syntax. A function or class that works with any type but should return/hold that same type consistently (e.g. first(items) below returning the same type its list contains) can declare a type parameter directly in its signature with square brackets — no separate from typing import TypeVar needed:

In [27]6 lines
def first[T](items: list[T]) -> T:   # T is a type parameter, scoped to this function
    return items[0]

print(first([1, 2, 3]))        # 1 -> T is inferred as int here
print(first(["a", "b"]))         # a -> and as str here
print(first.__type_params__)   # (T,) -> introspectable, like __annotations__ above
1
a
(T,)

The same bracket syntax works on a class, and a type statement creates a named alias for a type expression (the modern replacement for typing.TypeAlias/plain assignment) — both forms are lazily evaluated, so T and a type alias can even reference names defined later in the file:

In [28]17 lines
class Box[T]:
    def __init__(self, item: T) -> None:
        self.item = item

    def get(self) -> T:
        return self.item

b = Box(42)
print(b.get())   # 42

type IntOrStr = int | str   # a named alias, not a variable holding a value

def show(value: IntOrStr) -> None:
    print(value)

show(5)     # 5
show("hi")   # hi
42
5
hi

6.8 Functional Programming Concepts

Scope: Treating functions as data, and the constructs Python provides for that style — merged in here since it's fundamentally still about functions, just a different way of using them.

6.8.1 Lambda and Anonymous Functions

A lambda creates a small, unnamed ("anonymous") function inline, restricted to a single expression — no statements, no multiple lines of logic:

lambda parameters: expression
Part Meaning
lambda the keyword that starts an anonymous function — plays the same role def name(...): does
parameters a comma-separated parameter list — positional, defaults, *args, **kwargs (6.2) are all allowed, exactly like a normal function
: separates the parameter list from the body
expression a single expression, implicitly returned — no return keyword, and none is needed

A lambda is exactly equivalent to a def function with one expression in its body:

In [29]6 lines
square = lambda x: x ** 2

def square_def(x):
    return x ** 2

print(square(5), square_def(5))   # 25 25 -> identical behavior, just two ways to write it
25 25

Every parameter form from 6.2 works the same way here — multiple parameters, defaults, and so on:

In [30]6 lines
add = lambda a, b: a + b
print(add(3, 4))   # 7

greet = lambda name, greeting="Hello": f"{greeting}, {name}!"
print(greet("Alice"))        # Hello, Alice! -> uses the default
print(greet("Bob", "Hi"))     # Hi, Bob!
7
Hello, Alice!
Hi, Bob!

A lambda's real niche is being passed inline, right where a function is expected, without ever needing a name of its own — sorted()'s key argument is the classic example:

In [31]2 lines
pairs = [(1, "b"), (2, "a"), (3, "c")]
print(sorted(pairs, key=lambda pair: pair[1]))   # [(2, 'a'), (1, 'b'), (3, 'c')] -> sorted by the 2nd item
[(2, 'a'), (1, 'b'), (3, 'c')]

A ternary (3.1.7) is still a single expression, so it fits inside a lambda body too — that's how a lambda branches:

In [32]2 lines
label = lambda n: "even" if n % 2 == 0 else "odd"
print(label(4), label(7))   # even odd
even odd

Common mistake — a lambda body must be a single expression, not a statement. An if statement (not the ternary expression above), an assignment, a for loop, etc. are all illegal inside one:

In [33]6 lines
# wrapped in exec() only so this SyntaxError can be caught and printed instead of
# stopping the whole notebook — normally you'd just see this error directly
try:
    exec("f = lambda x: if x > 0: 1")   # an if STATEMENT can't live inside an expression
except SyntaxError as e:
    print("SyntaxError:", e)
SyntaxError: invalid syntax (<string>, line 1)

When to actually use one — reach for a lambda only for a short, throwaway function passed directly as an argument, like the sorted() example above; for anything reused or non-trivial, a named def function is clearer. That "pass a small function straight in" role is exactly what map(), filter(), and reduce() are built around, next.

*args/**kwargs (6.2.2/6.2.3) work in a lambda's parameter list too, same as any other function:

In [34]2 lines
summarize = lambda *args, **kwargs: (args, kwargs)
print(summarize(1, 2, x=3))   # ((1, 2), {'x': 3})
((1, 2), {'x': 3})

Common mistake — a lambda created inside a loop doesn't "remember" the loop variable's value at creation time; it looks the name up in the enclosing scope when called, and by then the loop has already finished. Every lambda below ends up seeing the loop's final value of i, not the value it had when each lambda was made:

In [35]5 lines
funcs = []
for i in range(3):
    funcs.append(lambda: i)   # each lambda just refers to the NAME i, not its value at this point

print([f() for f in funcs])   # [2, 2, 2] -> not [0, 1, 2] like you might expect
[2, 2, 2]

The fix is to force the current value in as a default argument — defaults are evaluated once, at the moment the lambda is defined, not when it's called:

In [36]5 lines
funcs2 = []
for i in range(3):
    funcs2.append(lambda i=i: i)   # default i=i captures the CURRENT value right now

print([f() for f in funcs2])   # [0, 1, 2] -> each one keeps its own snapshot
[0, 1, 2]

sorted() isn't the only built-in with a key argument — max()/min() (5.1.2) take one too, and it's the same pattern: a lambda picking what to compare by, without changing what gets returned:

In [37]3 lines
words = ["apple", "kiwi", "banana", "fig"]
print(max(words, key=lambda w: len(w)))   # banana -> longest word (6 letters)
print(min(words, key=lambda w: len(w)))     # fig -> shortest word (3 letters)
banana
fig

6.8.2 Higher-Order Functions and Built-ins

A higher-order function is one that takes another function as an argument (or returns one). map(), filter(), and reduce() are the classic three, each applying a function across an iterable instead of writing an explicit loop:

Function Signature Returns
map(func, iterable, ...) applies func to every item a lazy iterator (same O(1)-to-create idea as reversed()/enumerate(), 5.1.2)
filter(func, iterable) keeps only the items where func(item) is truthy a lazy iterator
reduce(func, iterable, initial=...) combines every item into one value, applying func cumulatively a single value — from functools, not a built-in
map()
In [38]5 lines
nums = [1, 2, 3, 4, 5]

squares = map(lambda x: x ** 2, nums)
print(squares)          # <map object at 0x...> -> lazy, same idea as reversed()/enumerate() (5.1.2)
print(list(squares))     # [1, 4, 9, 16, 25]
<map object at 0x7fb95e068c70>
[1, 4, 9, 16, 25]

map() accepts multiple iterables — func is then called with one item from each, pairwise, stopping at the shortest. It also works with any existing function, not just a lambda:

In [39]5 lines
names = ["alice", "bob"]
ages = [30, 25]
print(list(map(lambda n, a: f"{n}:{a}", names, ages)))   # ['alice:30', 'bob:25'] -> paired up positionally

print(list(map(str.upper, ["a", "b", "c"])))                # ['A', 'B', 'C'] -> an existing method, no lambda needed
['alice:30', 'bob:25']
['A', 'B', 'C']
filter()

Keeps only the items for which the function returns something truthy (4.1); everything else is dropped:

In [40]2 lines
evens = filter(lambda x: x % 2 == 0, nums)
print(list(evens))   # [2, 4] -> only the items where x % 2 == 0 was truthy
[2, 4]

Passing None instead of a function is a shorthand for "keep whatever's already truthy" — filter(None, iterable) drops every falsy value (4.1) with no function at all:

In [41]1 line
print(list(filter(None, [0, 1, "", "hi", None, [], [1]])))   # [1, 'hi', [1]] -> only the truthy items survive
[1, 'hi', [1]]
reduce()

Unlike map()/filter(), reduce() lives in the functools module, not the built-ins. It applies func(accumulator, item) left to right, carrying the running result forward, until one final value is left:

In [42]7 lines
from functools import reduce

total = reduce(lambda acc, x: acc + x, nums)
print(total)   # 15 -> ((((1+2)+3)+4)+5), the accumulator carries forward each step

total_with_start = reduce(lambda acc, x: acc + x, nums, 100)
print(total_with_start)   # 115 -> starts accumulating from 100 instead of from the first item
15
115

reduce() generalizes to any combining operation, not just addition — e.g. a running product, which has no dedicated built-in the way sum() does (5.1.2):

In [43]2 lines
product = reduce(lambda acc, x: acc * x, [1, 2, 3, 4])
print(product)   # 24 -> 1 * 2 * 3 * 4
24

A note on style — a list comprehension (5.1.3) is usually considered more readable than map()/filter() for simple cases ([x ** 2 for x in nums] vs. list(map(lambda x: x ** 2, nums))), but there's no comprehension equivalent for reduce() — that's part of why it's kept separate in functools rather than being a built-in.

Common mistake — map()/filter() return a one-shot iterator (5.1.2's "O(1) to create, O(n) to consume" idea) — once it's been fully consumed (e.g. by list()), it's empty for good. There's no way to "rewind" it; a fresh call is needed to go again:

In [44]3 lines
m = map(lambda x: x * 2, [1, 2, 3])
print(list(m))   # [2, 4, 6] -> first pass consumes it
print(list(m))   # [] -> second pass, nothing left to give
[2, 4, 6]
[]

The standard library's operator module gives named functions for the built-in operators (3.1) — often a cleaner substitute for a one-line lambda inside reduce():

In [45]4 lines
import operator

print(reduce(operator.add, [1, 2, 3, 4]))   # 10 -> same as lambda acc, x: acc + x
print(reduce(operator.mul, [1, 2, 3, 4]))     # 24 -> same as lambda acc, x: acc * x
10
24

map()/filter()/reduce() are just the standard-library's higher-order functions — nothing stops a plain def from being one too, since a function is just another object that can be passed around (6.1). It only needs to accept another function as an argument:

In [46]5 lines
def apply_twice(func, value):
    return func(func(value))

print(apply_twice(lambda x: x * 2, 3))   # 12 -> 3 -> 6 -> 12
print(apply_twice(str.upper, "ab"))         # AB -> upper() twice has no extra effect, but the plumbing still works
12
AB

6.8.3 Nested Functions and Closures

Before nested functions make sense, a quick primer on scope — the region of code where a name is visible. Every time a function runs, it gets its own fresh local scope: names created inside it exist only for that one call, and disappear the moment the function returns.

In [47]10 lines
def outer_plain():
    local_var = "I only exist inside outer_plain"
    print(local_var)

outer_plain()   # I only exist inside outer_plain

try:
    print(local_var)   # local_var was never visible out here to begin with
except NameError as e:
    print("NameError:", e)
I only exist inside outer_plain
NameError: name 'local_var' is not defined

A nested function is simply a function defined inside another function's body. Because it's still "inside" the outer function while it runs, it can see the outer function's local variables too — the outer function's local scope is called the nested function's enclosing scope:

In [48]7 lines
def outer():
    message = "hello"
    def inner():
        print(message)   # reads `message` from the ENCLOSING scope, not its own
    inner()

outer()   # hello
hello

That ability to reach into the enclosing scope is what makes a nested function a closure — but only once it's handed out: returned from the outer function (or stored somewhere) so it can still be called later, after the outer function has already finished running. Picture it like the nested function packing a small backpack with everything it needs from home before it leaves — it keeps carrying that backpack around, no matter where it ends up being called from next.

In [49]11 lines
def make_multiplier(factor):
    def multiply(x):
        return x * factor   # `factor` is packed into multiply's "backpack"
    return multiply           # handing the nested function out, instead of calling it

double = make_multiplier(2)   # make_multiplier already returned; its local scope is "gone"
triple = make_multiplier(3)     # yet double and triple still each remember their own factor

print(double(5))   # 10 -> 5 * 2
print(triple(5))     # 15 -> 5 * 3
print(double(10))   # 20 -> double still remembers factor=2, even now
10
15
20

Why this is useful — make_multiplier is a function factory: a function whose whole job is to build and return other, customized functions. It's a lightweight alternative to writing a small class (10 covers classes) just to remember one configuration value; make_multiplier(2) plays roughly the same role as constructing an object that stores factor = 2 and exposes a multiply method, but without any class machinery — just a function and a closure.

So far the closure only ever read the captured variable. A closure can also update it between calls, so it keeps changing state over time instead of always giving the same answer — that's when nonlocal becomes necessary:

In [50]14 lines
def make_counter():
    count = 0
    def increment():
        nonlocal count   # without this, the next line would create a new LOCAL `count`
        count += 1
        return count
    return increment

counter1 = make_counter()
print(counter1())   # 1
print(counter1())   # 2 -> counter1 remembers its own `count` between calls

counter2 = make_counter()
print(counter2())   # 1 -> a fresh, independent `count` — not shared with counter1
1
2
1

Why nonlocal is required — by default, assigning to a name inside a function (count += 1 is really count = count + 1) makes Python treat that name as local to that function, shadowing the enclosing one entirely. nonlocal tells Python "no, use the enclosing scope's variable, don't create a new local one." Without it:

In [51]12 lines
def make_counter_broken():
    count = 0
    def increment():
        count += 1   # Python sees this assignment and treats `count` as local from here on
        return count
    return increment

broken = make_counter_broken()
try:
    broken()
except UnboundLocalError as e:
    print("UnboundLocalError:", e)   # count is "local" but read before being assigned
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

global is not a substitute for nonlocal — global always refers to the module-level scope specifically, regardless of how many functions are nested in between. nonlocal instead refers to the nearest enclosing function's scope — here, count lives inside make_counter_global, not at module level, so telling Python global count sends it looking in the wrong place entirely:

In [52]13 lines
def make_counter_global():
    count = 0
    def increment():
        global count   # this looks for `count` at MODULE level, not in make_counter_global
        count += 1
        return count
    return increment

broken_global = make_counter_global()
try:
    print(broken_global())
except NameError as e:
    print("NameError:", e)   # name 'count' is not defined -> no module-level `count` exists at all
NameError: name 'count' is not defined

This is purely about rebinding a name, not about mutability (2.4) — mutating a mutable object in place (5.1.2's append(), for example) doesn't reassign the name at all, so nonlocal isn't needed there:

In [53]10 lines
def make_accumulator():
    items = []
    def add(x):
        items.append(x)   # mutates the SAME list in place -> no `nonlocal` needed
        return items
    return add

acc = make_accumulator()
print(acc(1))   # [1]
print(acc(2))     # [1, 2] -> `items` persisted between calls, same as `count` did above
[1]
[1, 2]

The captured variables aren't magic — they're stored in cell objects Python attaches to the function, inspectable via __closure__:

In [54]1 line
print(counter1.__closure__[0].cell_contents)   # 2 -> counter1's private `count`, still alive
2

6.8.4 Decorators

The problem decorators solve — imagine wanting to log every call to several functions: which one ran, and what it returned. The obvious first move is to add a print() inside each function:

In [55]14 lines
def add(a, b):
    print("Calling add")
    result = a + b
    print("Result:", result)
    return result

def subtract(a, b):
    print("Calling subtract")          # the same three lines, copy-pasted again
    result = a - b
    print("Result:", result)
    return result

print(add(2, 3))         # Calling add / Result: 5 / 5
print(subtract(5, 2))      # Calling subtract / Result: 3 / 3
Calling add
Result: 5
5
Calling subtract
Result: 3
3

That logging logic is identical every time, just copy-pasted — with 2 functions it's mildly annoying; with 50 it's a maintenance problem (change the log format once, edit it in 50 places). A decorator solves this: write the "before/after" logic once, as a closure (6.8.3) that wraps any function passed to it, and reuse it everywhere:

In [56]13 lines
def log_calls(func):                      # takes the ORIGINAL, un-modified function
    def wrapper(*args, **kwargs):           # this closure does the "before/after" work
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)        # calls the original, untouched function
        print("Result:", result)
        return result
    return wrapper                            # hand back the wrapped version

def add_clean(a, b):    # this function itself stays completely free of logging code
    return a + b

add_clean = log_calls(add_clean)   # wrap it once, here — not inside its own body
print(add_clean(2, 3))   # Calling add_clean / Result: 5 / 5
Calling add_clean
Result: 5
5

log_calls is a decorator — formally, a function that takes a function and returns a (usually enhanced) function. Python gives this exact pattern its own syntax: @decorator placed directly above a def is shorthand for the reassignment done manually above. Another example, this time transforming the return value instead of just observing it:

In [57]11 lines
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)   # calls the ORIGINAL function
        return result.upper()
    return wrapper

def greet(name):
    return f"hello, {name}"

greet = shout(greet)   # manual decoration -> exactly what `@shout` does automatically
print(greet("Alice"))   # HELLO, ALICE
HELLO, ALICE

The @ syntax does the same reassignment, just placed above the definition instead of after it:

In [58]5 lines
@shout
def greet2(name):
    return f"hello, {name}"

print(greet2("Bob"))   # HELLO, BOB
HELLO, BOB

Common mistake — since greet2 is now really wrapper, it loses the original function's identity: its __name__, __doc__, and other metadata (6.6.1 covers __annotations__, the same idea) all point at wrapper, not at the real function:

In [59]7 lines
@shout
def greet3(name):
    """Return a greeting."""
    return f"hi, {name}"

print(greet3.__name__)   # wrapper -> NOT 'greet3'
print(greet3.__doc__)      # None -> the docstring is gone too
wrapper
None

The fix — and the standard convention for every decorator — is functools.wraps, itself a decorator that copies the original function's metadata onto wrapper:

In [60]15 lines
import functools

def shout_fixed(func):
    @functools.wraps(func)   # copies __name__, __doc__, etc. from func onto wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@shout_fixed
def greet4(name):
    """Return a greeting."""
    return f"hi, {name}"

print(greet4.__name__)   # greet4 -> identity preserved
print(greet4.__doc__)      # Return a greeting.
greet4
Return a greeting.

A practical one: timing how long any function takes. *args/**kwargs (6.2.2/6.2.3) in wrapper are what let one decorator work on a function with any signature at all:

In [61]18 lines
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_add(a, b):
    time.sleep(0.01)
    return a + b

print(slow_add(2, 3))   # slow_add took 0.0XXXs (varies each run) -> 5
slow_add took 0.0101s
5

In practice — decorators are one of the highest-payoff patterns in real Python code. Flask's @app.route("/orders"), Django's @login_required, and a @retry decorator from the tenacity library (production-grade retry-with-backoff logic) are all exactly this shape — before/after logic written once and reused across many functions. A timing decorator like the one above is itself a common piece of real observability tooling, wrapping request handlers to report latency automatically.

Parameterized decorators — @timer takes no arguments of its own. To accept one (@repeat(3)), add one more level of nesting: the outermost function takes the decorator's own argument and returns the actual decorator:

In [62]19 lines
def repeat(n):                        # 1. takes the decorator's own argument
    def decorator(func):                # 2. the actual decorator, takes the function
        @functools.wraps(func)
        def wrapper(*args, **kwargs):     # 3. the usual wrapper
            result = None
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hi():
    print("hi")

say_hi()
# hi
# hi
# hi
hi
hi
hi

Tracing exactly what @repeat(3) does, step by step:

  1. repeat(3) runs first, immediately, before say_hi is even fully defined — it returns decorator, with n=3 captured in its closure.
  2. Python then calls decorator(say_hi) — passing the just-defined say_hi in — which returns wrapper, with func=say_hi captured in its closure.
  3. The name say_hi is reassigned to point at that wrapper — exactly like the manual add_clean = log_calls(add_clean) reassignment earlier in this section. The original say_hi function object still exists; it's just reachable only through wrapper's closure now, not directly by name.
  4. Calling say_hi() therefore really calls wrapper(), which loops n=3 times, calling the original function (func) on each pass.

Stacking decorators — multiple @ lines apply bottom-up: the one closest to the def wraps the original function first, then each one above wraps the result of the one below it:

In [63]18 lines
def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"**{func(*args, **kwargs)}**"
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return f"_{func(*args, **kwargs)}_"
    return wrapper

@bold
@italic
def phrase():
    return "hi"

print(phrase())   # **_hi_** -> italic (closest to def) applies first, bold wraps its result
**_hi_**
In [64]6 lines
@italic
@bold
def phrase2():
    return "hi"

print(phrase2())   # _**hi**_ -> swapping the order changes the result
_**hi**_

Decorators with their own state — a decorator's wrapper is a closure, so it can use nonlocal (6.8.3) to keep count across calls, the same way make_counter() did:

In [65]17 lines
def count_calls(func):
    count = 0                          # lives in count_calls's scope, shared by every call
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"{func.__name__} has been called {count} time(s)")
        return func(*args, **kwargs)
    return wrapper

@count_calls
def say_hello():
    print("hello")

say_hello()   # say_hello has been called 1 time(s) / hello
say_hello()   # say_hello has been called 2 time(s) / hello
say_hello()   # say_hello has been called 3 time(s) / hello
say_hello has been called 1 time(s)
hello
say_hello has been called 2 time(s)
hello
say_hello has been called 3 time(s)
hello

A decorator you don't have to write yourself — functools.lru_cache remembers a function's past results and returns the cached answer instead of recomputing, for any function whose arguments it's already seen. It's a perfect fit for 6.5's naive recursive fibonacci(), which recalculates the same values over and over:

In [66]20 lines
def fibonacci_slow(n):
    if n <= 1:
        return n
    return fibonacci_slow(n - 1) + fibonacci_slow(n - 2)

@functools.lru_cache(maxsize=None)   # maxsize=None -> cache never evicts old results
def fibonacci_fast(n):
    if n <= 1:
        return n
    return fibonacci_fast(n - 1) + fibonacci_fast(n - 2)   # repeat calls hit the cache

start = time.perf_counter()
print(fibonacci_slow(28))                             # 317811
print(f"slow: {time.perf_counter() - start:.4f}s")      # noticeably slow

start = time.perf_counter()
print(fibonacci_fast(28))                             # 317811 -> same answer
print(f"fast: {time.perf_counter() - start:.4f}s")      # effectively instant

print(fibonacci_fast.cache_info())   # shows how many calls were served from cache
317811
slow: 0.0396s
317811
fast: 0.0000s
CacheInfo(hits=26, misses=29, maxsize=None, currsize=29)

In practice — a real, zero-effort caching layer. Dropping @lru_cache onto a function that does an expensive computation or looks something up repeatedly with the same arguments (a config parser, a currency-conversion lookup, a pure computation called often with the same inputs) is one of the most common real uses of a decorator in production Python code, precisely because it needs no extra infrastructure — just one line.

Common mistake — lru_cache needs to use its arguments as a dict key internally, so every argument must be hashable (2.4). Passing something unhashable, like a list, fails outright instead of silently skipping the cache:

In [67]8 lines
@functools.lru_cache
def identity(x):
    return x

try:
    identity([1, 2, 3])   # a list can't be hashed -> can't be used as a cache key
except TypeError as e:
    print("TypeError:", e)   # unhashable type: 'list'
TypeError: unhashable type: 'list'

Going deeper — maxsize=None above means the cache is genuinely unbounded: it never evicts anything, so a function called with many distinct arguments over a long enough time is a real memory-leak vector, not just a theoretical one. Caching an instance method has a similar trap — self is part of the cache key, so every cached call keeps that self (and everything it references) alive for as long as the cache holds the entry, potentially far longer than the object would otherwise have lived.

Decorators aren't limited to plain functions — 10.3's @classmethod/@staticmethod and 10.6's @property are this exact same mechanism (a callable that wraps another callable, applied with @) applied specifically to methods inside a class body.