Python Function Arguments

When you call a Python function, the values you pass to it are called arguments, and the names that receive those values inside the function definition are called parameters. Python is unusually flexible about how arguments can be supplied — by position, by name, with defaults, or gathered into a variable-length group — and understanding every mode is essential for writing functions that are both convenient to call and hard to misuse. This lesson walks through every argument style Python supports, explains how the interpreter actually matches arguments to parameters, and highlights the mistakes that trip up even experienced developers.

Overview: How Function Arguments Work

Every parameter in a function definition falls into one of five categories: positional-or-keyword (the default kind), positional-only, keyword-only, var-positional (*args), and var-keyword (**kwargs). When you call a function, Python builds a binding between the arguments you supplied and the parameters in the definition. It does this in a strict order: first it fills positional-or-keyword parameters from left to right using any positional arguments you passed, then it matches remaining arguments by keyword name, then it applies default values to any parameter that still has no value, and finally it raises a TypeError if any required parameter is left unfilled or if an unexpected keyword was supplied without a **kwargs catch-all.

A subtlety that confuses beginners: arguments in Python are passed by object reference, sometimes called “pass by assignment.” The parameter name inside the function becomes another name bound to the same object the caller passed in. If that object is mutable (like a list or dict) and the function mutates it in place, the caller sees the change. But if the function simply reassigns the parameter to a new object, that has no effect on the caller’s variable — only the local name changes what it points to.

Default values deserve special attention because they are evaluated exactly once, at the moment the def statement runs (i.e., when the function object is created), not each time the function is called. This is harmless for immutable defaults like numbers or strings, but it is a classic source of bugs when the default is a mutable object such as a list or dictionary — every call that relies on the default ends up sharing the very same object. We’ll see this in detail in the Common Mistakes section below.

Syntax

The full parameter syntax, using every category at once, looks like this:

def function_name(pos1, pos2, /, pos_or_kw, *, kw_only1, kw_only2=default, **kwargs):
    # function body
    pass
Part Meaning
pos1, pos2 Positional-only parameters — must be supplied by position, never by keyword.
/ Marks the boundary: everything before it is positional-only.
pos_or_kw A normal parameter — can be passed positionally or by keyword.
* Marks the boundary: everything after it must be passed by keyword.
kw_only1, kw_only2 Keyword-only parameters; kw_only2 has a default value and becomes optional.
**kwargs Collects any extra keyword arguments not matched above into a dict.

You’ll rarely use every feature in one function. Most everyday functions just use plain positional-or-keyword parameters, optionally with defaults. The / and * markers exist for API design: / lets you rename a parameter later without breaking callers (since they could never use its name), and * forces callers to be explicit about which value means what — useful when a function takes several parameters of the same type. A separate *args parameter (without the boundary marker) collects any extra positional arguments into a tuple, and can appear on its own: def f(a, *args, **kwargs).

Examples

Example 1: Positional, keyword, and default arguments together

def describe_pet(name, animal_type="dog", age=None):
    if age is not None:
        print(f"{name} is a {age}-year-old {animal_type}.")
    else:
        print(f"{name} is a {animal_type}.")

describe_pet("Rex")
describe_pet("Whiskers", animal_type="cat")
describe_pet(name="Buddy", animal_type="dog", age=3)
describe_pet("Nibbles", "hamster", 1)

Output:

Rex is a dog.
Whiskers is a cat.
Buddy is a 3-year-old dog.
Nibbles is a 1-year-old hamster.

Here name is required, while animal_type and age have defaults and become optional. Notice that arguments can be supplied purely positionally ("Nibbles", "hamster", 1), purely by keyword (name="Buddy"), or mixed — as long as every positional argument comes before every keyword argument in the call.

Example 2: Variable-length arguments with *args and **kwargs

def order_summary(customer, *items, **details):
    print(f"Order for {customer}:")
    for item in items:
        print(f"  - {item}")
    for key, value in details.items():
        print(f"  {key}: {value}")

order_summary("Alice", "Book", "Pen", gift_wrap=True, express=False)

Output:

Order for Alice:
  - Book
  - Pen
  gift_wrap: True
  express: False

customer takes the first positional argument as usual. Every additional positional argument ("Book", "Pen") is swept into the tuple items. Every keyword argument that isn’t an explicitly named parameter (gift_wrap, express) is swept into the dict details, preserving the order they were passed in. This pattern is how functions like print() accept an unlimited number of arguments.

Example 3: Positional-only and keyword-only parameters

def calculate_price(base_price, /, *, tax_rate=0.08, discount=0):
    price_after_discount = base_price - discount
    total = price_after_discount * (1 + tax_rate)
    return round(total, 2)

print(calculate_price(100))
print(calculate_price(100, tax_rate=0.05, discount=10))

Output:

108.0
94.5

