Python's function call syntax supports two styles: positional arguments, matched by their order in the signature, and keyword arguments, matched by name. At the call site you can mix the two freely — all positional arguments come first, then keyword arguments in any order. Keyword arguments make calls self-documenting: connect(host="db", port=5432, timeout=5) beats connect("db", 5432, 5) because every value labels itself.
Default values are expressions that Python evaluates once, at def time, and reuses on every call where the parameter is omitted. That one-shot evaluation is the source of the single most famous Python gotcha: using a mutable default such as def fn(items=[]): creates one shared list that persists between calls. The fix is def fn(items=None): followed by if items is None: items = [] inside the body. Treat mutable defaults as a language trap, not a feature.
Python has two special parameter markers that make signatures more expressive. A bare * in the parameter list forces every parameter after it to be keyword-only: def connect(host, *, timeout=5): forbids connect("db", 10) and requires connect("db", timeout=10). A bare / (Python 3.8+) does the opposite: it forces every parameter before it to be positional-only. Together they let the author pick the right call style for each parameter.
The *args and **kwargs catch-alls collect the extra positional and keyword arguments respectively. *args becomes a tuple, **kwargs becomes a dict. They are most useful when you are writing a wrapper that forwards every argument to an underlying function: def log_call(func, *args, **kwargs): ... return func(*args, **kwargs). Do not use them for ordinary business logic — an explicit signature is always clearer.
Argument unpacking completes the picture. At the call site, func(*seq) expands a sequence into positional arguments and func(**mapping) expands a mapping into keyword arguments. This lets you build an argument list dynamically — from a config file, CLI parser, or JSON body — and hand it to a function in one step. * and ** in a call are the mirror image of *args / **kwargs in a definition.
The mutable-default trap
The rule to memorise is: defaults are evaluated at def-time and shared between calls. Immutable defaults (numbers, strings, None, tuples, frozensets) are safe because they cannot change. For anything mutable, default to None and replace it inside the function with a fresh object. Type checkers warn about this mistake; silencing the warning is almost always wrong.
Keyword-only and positional-only parameters
Keyword-only parameters (*) are perfect for configuration flags that should never be positional (strict=True, timeout=5). Positional-only parameters (/) are useful when the parameter name is an implementation detail you do not want to commit to in the public API — if you ever rename it, no caller has to change.
These are the mechanics of Python's flexible calling conventions.
| Tool | Purpose |
|---|---|
keyword=valuesyntax | Passes an argument by name at the call site. |
parameter=defaultsyntax | Gives a parameter a default, evaluated at def-time. |
*argsparameter | Collects extra positional arguments into a tuple. |
**kwargsparameter | Collects extra keyword arguments into a dict. |
*, kw_onlymarker | Every parameter after `*` is keyword-only. |
pos_only, /marker | Every parameter before `/` is positional-only. |
func(*seq, **mapping)call syntax | Unpacks a sequence and mapping into arguments. |
inspect.signature()function | Introspects a callable's parameters at runtime. |
Using Keyword Arguments and Default Values code example
The example defines a flexible greet() that uses safe defaults, keyword-only parameters, and unpacked kwargs.
# Lesson: Using Keyword Arguments and Default Values
# Goal: avoid the mutable-default trap and use keyword-only parameters.
from inspect import signature
def greet(
name: str,
*,
greeting: str = "Hello",
punctuation: str = "!",
decorations: list[str] | None = None, # safe: default is immutable None
) -> str:
'''Build a greeting string; extra decorations are wrapped with parens.'''
if decorations is None:
decorations = [] # fresh list per call, no shared state
prefix = " ".join(f"({d})" for d in decorations)
prefix = f"{prefix} " if prefix else ""
return f"{prefix}{greeting}, {name}{punctuation}"
def bad_default(items: list[str] = []) -> list[str]:
'''Intentionally wrong default; shown so you can spot it in real code.'''
items.append("seen")
return items
# --- main script ---------------------------------------------------------
print(greet("Ada"))
print(greet("Ben", greeting="Hi", punctuation="."))
print(greet("Cho", decorations=["vip", "new"]))
# Demonstrate the gotcha: each call adds to the SAME default list
print(bad_default()) # ['seen']
print(bad_default()) # ['seen', 'seen'] <- surprise!
print(bad_default([])) # ['seen'] (when you pass a list, no trap)
# Unpacking at the call site
config = {"greeting": "Hej", "punctuation": "?"}
print(greet("Dara", **config))
# Introspection
print(list(signature(greet).parameters))
Four things to notice:
1) `*` in greet() makes every keyword a self-documenting label.
2) `decorations=None` avoids the shared-list bug of `decorations=[]`.
3) `**config` unpacks a dict into keyword arguments.
4) inspect.signature() reads the parameter list for tooling.
Two focused snippets on defaults and unpacking.
# Example A: *args forwarding for a simple wrapper
def timed(func):
from time import perf_counter
def wrapper(*args, **kwargs):
start = perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {(perf_counter() - start) * 1000:.2f} ms")
return result
return wrapper
@timed
def square(n):
return n * n
square(12)
# Example B: keyword-only flag prevents bugs at call sites
def save(path: str, data: bytes, *, overwrite: bool = False) -> None:
# save("a.bin", data, True) # would be a TypeError: True is positional
if not overwrite:
print("would refuse to overwrite")
# ... actual IO omitted ...
Assertions that pin the intent of each parameter kind.
assert greet("Ada") == "Hello, Ada!"
assert greet("Ada", greeting="Hi", punctuation="?") == "Hi, Ada?"
# Calling with a positional keyword-only argument must fail:
try:
greet("Ada", "Hi") # "Hi" would try to fill greeting positionally
except TypeError:
pass
else:
raise AssertionError("greeting must be keyword-only")
# The classic mutable-default bug:
assert bad_default() == ["seen"]
assert bad_default() == ["seen", "seen"]
Running the script prints:
Hello, Ada!
Hi, Ben.
(vip) (new) Hello, Cho!
['seen']
['seen', 'seen']
['seen']
Hej, Dara?
['name', 'greeting', 'punctuation', 'decorations']