What is the difference between positional and keyword arguments in Python?
Positional arguments are matched by their position in the call; keyword arguments are matched by name and can appear in any order. Python 3 also introduced positional-only (/) and keyword-only (*) separators to enforce calling conventions in function signatures.
How to think about it
The question sounds basic but branches into genuinely useful territory: the / and * separators library authors use to keep APIs future-proof, and the mutable-default trap that snags even experienced engineers.
The core is simple. Python matches each argument to a parameter one of two ways — by position (the order you wrote them) or by name (param=value). You can mix both in one call, with the single rule that positionals come before keywords.
A worked example
def train(model_name, n_epochs=10, lr=0.001, verbose=False):
return f"Training {model_name}: epochs={n_epochs}, lr={lr}, verbose={verbose}"
# All positional — order decides which parameter gets which value
print(train("LinearSVC", 20, 0.01, True))
# Mixed — named args may appear in any order and skip optional ones
print(train("RandomForest", lr=0.05, n_epochs=5))
# All keyword — the most readable form for a long signature
print(train(model_name="XGBoost", n_epochs=50, lr=0.1, verbose=True))
# / makes params before it positional-only; * makes params after it keyword-only
def strict(pos_only, /, normal, *, kw_only):
return f"pos={pos_only}, normal={normal}, kw={kw_only}"
print(strict(1, 2, kw_only=3)) # ok — pos_only positional, kw_only named
print(strict(1, normal=2, kw_only=3)) # ok — 'normal' may go either way
# strict(pos_only=1, ...) -> TypeError; strict(1, 2, 3) -> TypeError
Training LinearSVC: epochs=20, lr=0.01, verbose=True
Training RandomForest: epochs=5, lr=0.05, verbose=False
Training XGBoost: epochs=50, lr=0.1, verbose=True
pos=1, normal=2, kw=3
pos=1, normal=2, kw=3
Watch the second train call: it set lr and n_epochs by name, out of order, and left verbose at its default — exactly what keyword arguments buy you, readability and optionality. The strict signature then shows the two separators at work: pos_only must be positional, kw_only must be named, and normal is free to go either way.
Why the separators matter
/ says “everything before me cannot be passed by name.” That lets a library rename a positional parameter later without breaking callers — it’s why len([1,2,3]) works but len(obj=[1,2,3]) raises TypeError. * says “everything after me must be named,” forcing callers to be explicit — invaluable when a function takes several boolean flags that are easy to swap by accident.
The default-value sharp edge
Default values are evaluated once, when def runs — not on each call — which bites when the default is mutable:
# Bug: every call that omits lst shares the SAME list
def append_to(val, lst=[]):
lst.append(val)
return lst
# Fix: None sentinel, build the list inside
def append_to(val, lst=None):
if lst is None:
lst = []
lst.append(val)
return lst