The / after base_price forces callers to pass it positionally (calculate_price(base_price=100) would raise a TypeError). The * forces tax_rate and discount to be passed by keyword, which makes call sites self-documenting — you can’t accidentally swap the tax rate and the discount by getting the order wrong, because both must be named.

Under the Hood: Unpacking Arguments at the Call Site

The * and ** operators aren’t just for collecting arguments inside a definition — they can also unpack a sequence or mapping into separate arguments when you call a function:

def total_cost(a, b, c):
    return a + b + c

values = [10, 20, 30]
print(total_cost(*values))

info = {"a": 1, "b": 2, "c": 3}
print(total_cost(**info))

Output:

60
6

*values unpacks the list into three separate positional arguments, equivalent to writing total_cost(10, 20, 30). **info unpacks the dictionary into keyword arguments, equivalent to total_cost(a=1, b=2, c=3), matching each dict key to a parameter name. Internally, Python performs this binding using the same machinery that inspect.signature().bind() exposes: it walks the parameter list left to right, consumes positional arguments first, then keyword arguments by name, then applies defaults, and finally raises TypeError: missing a required argument or TypeError: got an unexpected keyword argument if anything is left unresolved.

Common Mistakes

Mistake 1: Using a mutable default argument

def add_item(item, cart=[]):
    cart.append(item)
    return cart

print(add_item("apple"))
print(add_item("banana"))

Output:

['apple']
['apple', 'banana']

This is almost never what you want. Because the default list is created once when the function is defined, every call that omits cart shares and mutates that same list, so items accumulate across unrelated calls. The fix is to use None as the default and create a fresh list inside the function body:

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

print(add_item("apple"))
print(add_item("banana"))

Output:

['apple']
['banana']

Now each call that doesn’t supply cart gets its own new list. The same rule applies to any mutable default: dictionaries, sets, and custom mutable objects.

Mistake 2: Placing a positional argument after a keyword argument

Python requires that once you start using keyword arguments in a call, every argument after them must also be a keyword argument. Writing something like describe_pet(animal_type="cat", "Whiskers") is invalid — the positional value "Whiskers" comes after the keyword argument animal_type="cat", which Python’s parser rejects outright:

describe_pet(animal_type="cat", "Whiskers")

This raises SyntaxError: positional argument follows keyword argument before the function is even called — it’s a parse-time error, not a runtime one. The fix is simply to reorder the call so all positional arguments come first: describe_pet("Whiskers", animal_type="cat").

Best Practices

  • Give parameters descriptive names — they double as documentation and, for keyword arguments, as labels at the call site.
  • Never use a mutable object (list, dict, set) as a default value; use None and create the mutable object inside the function body instead.
  • Use * to force keyword-only arguments when a function has several parameters of the same type, so call sites stay unambiguous (compare resize(width, height) to resize(*, width, height)).
  • Use / to mark parameters positional-only when the parameter name is an implementation detail you want the freedom to rename later.
  • Prefer a small number of explicit parameters over an **kwargs catch-all whenever the set of valid arguments is known in advance — explicit parameters give you better error messages and editor autocompletion.
  • When a function genuinely needs to accept an open-ended set of options (e.g. wrapping another API), *args/**kwargs is the right tool, but document what’s expected in the docstring.
  • Keep default values simple and self-explanatory; if a default requires a comment to explain, consider whether it should be a named constant instead.
  • Order parameters from most-important/required to least-important/optional, matching how a reader would naturally think about the call.

Practice Exercises

  1. Write a function make_profile(name, age, /, *, city="Unknown", occupation="Unknown") that prints a one-line profile string. Call it once using only the required positional arguments, and once supplying city and occupation as keywords. Confirm that calling it with name="Sam" as a keyword raises a TypeError.
  2. Write a function average(*numbers) that returns the average of any number of numeric arguments, and raises a ValueError with a clear message if it is called with zero arguments. Test it with average(2, 4, 6) (expected: 4.0) and with no arguments.
  3. Write a function build_url(base, **params) that takes a base URL string and any number of keyword arguments, and returns a URL with those keywords joined as a query string, e.g. build_url("https://example.com", page=2, sort="asc") should return "https://example.com?page=2&sort=asc". Hint: use "&".join(f"{k}={v}" for k, v in params.items()).

Summary

  • Parameters are the names in a function definition; arguments are the values supplied when calling it.
  • Python supports positional-or-keyword, positional-only (before /), keyword-only (after *), *args (extra positionals as a tuple), and **kwargs (extra keywords as a dict).
  • Arguments are matched positionally first, then by keyword, then defaults fill in anything left unset.
  • Default values are evaluated once at function-definition time — never use a mutable object as a default.
  • The * and ** operators can unpack lists/tuples and dicts into separate arguments at a call site.
  • A positional argument may never follow a keyword argument in a function call — Python rejects this as a SyntaxError.
  • Use / and * markers deliberately to design clearer, more maintainable function signatures.