Python by Example

Functions

Python function signatures: keyword-only and positional-only args, the mutable-default footgun, and returning multiple values cleanly.

A function is defined with def name(parameters):. Python functions support positional arguments, keyword arguments, default values, *args for variadic positional arguments, and **kwargs for variadic keyword arguments. Two separator symbols - / and * - let you control exactly how callers may pass each argument.

Placing * in a parameter list means every argument after it must be passed by name (keyword-only). Placing / means every argument before it must be passed by position (positional-only). These are not just style rules - Python enforces them at call time. Keyword-only arguments make call sites self-documenting and prevent argument-order bugs.

# timeout and retries are keyword-only (after *)
def fetch(url, *, timeout=10, retries=3):
    ...
 
fetch("https://example.com")                # ok
fetch("https://example.com", timeout=5)     # ok
fetch("https://example.com", 5)             # TypeError: too many positional arguments
 
# x is positional-only (before /); y can be either
def transform(x, /, y):
    return x + y
 
transform(1, 2)       # ok - both positional
transform(1, y=2)     # ok - y as keyword
transform(x=1, y=2)   # TypeError: x is positional-only
 
# Combining both: url is positional-only, timeout and retries are keyword-only
def request(url, /, *, timeout=10, retries=3):
    ...

Default argument values are evaluated once when Python reads the def statement, not each time the function is called. For immutable defaults like strings and numbers this does not matter. For mutable objects like lists and dicts it causes a subtle bug: every call that omits the argument shares the same object. The fix is to use None as the sentinel and build the mutable default inside the function body.

# Bug: items is created once; all calls share the same list
def append_item(value, items=[]):
    items.append(value)
    return items
 
append_item(1)   # [1]
append_item(2)   # [1, 2]  -- not [2]!
append_item(3)   # [1, 2, 3]
 
# Fix: use None as sentinel, build the default inside the body
def append_item_fixed(value, items=None):
    if items is None:
        items = []
    items.append(value)
    return items
 
append_item_fixed(1)   # [1]
append_item_fixed(2)   # [2]   -- fresh list each time

A Python function can return multiple values by returning a tuple. The caller can unpack them directly into separate names. For shapes that will be passed around or stored, a typing.NamedTuple adds names and type annotations without the overhead of a full class.

from typing import NamedTuple
 
# Plain tuple return: works, but callers must remember the order
def divmod_plain(a, b):
    return a // b, a % b
 
quotient, remainder = divmod_plain(17, 5)
# quotient=3, remainder=2
 
# NamedTuple: adds names, type annotations, and dot-access
class DivResult(NamedTuple):
    quotient: int
    remainder: int
 
def divmod_named(a, b):
    return DivResult(a // b, a % b)
 
result = divmod_named(17, 5)
result.quotient    # 3
result.remainder   # 2
 
# Still unpackable like a plain tuple
q, r = divmod_named(17, 5)

In production

def f(items=[]): evaluates the default once at definition time - every call without an argument shares the same list across the lifetime of the program; use def f(items=None): items = items if items is not None else []. Functions with more than two or three positional arguments become unreadable at call sites - use * to make the rest keyword-only (def fetch(url, *, timeout, retries)); positional arguments past three are a code-review signal. See the idempotency key pattern for how careful parameter design makes retry-safe APIs possible.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free