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) |
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:
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:
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:
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):
# 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:
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:
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:
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:
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:
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:
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 = 30In 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:
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 {}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:
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:
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.
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]
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:
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]
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).
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:
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):
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:
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 |
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__:
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":
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:
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:
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:
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:
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:
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:
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:
# 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:
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:
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:
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:
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()
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:
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:
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:
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:
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):
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:
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():
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:
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.
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:
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.
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:
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:
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:
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:
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__:
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:
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:
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:
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:
@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:
@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:
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:
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:
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:
repeat(3)runs first, immediately, beforesay_hiis even fully defined — it returnsdecorator, withn=3captured in its closure.- Python then calls
decorator(say_hi)— passing the just-definedsay_hiin — which returnswrapper, withfunc=say_hicaptured in its closure. - The name
say_hiis reassigned to point at thatwrapper— exactly like the manualadd_clean = log_calls(add_clean)reassignment earlier in this section. The originalsay_hifunction object still exists; it's just reachable only throughwrapper's closure now, not directly by name. - Calling
say_hi()therefore really callswrapper(), which loopsn=3times, 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:
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_**
@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:
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:
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:
@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.