Functions — argument patterns
Every tool for writing function signatures expressively — defaults, *args/**kwargs, positional-only (/), and keyword-only (*).
If Chapter 4 Collections and comprehensions covered the tools for handling data, this chapter is how to express the signatures of functions that receive that data. Python functions have rich argument notation. It’s a strength, but also a part that can confuse you at first. This chapter collects those patterns in one place.
The argument notation in this chapter shows up again in Part 2’s Chapter 12 Decorator patterns and Part 3’s Chapter 20 Typing in depth — Variance, ParamSpec, Self, overload. Advanced typing tools like ParamSpec end up mapping the *args / **kwargs shape of this chapter directly into the type system, so pinning down the notation patterns by the end of this chapter helps later.
The simplest function #
def greet(name: str) -> None:
print(f"hi, {name}!")
greet("curtis") # hi, curtis!def keyword, indent after the colon. Writing the type hints (: str, -> None) seen in Chapter 2 on every function is the modern Python principle, and this book follows it.
return and multiple returns
#
def split_name(full: str) -> tuple[str, str]:
first, last = full.split(" ", 1)
return first, last
first, last = split_name("curtis kim")
print(first, last) # curtis kimreturn a, b is really return (a, b). The receiving side unpacks it. Similar benefit to JavaScript’s object destructuring, via tuples.
If there’s no return, the function returns None. Writing it explicitly or not gives the same result.
Defaults #
def greet(name: str, greeting: str = "hi") -> None:
print(f"{greeting}, {name}!")
greet("curtis") # hi, curtis!
greet("curtis", "hello") # hello, curtis!A parameter with a default must come after parameters without defaults.
def bad(x: int = 0, y: int) -> int: ...
# SyntaxError: non-default argument follows default argumentPitfall — mutable default #
This is Python’s most famous pitfall.
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← ?!
print(append_to(3)) # [1, 2, 3]The default [] is created once when the function is defined, and every call shares the same object. A new empty list is not made each call.
Fix: set the default to None and create a new one inside the function.
def append_to(item, target: list[int] | None = None):
if target is None:
target = []
target.append(item)
return targetThis pattern shows up a lot. Worth memorizing.
Positional vs. keyword calls #
There are two ways to pass arguments when calling.
def create_user(name: str, age: int, role: str = "member") -> dict:
return {"name": name, "age": age, "role": role}
# positional — by position
create_user("curtis", 30)
create_user("curtis", 30, "admin")
# keyword — by name
create_user(name="curtis", age=30)
create_user(age=30, name="curtis") # order can be swapped
# mixed (positional first)
create_user("curtis", age=30, role="admin")To make the intent visible from the call site alone, keyword calls are powerful. A signature like create_user("curtis", 30, True, False, "admin") is hard to read, but create_user(name="curtis", age=30, is_active=True, is_admin=False, role="admin") reads at a glance.
*args — variable positional arguments
#
Prefixing a parameter with * means “receive all remaining positional arguments as a tuple”.
def add(*nums: int) -> int:
total = 0
for n in nums:
total += n
return total
print(add(1, 2, 3)) # 6
print(add(1, 2, 3, 4, 5)) # 15
print(add()) # 0The name is conventionally args, but you can use a meaningful name like *nums.
Unpacking at the call site #
* also works on the calling side. It unpacks a sequence into positional arguments.
def add(a: int, b: int, c: int) -> int:
return a + b + c
nums = [1, 2, 3]
print(add(*nums)) # unpacked as 1, 2, 3 → 6**kwargs — variable keyword arguments
#
Prefix with ** means “receive all remaining keyword arguments as a dict”.
def configure(**options: str) -> None:
for key, value in options.items():
print(f"{key} = {value}")
configure(host="localhost", port="5432", db="myapp")
# host = localhost
# port = 5432
# db = myappUnpacking at the call site #
The ** at the call site unpacks a dict into keyword arguments.
def create_user(name: str, age: int, role: str) -> dict:
return {"name": name, "age": age, "role": role}
data = {"name": "curtis", "age": 30, "role": "admin"}
print(create_user(**data))Often used to forward an API response straight into a function’s arguments.
All forms together #
def fn(a: int, b: int, *args: int, **kwargs: str) -> None:
print(a, b, args, kwargs)
fn(1, 2, 3, 4, 5, name="curtis", role="admin")
# 1 2 (3, 4, 5) {'name': 'curtis', 'role': 'admin'}Order: regular args → *args → regular args (keyword-only) → **kwargs.
Keyword-only — bare *
#
A bare * makes the parameters after it receivable only as keywords.
def create(name: str, *, role: str = "member", active: bool = True) -> dict:
return {"name": name, "role": role, "active": active}
create("curtis") # OK
create("curtis", role="admin") # OK
create("curtis", "admin") # ✗ TypeError
# create() takes 1 positional argument but 2 were givenThis blocks the caller from writing things like create("curtis", "admin", True). When there are multiple boolean flags, it forces the call to read meaningfully.
Positional-only — bare / (3.8+)
#
Conversely, parameters before / are forced to be positional only.
def power(base: float, exp: float = 2, /) -> float:
return base ** exp
power(3) # 9
power(2, 3) # 8
power(base=3) # ✗ TypeError — base can't be a keywordWhen to use? When you want to keep the freedom to rename a parameter in the future. If the caller isn’t tied to a name, the library maintainer can rename and not break caller code. Most built-in functions (abs(x), len(seq)) are this shape.
All three together #
def f(pos1: int, pos2: int, /, normal: int, *, kw1: int, kw2: int) -> None:
print(pos1, pos2, normal, kw1, kw2)
# pos1, pos2 → positional only
# normal → positional or keyword
# kw1, kw2 → keyword only
f(1, 2, 3, kw1=4, kw2=5) # OK
f(1, 2, normal=3, kw1=4, kw2=5) # OK
f(pos1=1, pos2=2, ...) # ✗ pos1, pos2 can't be keywords
f(1, 2, 3, 4, 5) # ✗ kw1, kw2 can't be positionalHow to read it:
- Before
/= positional-only - Between
/and*= regular - After
*= keyword-only
Lambdas — one-line functions #
lambda is an anonymous function. It can hold only one expression.
square = lambda x: x ** 2
print(square(5)) # 25
# Often used as a callback like the key of sorted
people = [("curtis", 30), ("smith", 25), ("john", 40)]
people.sort(key=lambda p: p[1])
print(people)
# [('smith', 25), ('curtis', 30), ('john', 40)]Assigning a lambda to a variable (square = lambda x: ...) is not recommended. Write def square(x): ... instead. Conventionally lambdas are only used where no name is needed.
nonlocal and global — a quick word on scope
#
To modify a variable in an outer scope from inside a function, you must declare it.
def outer():
count = 0
def inner():
nonlocal count # declare that we modify outer's count
count += 1
inner()
inner()
print(count) # 2
outer()Without nonlocal, count += 1 creates a new local variable inside inner and outer’s stays unchanged. (Strictly, you get an UnboundLocalError — reading before writing.)
To modify a global from inside a function, use global. But where possible, avoiding global variables themselves is better.
Functions are first-class objects #
Functions can be assigned to variables, passed as arguments, and returned.
def add(a: int, b: int) -> int:
return a + b
op = add # assign a function to a variable
print(op(2, 3)) # 5
def apply(fn, x: int, y: int) -> int:
return fn(x, y)
print(apply(add, 2, 3)) # 5This property is the foundation of Chapter 12 Decorator patterns. A one-line function that takes a function and returns a function is the essence of a decorator.
The Callable type — typing functions that take functions
#
When taking a function as an argument and you want to write its signature, use Callable.
from collections.abc import Callable
def apply(fn: Callable[[int, int], int], x: int, y: int) -> int:
return fn(x, y)
apply(lambda a, b: a + b, 1, 2) # 3The form is Callable[[argument types], return type]. The argument types are written as a list, which feels odd at first.
The ParamSpec that treats the argument signature of Callable itself as a type variable is covered in Chapter 20 Typing in depth.
docstring — documentation strings #
The first-line string of a function becomes its docstring.
def add(a: int, b: int) -> int:
"""Return the sum of two integers."""
return a + b
print(add.__doc__)
# Return the sum of two integers.
help(add)Type hints fill in “what is taken and what is given”, so the docstring is for “why / how to use”. A short, firm one-liner is best.
Exercises #
- Reproduce the mutable-default pitfall yourself. With a function of signature
def collect(item, bucket=[]):, call it three times with the same argument and check the result. Then fix the same function tobucket: list | None = Noneplus creating a new list inside, and run the same calls again. - Write a function with signature
def request(url: str, *, method: str = "GET", timeout: float = 5.0, retries: int = 0) -> str:.method,timeout, andretriesare forced to be keyword-only. Confirm thatrequest("https://example.com", "POST")fails andrequest("https://example.com", method="POST")works. - Import
Callableviafrom collections.abc import Callable, and writedef apply_n_times(fn: Callable[[int], int], x: int, n: int) -> int:. It returns the result of applyingfntoxntimes. Confirm thatapply_n_times(lambda x: x + 1, 0, 5)→ 5 andapply_n_times(lambda x: x * 2, 1, 10)→ 1024.
In one line: Function arguments combine regular / default /
*args/**kwargs/ keyword-only (*) / positional-only (/). Mutable defaults becomeNone+ create inside; multiple booleans should be forced keyword-only.*seq/**dictat the call site unpack. Functions are values, so type-hint them withCallable[[...], ...].
Next chapter #
In the next chapter, Chapter 6 Errors and exception handling, we cover try/except/else/finally, user-defined exceptions, and the except* (exception group) added in Python 3.11. The start of Chapter 6 is how callers receive the exceptions raised by the functions of this chapter.