Arguments in Depth

Python gives you fine control over how functions accept arguments: by position or by name, with defaults, with any number of extras via *args and **kwargs, and with markers that force some arguments to be named. This lesson also covers the most famous Python gotcha — mutable default values.

Positional and keyword arguments

PY basics.py
def book_flight(origin, destination, passengers=1):
    return f"{passengers} × {origin} → {destination}"

print(book_flight("LIS", "OPO"))
print(book_flight("LIS", "OPO", 2))
print(book_flight(destination="NYC", origin="LIS"))
print(book_flight("LIS", passengers=3, destination="PAR"))
Output
1 × LIS → OPO
2 × LIS → OPO
1 × LIS → NYC
3 × LIS → PAR

Positional arguments are matched by order; keyword arguments by name, in any order. Once you use a keyword argument in a call, everything after it must be a keyword argument too.

*args: any number of positional arguments

PY args.py
def average(*numbers):
    if not numbers:
        return 0
    return sum(numbers) / len(numbers)

print(average(4, 8, 15))
print(average())
values = [1, 2, 3, 4]
print(average(*values))
Output
9.0
0
2.5

A parameter starting with * collects extra positional arguments into a tuple. At the call site, *values does the opposite, spreading a list into separate arguments.

**kwargs: any number of keyword arguments

PY kwargs.py
def make_tag(tag, text, **attributes):
    attrs = "".join(f' {name}="{value}"' for name, value in attributes.items())
    return f"<{tag}{attrs}>{text}</{tag}>"

print(make_tag("a", "Home", href="/", title="Go home"))
print(make_tag("p", "Hello"))

options = {"id": "intro", "lang": "en"}
print(make_tag("p", "Hi", **options))
Output
<a href="/" title="Go home">Home</a>
<p>Hello</p>
<p id="intro" lang="en">Hi</p>

**attributes collects extra keyword arguments into a dictionary. The names args and kwargs are only conventions — the stars are what matter.

Keyword-only arguments

Parameters after a bare * (or after *args) must be passed by name. This makes calls with boolean flags readable:

PY keyword_only.py
def connect(host, port=5432, *, timeout=10, ssl=True):
    return f"{host}:{port} timeout={timeout} ssl={ssl}"

print(connect("db.local", timeout=30))
print(connect("db.local", 5432, 30))
Output
db.local:5432 timeout=30 ssl=True
Traceback (most recent call last):
  File "main.py", line 5, in <module>
    print(connect("db.local", 5432, 30))
          ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
TypeError: connect() takes from 1 to 2 positional arguments but 3 were given

Without the *, a call like connect("db.local", 5432, 30, False) would be legal but impossible to understand at a glance.

Positional-only arguments

Parameters before a / can only be passed by position. Library authors use this so they can rename parameters later without breaking callers:

PY positional_only.py
def distance(x1, y1, x2, y2, /):
    return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5

print(distance(0, 0, 3, 4))
Output
5.0

Putting it all together, a signature can have up to five kinds of parameters: def f(pos_only, /, normal, *args, kw_only, **kwargs).

The mutable default trap

Default values are evaluated once, when the function is defined — not each time it's called. With a mutable default like a list, every call shares the same object:

PY mutable_default.py
def add_item_buggy(item, basket=[]):
    basket.append(item)
    return basket

print(add_item_buggy("apple"))
print(add_item_buggy("pear"))

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("pear"))
Output
['apple']
['apple', 'pear']
['apple']
['pear']

The fix is the standard idiom: default to None and create the new list inside the function. Never use a list, dict or set as a default value.

Arguments are passed by reference to objects

A function receives the same object the caller passed. Reassigning the parameter has no effect outside, but mutating the object does:

PY passing.py
def reassign(numbers):
    numbers = [0]

def mutate(numbers):
    numbers.append(99)

data = [1, 2]
reassign(data)
print(data)
mutate(data)
print(data)
Output
[1, 2]
[1, 2, 99]

Functions that quietly change their arguments surprise callers. Prefer returning a new object, or make the mutation obvious from the function's name (add_to_cart(cart, item)).

Forwarding arguments

*args and **kwargs together let a wrapper pass everything through to another function — the basis of decorators (lesson 34):

PY forwarding.py
def logged(fn, *args, **kwargs):
    print(f"calling {fn.__name__} with {args} {kwargs}")
    return fn(*args, **kwargs)

print(logged(round, 3.14159, ndigits=2))
Output
calling round with (3.14159,) {'ndigits': 2}
3.14

Try it yourself

Try it yourself
Console output