Contents
5 Chapter

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 #

basic
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 #

multiple returns are really tuples
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 kim

return 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 #

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.

✗ not allowed
def bad(x: int = 0, y: int) -> int: ...
# SyntaxError: non-default argument follows default argument

Pitfall — mutable default #

This is Python’s most famous pitfall.

🚫 never write it this way
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.

✅ correct pattern
def append_to(item, target: list[int] | None = None):
    if target is None:
        target = []
    target.append(item)
    return target

This pattern shows up a lot. Worth memorizing.

Positional vs. keyword calls #

There are two ways to pass arguments when calling.

two call styles
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”.

*args
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())                   # 0

The 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.

the * at the call site
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”.

**kwargs
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 = myapp

Unpacking at the call site #

The ** at the call site unpacks a dict into keyword arguments.

the ** at the call site
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 #

every notation in one place
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.

keyword-only
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 given

This 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.

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 keyword

When 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 #

positional-only / regular / keyword-only
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 positional

How 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.

lambda
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.

nonlocal
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.

functions as values
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))   # 5

This 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.

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)   # 3

The 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.

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 #

  1. 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 to bucket: list | None = None plus creating a new list inside, and run the same calls again.
  2. Write a function with signature def request(url: str, *, method: str = "GET", timeout: float = 5.0, retries: int = 0) -> str:. method, timeout, and retries are forced to be keyword-only. Confirm that request("https://example.com", "POST") fails and request("https://example.com", method="POST") works.
  3. Import Callable via from collections.abc import Callable, and write def apply_n_times(fn: Callable[[int], int], x: int, n: int) -> int:. It returns the result of applying fn to x n times. Confirm that apply_n_times(lambda x: x + 1, 0, 5) → 5 and apply_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 become None + create inside; multiple booleans should be forced keyword-only. *seq / **dict at the call site unpack. Functions are values, so type-hint them with Callable[[...], ...].

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.

X