Arguments in Depth

Explore · optional
The Course
•
January 8, 2026
•
6 min read

Python’s argument rules are unusually flexible. Learning them is what lets you read other people’s code — and the standard library — without guessing.

The parts of this you need for the rest of the beginner course are already in Defining Functions: naming an argument at the call site, and why a default must never be a list. What’s new here — collecting any number of arguments, and spreading a collection into a call — is how decorators and wrapper functions work, and the intermediate course leans on it.

Positional and keyword

Arguments are matched by position by default, or by name if you name them.

Two ways to pass the same values Python
def book_room(guest, nights, breakfast):
    return f"{guest}: {nights} nights, breakfast={breakfast}"

print(book_room("Ada", 3, True))                          # by position
print(book_room(guest="Ada", nights=3, breakfast=True))   # by name
print(book_room("Ada", breakfast=True, nights=3))         # mixed

# Named arguments may be reordered; positional ones may not.
print(book_room(3, "Ada", True))

That last line runs happily and produces nonsense — the classic argument-order bug. Naming arguments at the call site is free insurance, especially for booleans: book_room("Ada", 3, breakfast=True) says something that True alone does not.

*args — any number of positional arguments

Collecting extras Python
def total(*numbers):
    """Sum any number of arguments."""
    print("received:", numbers, type(numbers))
    return sum(numbers)

print(total(1, 2))
print(total(1, 2, 3, 4, 5))
print(total())

*numbers gathers every extra positional argument into a tuple. The name is up to you; args is simply the convention.

**kwargs — any number of named arguments

Collecting named extras Python
def describe(**details):
    print("received:", details)
    for key, value in details.items():
        print(f"  {key}: {value}")

describe(name="Ada", born=1815, field="mathematics")

**details gathers named arguments into a dictionary. Together, *args and **kwargs let a function accept anything — which is how decorators and wrapper functions work.

Everything at once Python
def report(title, *items, separator=", ", **meta):
    print(f"{title}: {separator.join(items)}")
    if meta:
        print("  meta:", meta)

report("Fruit", "apple", "pear", "fig")
report("Fruit", "apple", "pear", separator=" | ", source="market", fresh=True)

The order in a definition is fixed: normal parameters, then *args, then keyword-only parameters, then **kwargs.

Unpacking at the call site

The same two stars work in reverse — spreading a collection into arguments.

Spreading a list or dict into a call Python
def book_room(guest, nights, breakfast):
    return f"{guest}: {nights} nights, breakfast={breakfast}"

booking_values = ["Grace", 2, False]
print(book_room(*booking_values))          # list -> positional arguments

booking = {"guest": "Alan", "nights": 4, "breakfast": True}
print(book_room(**booking))                # dict -> keyword arguments

This is why you see f(*args, **kwargs) everywhere: it means “pass along whatever I was given, untouched”.

Mutable arguments

Arguments are passed by reference. Rebinding a name inside a function is local; mutating the object is visible to the caller.

What the caller sees Python
def rebind(items):
    items = ["new", "list"]      # only the local name changes
    return items

def mutate(items):
    items.append("added")        # the caller's list really changes
    return items

original = ["a", "b"]
rebind(original)
print("after rebind:", original)

mutate(original)
print("after mutate:", original)

Neither is wrong, but a function that quietly edits its arguments should say so in its name — sort_in_place(rows), not get_rows(rows). When in doubt, copy the input and return a new object.

Before you write your own

Predict. How many positional arguments arrive each time, and which names?

What will this print? Python
def show(*args, **kwargs):
    print(len(args), sorted(kwargs))

show(1, 2, 3)
show("a", size="L", colour="red")
show()
Check your prediction
3 []
1 ['colour', 'size']
0 []

Positional arguments land in the tuple args, named ones in the dictionary kwargs — and sorted on a dictionary sorts its keys. Calling with nothing at all is fine: both are simply empty.

Fix. This should say Move 50 from savings to current. Every argument is there — in the wrong places. Fix it by naming them at the call, without touching the function.

Right values, wrong places Python
def transfer(amount, from_account, to_account):
    return f"Move {amount} from {from_account} to {to_account}"

print(transfer("savings", "current", 50))
Check your fix
def transfer(amount, from_account, to_account):
    return f"Move {amount} from {from_account} to {to_account}"

print(transfer(50, from_account="savings", to_account="current"))
Move 50 from savings to current

Python matched the values to the parameters by position and ran without complaint — which is what makes this kind of bug dangerous with money. Named arguments make the call say what it means.

Exercise

A flexible formatter

Write make_tag(name, *contents, **attributes) that builds an HTML-ish tag. The two calls in the playground should print:

<p class="intro">Hello world</p>
<h1>Title</h1>

Join the contents with spaces, and build each attribute as key="value" (strip any trailing underscore from the key, since class is a reserved word).

def make_tag(name, *contents, **attributes):
    # Your code here
    return ""

print(make_tag("p", "Hello", "world", class_="intro"))
print(make_tag("h1", "Title"))
Hint 1: the idea

Build two pieces of text, then put them together. contents is a tuple of strings, so joining it with spaces gives the inside of the tag. attributes is a dictionary: each key and value becomes one key="value" piece, and the pieces are added together.

Hint 2: a first step

Make the simpler call work first — the one with no attributes. With inner = " ".join(contents) and return f"<{name}>{inner}</{name}>", the second line already prints <h1>Title</h1>. Then loop over attributes.items() to add the rest, using key.rstrip("_") for each name.

Show one solution
def make_tag(name, *contents, **attributes):
    """Build a simple HTML tag from contents and attributes."""
    parts = ""
    for key, value in attributes.items():
        parts += f' {key.rstrip("_")}="{value}"'
    inner = " ".join(contents)
    return f"<{name}{parts}>{inner}</{name}>"

print(make_tag("p", "Hello", "world", class_="intro"))
print(make_tag("h1", "Title"))

This is close to how real templating helpers are written — *contents for however many children the caller has, **attributes for however many attributes.

What you learned

  • Arguments bind by position, or by name if you name them at the call site.
  • *args collects extra positional arguments into a tuple.
  • **kwargs collects extra named arguments into a dictionary.
  • * and ** at a call site spread a sequence or mapping into arguments.
  • Mutating an argument changes the caller’s object; rebinding does not.
Last updated on January 8, 2026

Was this article helpful?

Your response is saved on this device.