Skip to content

Args, Kwargs & Parameters

Python distinguishes between parameters (names in the function definition) and arguments (values passed at call time). Python gives you fine-grained control over how callers can supply those values.

By default, every parameter can be supplied positionally or by keyword name.

def connect(host: str, port: int, timeout: int = 30) -> str:
return f"{host}:{port} (timeout={timeout}s)"
# Positional
connect("localhost", 5432)
# Keyword — order does not matter
connect(port=5432, host="localhost")
# Mixed — positional first, then keyword
connect("localhost", port=5432, timeout=60)

timeout has a default value of 30, making it optional. Positional arguments must always come before keyword arguments at the call site.

Prefix a parameter with * to collect any number of positional arguments into a tuple.

def total(*amounts: float) -> float:
return sum(amounts)
print(total(10.0, 20.5, 5.0)) # 35.5
print(total()) # 0

sum() on an empty iterable returns the int 0 unless you pass a start value, e.g. sum(amounts, 0.0).

Prefix a parameter with ** to collect any number of keyword arguments into a dict.

def log_event(event: str, **metadata) -> None:
parts = [f"event={event}"]
for key, value in metadata.items():
parts.append(f"{key}={value}")
print(", ".join(parts))
log_event("login", user_id=42, ip="127.0.0.1")
# event=login, user_id=42, ip=127.0.0.1

**kwargs is a fresh dict on every call — it is never shared between calls, even mutating it inside the function has no effect on the next call:

def collect(**kwargs):
kwargs["seen"] = True
return kwargs
first = collect(a=1)
second = collect(b=2)
print(first) # {'a': 1, 'seen': True}
print(second) # {'b': 2, 'seen': True}
print(first is second) # False — two different dict objects

This is a different rule from the mutable default argument trap covered later in this lesson: a default value like items=[] is created once at definition time and shared, but **kwargs itself is built freshly by Python on every single call, regardless of defaults.

Any parameter listed after a bare * (or after *args) can only be supplied by keyword — never positionally. This makes APIs self-documenting and prevents argument order mistakes.

def send_email(to: str, subject: str, *, cc: str = "", bcc: str = "") -> None:
print(f"To: {to} | Subject: {subject} | CC: {cc} | BCC: {bcc}")
send_email("[email protected]", "Hello", cc="[email protected]")
# Calling send_email("[email protected]", "Hi", "[email protected]") would raise TypeError

Any parameter listed before a bare / can only be supplied positionally — never by keyword name. This is the mirror image of keyword-only *, and it exists for the same reason: it lets a library author rename an internal parameter later without breaking callers who never depended on that name.

def divide(numerator, denominator, /, *, round_to=2):
return round(numerator / denominator, round_to)
print(divide(10, 4)) # 2.5 — positional, as required
print(divide(10, 4, round_to=0)) # 2.0 — round_to is keyword-only, after *
divide(numerator=10, denominator=4)
# TypeError: divide() got some positional-only arguments passed as keyword arguments: 'numerator, denominator'

Everything before / is positional-only, everything after a bare * is keyword-only, and anything in between (with no / or * around it) can be passed either way — exactly the default behavior every parameter had before this lesson introduced / and *.

