Python

*args, **kwargs, and unpacking

The asterisk means something different in five slightly different places — collecting arguments, collecting keyword arguments, and spreading a collection back out — and mixing them up is where most of the confusion actually comes from.

Intermediate

4 min read

The problem: a function that needs to accept "however many"

def add(a, b):
    return a + b
 
add(1, 2, 3)   # TypeError: add() takes 2 positional arguments but 3 were given

A normal function signature declares a fixed number of parameters, and calling it with the wrong count is an error — which is exactly right most of the time, but sometimes a function genuinely needs to accept an unknown, variable number of arguments. print() is the obvious built-in example: print(1), print(1, 2), and print(1, 2, 3, 4, 5) are all valid, and Python's own function signature has to somehow allow for that.

*args — collecting extra positional arguments into a tuple

def total(*args):
    return sum(args)
 
total(1, 2, 3)        # 6 — args is (1, 2, 3)
total(1, 2, 3, 4, 5)   # 15 — args is (1, 2, 3, 4, 5)

The * before a parameter name tells Python: "gather every remaining positional argument into a tuple with this name." args is just a convention, not a keyword — *numbers works exactly the same way — but *args is what you'll see in almost every codebase, and deviating from it without a reason just makes code harder to skim. Inside the function, args behaves like any other tuple: iterate over it, index into it, pass it to sum().

**kwargs — collecting extra keyword arguments into a dict

def describe(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")
 
describe(name="Ada", age=36, field="mathematics")
name: Ada
age: 36
field: mathematics

** before a parameter name does the same thing for keyword arguments — anything passed as key=value that doesn't match a named parameter gets collected into a dict named kwargs (again, a strong convention, not a requirement). This is what lets a function accept an open-ended, named set of options without declaring every possible one up front.

Combining fixed parameters with both

def create_user(username, *args, **kwargs):
    print(f"username: {username}")
    print(f"extra positional: {args}")
    print(f"extra keyword: {kwargs}")
 
create_user("ada", "admin", role="mathematician", active=True)
username: ada
extra positional: ('admin',)
extra keyword: {'role': 'mathematician', 'active': True}

Python requires a fixed order: regular positional parameters first, then *args, then **kwargs — this isn't a style preference, it's the only order Python's parser accepts. username is matched first because it's declared explicitly; everything else positional falls into args, everything else keyword falls into kwargs.

The other direction: * and ** to unpack, not collect

The same two symbols do the opposite job at a call site instead of a function definition — spreading a collection back out into individual arguments, rather than gathering individual arguments into a collection:

def add(a, b, c):
    return a + b + c
 
numbers = [1, 2, 3]
add(*numbers)          # same as add(1, 2, 3) — the list is unpacked into three positional args
 
options = {"a": 1, "b": 2, "c": 3}
add(**options)         # same as add(a=1, b=2, c=3) — the dict is unpacked into keyword args

This is genuinely the same underlying idea in reverse: *args in a definition packs loose arguments into a tuple; * at a call site unpacks a tuple (or list, or any iterable) into loose arguments. The same relationship holds for ** and dicts. Confusing "packing" and "unpacking" is the single most common source of */** mistakes — the direction depends entirely on whether the asterisk appears in a def or in a call.

Where this shows up constantly in real code

Decorators, covered in their own lesson, rely on exactly this: a wrapper function is written as def wrapper(*args, **kwargs) specifically because it has no idea what arguments the function it's wrapping will be called with — collecting everything generically and passing it straight through (func(*args, **kwargs)) is what lets one decorator work on any function, regardless of that function's own signature. This is also how Django's class-based views and many library APIs forward arguments through layers of wrapping without needing to know each layer's exact signature.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. In `def total(*args): return sum(args)`, what type is args inside the function?

2. Why does `def create_user(username, *args, **kwargs):` require exactly that parameter order?

3. Given `numbers = [1, 2, 3]`, what does `add(*numbers)` actually do at the call site?

4. Why do decorator wrapper functions almost always use `def wrapper(*args, **kwargs):`?