Contents
Chapter 5

Functions

You define functions with the def keyword, followed by the function name and parameter list, and a colon to begin the function body:

# a_function.py

def a_function(response):
    val = 0
    if response == "yes":
        print("affirmative")
        val = 1
    print("continuing...")
    return val

print(a_function("no"))
#: continuing...
#: 0
print(a_function("yes"))
#: affirmative
#: continuing...
#: 1

Here the function signature specifies only the function name and the parameter names, but no argument types or return types (Static Typing covers these). Python is dynamically typed, so type errors surface at runtime rather than at compile time. This means the same function can accept and return different types:

# flexible_args_and_returns.py

def flexible_args_and_returns(arg):
    if arg == 1:
        return "Hello"
    if arg == "one":
        return 2

print(flexible_args_and_returns(1))
#: Hello
print(flexible_args_and_returns("one"))
#: 2

Here, the same function applies the ‘+’ operator to integers and strings:

# add.py

def add(arg1, arg2):
    return arg1 + arg2

print(add(42, 47))
#: 89
print(add("spam ", "eggs"))
#: spam eggs

The only constraint on a function argument is that the function can apply its operations to that object.

Default and Keyword Arguments

Parameters can have default values. Keyword arguments let callers pass arguments by name, in any order. Keyword arguments make a call self-documenting:

# default_args.py

def connect(host, port=5432, timeout=30):
    return f"{host}:{port} (timeout {timeout}s)"

print(connect("db.example.com"))  # Uses both defaults
#: db.example.com:5432 (timeout 30s)
print(connect("db.example.com", timeout=5))  # Skip to a keyword
#: db.example.com:5432 (timeout 5s)
print(connect(port=80, host="web.example.com"))  # Any order by name
#: web.example.com:80 (timeout 30s)

Python evaluates a default value once, at function definition. This means a mutable default is shared across calls:

# mutable_default.py

def bad_append(item, target=[]):  # The same list every call
    target.append(item)
    return target

print(bad_append(1))
#: [1]
print(bad_append(2))  # Surprise, the default kept the 1
#: [1, 2]

def good_append(item, target=None):
    if target is None:
        target = []  # A fresh list each call
    target.append(item)
    return target

print(good_append(1))
#: [1]
print(good_append(2))
#: [2]

A mutable default persists because it lives on the function object. Python does not recreate it on each call. This behavior commonly confuses newcomers to the language.

You only need the None sentinel when the function modifies the argument. If the function only reads the parameter, use an immutable default such as an empty tuple. Calls still share it, but sharing is harmless because it cannot change:

# immutable_default.py

def show(items=()):  # An empty tuple is safe: it can't be mutated
    for item in items:
        print(item)
    print(f"({len(items)} items)")

show()
#: (0 items)
show(["a", "b"])
#: a
#: b
#: (2 items)

With the type hints from Static Typing, such a parameter reads:

items: Sequence[str] = ()

The None sentinel works only because None is not a meaningful value here. When None is also a valid argument, you need a distinct marker. Python 3.15 (PEP 661) adds a sentinel builtin that creates a unique self-describing value for this purpose:

# sentinel_default.py

MISSING = sentinel("MISSING")

def get(data, key, default=MISSING):
    if key in data:
        return data[key]
    if default is MISSING:
        return MISSING  # Normally raise exception here
    return default

prefs = {"volume": 3, "mute": None}
print(get(prefs, "volume"))
#: 3
print(get(prefs, "mute"))  # None is a real stored value
#: None
print(get(prefs, "theme"))
#: MISSING
print(get(prefs, "theme", "dark"))
#: dark

Here prefs stores mute as None, so None cannot also mean “not supplied.” The MISSING sentinel keeps the two cases apart. A missing key with no default would normally raise an exception. A stored None comes back untouched.

Variable Argument Lists

A *args parameter collects extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dictionary:

# var_args.py

def report(label, *values, **options):
    print(label, values, options)

report("nums", 1, 2, 3)
#: nums (1, 2, 3) {}
report("point", 3, 4, color="red", size=10)
#: point (3, 4) {'color': 'red', 'size': 10}

Unpacking Arguments

* and ** also work in the other direction. At a call site, * unpacks a sequence into separate positional arguments, and ** unpacks a dictionary into keyword arguments.

# unpacking.py

def f(a, b, c):
    print(a, b, c)

x = [1, 2, 3]
f(*x)
#: 1 2 3
f(*(4, 5, 6))
#: 4 5 6
# ** unpacks a dictionary into keyword arguments:
d = {"a": 3.14, "b": 1.62, "c": 2.72}
f(**d)
#: 3.14 1.62 2.72

def report(label, *values, **options):
    print(label, values, options)

nums = (1, 2, 3)
opts = {"color": "red", "size": 10}
report("point", *nums, **opts)
#: point (1, 2, 3) {'color': 'red', 'size': 10}

Because collecting and unpacking are inverses, a function can gather arguments with *args and **kwargs, then pass them on unchanged, as seen in report(). This is the standard way to write a wrapper around another function.

Positional-Only and Keyword-Only Parameters

Two markers in a parameter list control how callers may pass arguments. A / ends the positional-only parameters. You must pass every parameter before it by position, never by name. A * begins the keyword-only parameters. You must pass every parameter after it by name.

# param_markers.py
# `/` ends the positional-only parameters.
# `*` begins the keyword-only parameters.

def divide(a, b, /):
    return a / b

print(divide(10, 2))
#: 5.0

def make_user(name, *, admin=False):
    return f"{name} (admin={admin})"

print(make_user("Bob"))
#: Bob (admin=False)
print(make_user("Sue", admin=True))
#: Sue (admin=True)

Calling divide(a=10, b=2) is an error, because a and b are positional-only. Calling make_user("Sue", True) is an error, because admin is keyword-only.

In the standard library, many built-in functions and methods take positional-only parameters, such as dict.get(key, default, /). Marking a parameter positional-only also keeps its name out of the method’s contract. That matters when a subclass overrides a method. Since the name is not part of the interface, the subclass can rename the parameter, and a type checker will not object.

Lambdas

A lambda is a small anonymous function written as a single expression. It is useful for passing behavior to functions such as sorted():

# lambdas.py

words = ["banana", "kiwi", "apple", "fig"]
print(sorted(words, key=lambda w: len(w)))  # Sort by length
#: ['fig', 'kiwi', 'apple', 'banana']
square = lambda n: n * n  # Usually prefer def
print(square(9))
#: 81

Compared to other languages, Python’s lambdas allow only a single expression. For anything more complicated, write a separate function.

Exercises

  1. In mutable_default.py, call bad_append(3) a third time and predict the result before checking it. Then change bad_append’s default from [] to () and explain why that alone does not fix it (hint: target.append(item) on a tuple).
  2. In sentinel_default.py, add a third key to prefs, "volume2": None, and call get(prefs, "volume2") to confirm the sentinel still tells None-as-value apart from missing.
  3. In param_markers.py, add a parameter label: str = "result" to divide(), keyword-only, so divide(10, 2, label="half") prints "half: 5.0". Confirm that divide(10, 2, "half"), passing label positionally, is now a TypeError.
  4. Rewrite report() from var_args.py so it also accepts a total: bool = False keyword-only flag that, when true, additionally prints sum(values). Confirm report("nums", 1, 2, 3, total=True) prints the sum.