The same */** syntax that collects arguments inside a function definition also spreads a sequence or mapping into separate arguments at a call site:

def connect(host: str, port: int, timeout: int = 30) -> str:
return f"{host}:{port} (timeout={timeout}s)"
args = ("localhost", 5432)
kwargs = {"timeout": 60}
print(connect(*args, **kwargs))
# localhost:5432 (timeout=60s) — same as connect("localhost", 5432, timeout=60)

*args in a definition means “gather extra positional arguments into a tuple named args.” *args at a call site means “unpack this iterable into separate positional arguments.” The symbol is the same; whether you are defining or calling tells you which direction it goes.

The forwarding pattern: def wrapper(*a, **kw)

Section titled “The forwarding pattern: def wrapper(*a, **kw)”

Combining “gather” (in the signature) with “spread” (in the body) lets a wrapper function accept absolutely any call shape and forward it unchanged to another function — this is exactly how every decorator’s wrapper in the Decorators lesson stays compatible with whatever function it wraps:

def base(a, b, *, c=10):
return a + b + c
def wrapper(*a, **kw):
print("forwarding", a, kw)
return base(*a, **kw)
print(wrapper(1, 2, c=3))
# forwarding (1, 2) {'c': 3}
# 6

The order of parameter kinds in a definition must always be:

  1. Positional-only (x, y, then /)
  2. Positional-or-keyword (x, y)
  3. *args (or bare *)
  4. Keyword-only (key=value after *)
  5. **kwargs
def describe(
name: str,
*adjectives: str,
separator: str = ", ",
**extras
) -> str:
adj_str = separator.join(adjectives) if adjectives else "no adjectives"
extra_str = ", ".join(f"{k}={v}" for k, v in extras.items())
result = f"{name}: {adj_str}"
if extra_str:
result += f" ({extra_str})"
return result
print(describe("Python"))
print(describe("Python", "fast", "readable"))
print(describe("Python", "fast", "readable", separator=" | "))
print(describe("Python", "fast", separator=" + ", version="3.11", typed=True))

Depth demo: /, unpacking, fresh **kwargs, and the datetime.now() default trap

Section titled “Depth demo: /, unpacking, fresh **kwargs, and the datetime.now() default trap”

The playground below runs every depth topic above, including two real failures — calling a positional-only parameter by keyword, and a mutable-style trap with datetime.now() as a default value:

from datetime import datetime
import time
# --- positional-only parameters with / ---
def divide(numerator, denominator, /, *, round_to=2):
return round(numerator / denominator, round_to)
print("--- positional-only / ---")
print(divide(10, 4))
print(divide(10, 4, round_to=0))
try:
divide(numerator=10, denominator=4)
except TypeError as e:
print(type(e).__name__ + ":", e)
# --- unpacking at call sites: * and ** ---
print("\n--- unpacking at call sites ---")
def connect(host: str, port: int, timeout: int = 30) -> str:
return f"{host}:{port} (timeout={timeout}s)"
args = ("localhost", 5432)
kwargs = {"timeout": 60}
print(connect(*args, **kwargs))
# --- **kwargs is a fresh dict every call ---
print("\n--- **kwargs is a fresh dict each call ---")
def collect(**kwargs):
kwargs["seen"] = True
return kwargs
first = collect(a=1)
second = collect(b=2)
print("first:", first)
print("second:", second)
print("same dict object?", first is second)
# --- datetime.now() as a default is evaluated once ---
print("\n--- datetime.now() default trap ---")
def log_event_broken(message, when=datetime.now()):
return f"{when.isoformat()}: {message}"
first_call = log_event_broken("first")
time.sleep(0.05)
second_call = log_event_broken("second")
print("same timestamp both calls:", first_call.split(":")[0:2] == second_call.split(":")[0:2])
def log_event_fixed(message, when=None):
when = when if when is not None else datetime.now()
return f"{when.isoformat()}: {message}"
fixed_first = log_event_fixed("first")
time.sleep(0.05)
fixed_second = log_event_fixed("second")
print("fixed version differs:", fixed_first != fixed_second)
# --- forwarding pattern def wrapper(*a, **kw) ---
print("\n--- forwarding pattern ---")
def base(a, b, *, c=10):
return a + b + c
def wrapper(*a, **kw):
print("forwarding", a, kw)
return base(*a, **kw)
print(wrapper(1, 2, c=3))
# --- positional-only rejects the keyword form ---
print("\n--- positional-only rejects keyword form ---")
def area(width, height, /):
return width * height
try:
area(width=3, height=4)
except TypeError as e:
print(type(e).__name__ + ":", e)

Reading a signature left to right shows exactly which arguments can be positional, which can be either, and which must be named — the / and * are the two dividers:

flowchart LR
  A["positional-only\n(before /)"] --> B["positional-or-keyword\n(the default, no marker)"]
  B --> C["*args\n(extra positionals -> tuple)"]
  C --> D["keyword-only\n(after * or *args)"]
  D --> E["**kwargs\n(extra keywords -> dict)"]
Parameter order: positional-only -> positional-or-keyword -> *args -> keyword-only -> **kwargs
What you getBenefitCost
Positional-only (/)Frees the author to rename the parameter later without breaking callersCallers cannot self-document a call with divide(numerator=10, denominator=4)
Keyword-only (*)Callers must name the argument — self-documenting, order-mistake-proofAn extra * in the signature to remember; positional-only callers get TypeError
*args/**kwargs forwardingA wrapper works with any signature the wrapped function hasinspect.signature on the wrapper alone shows (*args, **kwargs), hiding the real parameters (see Decorators for the functools.wraps fix)
Unpacking (*/**) at a call siteBuild the argument list/dict dynamically, e.g. from config or another function’s return valueA mismatch between the unpacked structure and the signature only fails at call time, not at definition time
Mutable default (items=[]) / datetime.now() defaultLooks like “a fresh empty/current value every call”Evaluated once at definition time — every call that does not override it shares (or freezes) the same object
  • “Positional-only parameters are just a style preference — you can always pass them by name” — fact: / makes it a real TypeError at the call site to pass those parameters by keyword, not a lint warning.
  • “**kwargs behaves like a mutable default — mutating it leaks into the next call” — fact: **kwargs is a brand-new dict built by Python on every call; only an actual default value (items=[]) is created once and shared. Do not confuse the two.
  • “def f(when=datetime.now()) gives every call a fresh timestamp” — fact: datetime.now() runs exactly once, when the def statement executes, so when is the same frozen instant on every call that does not override it — the same class of bug as a mutable default, just with a non-obviously-mutable value.
  • “*args and **kwargs in a signature and *args/**kwargs at a call site do the same thing” — fact: in a signature they gather extra arguments into a tuple/dict; at a call site the identical syntax spreads a tuple/dict into separate arguments — opposite directions, same symbols.
  • “A TypeError about positional-only arguments means the function doesn’t accept that argument at all” — fact: it still accepts the value — only not by that keyword name; passing it positionally in the right slot works fine.
  • Does a default argument look like def f(x, when=datetime.now()) or any other function call as a default (not just []/{})? It only runs once, at definition time, exactly like a mutable default.
  • Does a wrapper function def wrapper(*a, **kw) actually forward every argument to the wrapped call (func(*a, **kw)), or does it drop or reorder some?
  • Is a parameter documented as “must be passed by name” but the signature has no * before it? Nothing enforces that contract — a caller can still pass it positionally.
  • Does code unpack a dict into keyword arguments (f(**config)) where config’s keys are not guaranteed to match f’s parameter names? A typo’d or extra key raises TypeError: f() got an unexpected keyword argument.
  • Is *args/**kwargs used in a signature where the function only ever expects a fixed, known set of parameters? Explicit named parameters (with //* where appropriate) document the contract better and let a type checker catch mismatches.

🐛 Spot the bug

from datetime import datetime
def create_ticket(title: str, opened_at: datetime = datetime.now()) -> dict:
return {"title": title, "opened_at": opened_at}
ticket_a = create_ticket("Server down")
# ... hours later, in the same long-running process ...
ticket_b = create_ticket("Disk full")
print(ticket_a["opened_at"] == ticket_b["opened_at"])
Show the answer

datetime.now() in the default value is evaluated once, when the def create_ticket(...) statement runs — not on every call. Every ticket created without an explicit opened_at gets the exact same frozen timestamp, no matter how much real time passes between calls in a long-running process (a web server, a worker process). The printed line is True, which is almost certainly not the intent for a field named opened_at. This is the same underlying bug class as the mutable-default-argument trap, just with a non-obviously-mutable default. The fix is the same None-sentinel pattern: def create_ticket(title, opened_at=None): opened_at = opened_at if opened_at is not None else datetime.now().

What type does *args collect arguments into?
A parameter defined after a bare * in a function signature is:
What does this print? def divide(numerator, denominator, /): return numerator / denominator try: print(divide(numerator=10, denominator=2)) except TypeError as e: print(type(e).__name__)
What does this print? def collect(**kwargs): kwargs['seen'] = True return kwargs a = collect(x=1) b = collect(y=2) print(a, b, a is b)
Why is def f(x, when=datetime.now()) a bug in a long-running process?
Why does def wrapper(*a, **kw): return base(*a, **kw) correctly forward any call shape base accepts?