Function Arguments: Positional, Keyword, Default

The previous article introduced python function arguments at a basic level — this one goes deep into the different ways Python lets you pass data into a function, including the classic mutable-default trap and the stricter positional-only and keyword-only rules that give you finer control over how a function can be called.

Introduction: Parameters vs. Arguments

As a quick reminder from the previous article: parameters are the placeholder names in a function's definition. Arguments are the actual values supplied when the function is called.

def greet(name):   # 'name' is a parameter
    print(f"Hello, {name}!")

greet("Alex")   # "Alex" is the argument

Python offers several distinct ways to pass arguments into a function — positional, keyword, default, and variable-length (*args/**kwargs). This article covers each in turn, along with the rules for combining them.

Positional Arguments

Matched strictly by order

Positional arguments are matched to parameters purely by their position in the call — the first argument fills the first parameter, the second fills the second, and so on.

def describe_pet(name, animal_type):
    print(f"{name} is a {animal_type}")

describe_pet("Rex", "dog")   # Rex is a dog
Missing arguments

If a required positional parameter doesn't get a matching argument, Python raises an error immediately:

describe_pet("Rex")
# TypeError: describe_pet() missing 1 required positional argument: 'animal_type'
Swapped arguments: silently wrong, not an error

This is the more dangerous failure mode, because Python doesn't catch it at all — swapping two positional arguments of compatible types produces no error, just a wrong result:

def subtract(a, b):
    return a - b

print(subtract(10, 3))   # 7 — correct
print(subtract(3, 10))   # -7 — technically valid, but probably not what was intended

Both calls run without complaint. Only the caller (or careful testing) can catch that the second one likely got the arguments backward. This is exactly the kind of mistake keyword arguments, covered next, are designed to prevent.

Keyword Arguments

Passing by explicit name

Keyword arguments let you specify which parameter a value belongs to by name, rather than relying on position:

def describe_pet(name, animal_type):
    print(f"{name} is a {animal_type}")

describe_pet(animal_type="dog", name="Rex")   # Rex is a dog — order doesn't matter here

Because each argument is explicitly labeled, the call is both self-documenting (a reader instantly knows which value is which) and order-independent — you can list keyword arguments in any sequence, and Python will still route them correctly.

The rule: positional arguments must come first

When mixing positional and keyword arguments in a single call, all positional arguments have to appear before any keyword arguments:

def describe_pet(name, animal_type):
    print(f"{name} is a {animal_type}")

# Correct — positional first, keyword second
describe_pet("Rex", animal_type="dog")

# Incorrect — keyword before positional
describe_pet(animal_type="dog", "Rex")
# SyntaxError: positional argument follows keyword argument

This rule exists because once Python sees a keyword argument, it needs to know unambiguously which remaining parameters are still open for positional matching — allowing a positional argument to follow a keyword one would make that ambiguous.

Default Arguments

Making an argument optional

You can assign a fallback value directly in the function definition, which makes that argument optional at call time:

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Alex")                    # Hello, Alex!
greet("Alex", "Good morning")    # Good morning, Alex!

If the caller doesn't supply a value for greeting, Python uses the default. If they do, their value overrides it entirely.

Interaction with positional and keyword calls

Defaults work with both positional and keyword-style calls:

greet("Alex", greeting="Hi")   # Hi, Alex! — keyword override
greet("Alex", "Hi")             # Hi, Alex! — positional override, same result

Either way, supplying a value — by position or by name — overrides the default; omitting it entirely falls back to whatever was specified in the function definition.

The classic mutable-default-argument trap

This is one of Python's most well-known gotchas, and it genuinely surprises almost everyone the first time they hit it. Default values are created once, at the moment the function is defined — not fresh, on every individual call. For immutable defaults (numbers, strings, None), this distinction doesn't matter, since you can't modify them anyway. But for a mutable default, like a list or dictionary, it causes a real, confusing bug: the same object gets reused and potentially modified across every call that relies on the default.

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

print(add_item("apple"))    # ['apple']
print(add_item("banana"))   # ['apple', 'banana'] — wait, shouldn't this be a fresh list?

That second call should intuitively return just ['banana'], if you expect the default to reset on every call — but it doesn't. Because the default list was created exactly once, when the function was defined, every call that doesn't supply its own items argument shares that same list, and modifications from one call persist into the next.

The fix: use None as a sentinel

The standard, universally recommended fix is to default to None instead, and create the actual mutable object fresh inside the function body:

def add_item(item, items=None):
    if items is None:
        items = []   # a brand-new list, created fresh on every call
    items.append(item)
    return items

print(add_item("apple"))    # ['apple']
print(add_item("banana"))   # ['banana'] — correct, independent lists each time

This is such a well-established pattern that it's worth committing to memory as a default habit: any time a parameter's default value would naturally be a list, dictionary, or set, default to None and build the actual object inside the function instead.

Variable-Length Arguments and Strict Ordering Rules

*args: extra positional arguments

Prefixing a parameter with a single asterisk collects any extra positional arguments into a tuple:

def add_all(*numbers):
    return sum(numbers)

print(add_all(1, 2, 3))         # 6
print(add_all(1, 2, 3, 4, 5))   # 15

numbers inside the function is just a regular tuple — (1, 2, 3) in the first call — built automatically from however many positional arguments were actually passed, letting the function accept any number of them.

**kwargs: extra keyword arguments

Similarly, a double asterisk collects any extra keyword arguments into a dictionary:

def print_details(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

print_details(name="Alex", age=30, city="Nagpur")
# name: Alex
# age: 30
# city: Nagpur

details is a regular dictionary — {"name": "Alex", "age": 30, "city": "Nagpur"} — built from whatever keyword arguments the caller happened to supply.

The mandatory parameter order

When you combine several of these argument types in a single function definition, Python enforces a strict order for how they must appear:

def example(positional, default="value", *args, keyword_only, **kwargs):
    pass

That order is: positional parametersdefault parameters*argskeyword-only parameters (covered next) → **kwargs. Getting this order wrong raises a SyntaxError at definition time, before the function is ever even called.

Positional-only and keyword-only markers

Python also lets you explicitly restrict how a parameter must be passed, using two special markers.

Positional-only (/) — parameters listed before a / in the definition can only be passed positionally, never by keyword:

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

print(divide(10, 2))       # 5.0 — fine
print(divide(a=10, b=2))
# TypeError: divide() got some positional-only arguments passed as keyword arguments

This is useful when a parameter's name isn't meaningful to the caller, or when you want to reserve the freedom to rename it later without breaking anyone calling the function with that name as a keyword.

Keyword-only (*) — parameters listed after a bare * in the definition can only be passed by keyword, never positionally:

def create_user(name, *, is_admin=False):
    print(f"{name}, admin: {is_admin}")

create_user("Alex", is_admin=True)   # fine
create_user("Alex", True)
# TypeError: create_user() takes 1 positional argument but 2 were given

This is genuinely useful for parameters where getting the order wrong by mistake (as in the earlier subtract() example) could be dangerous or confusing — forcing the caller to explicitly name is_admin=True makes the call self-documenting and prevents an easy-to-miss ordering mistake.

A real-world pattern: a flexible logging or config function

Here's an example combining several of these concepts into one practical, realistic function:

def log_event(event_name, *, level="INFO", **extra_fields):
    fields = ", ".join(f"{key}={value}" for key, value in extra_fields.items())
    print(f"[{level}] {event_name} — {fields}")

log_event("user_login", level="DEBUG", user_id=42, ip="192.168.1.1")
# [DEBUG] user_login — user_id=42, ip=192.168.1.1

log_event("server_start")
# [INFO] server_start —

This function requires event_name, makes level an optional keyword-only argument with a sensible default, and accepts any number of additional named fields through **extra_fields — a genuinely common shape for logging utilities, configuration builders, or anything else that needs to flexibly accept a core set of known fields alongside an open-ended set of extras.