Args, Kwargs & Parameters
Parameters and arguments
Section titled “Parameters and arguments”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.
Positional and keyword arguments
Section titled “Positional and keyword arguments”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)"
# Positionalconnect("localhost", 5432)
# Keyword — order does not matterconnect(port=5432, host="localhost")
# Mixed — positional first, then keywordconnect("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.
Variadic positional args: *args
Section titled “Variadic positional args: *args”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.5print(total()) # 0sum() on an empty iterable returns the int 0 unless you pass a start value, e.g. sum(amounts, 0.0).
Variadic keyword args: **kwargs
Section titled “Variadic keyword args: **kwargs”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 objectsThis 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.
Keyword-only parameters
Section titled “Keyword-only parameters”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}")
# Calling send_email("[email protected]", "Hi", "[email protected]") would raise TypeErrorPositional-only parameters: /
Section titled “Positional-only parameters: /”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 requiredprint(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 *.
Unpacking at call sites: * and **
Section titled “Unpacking at call sites: * 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}# 6Full parameter order
Section titled “Full parameter order”The order of parameter kinds in a definition must always be:
- Positional-only (
x,y, then/) - Positional-or-keyword (
x,y) *args(or bare*)- Keyword-only (
key=valueafter*) **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))Loading Python runtime (first run only)…
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 datetimeimport 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 * heighttry: area(width=3, height=4)except TypeError as e: print(type(e).__name__ + ":", e)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”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)"]
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Positional-only (/) | Frees the author to rename the parameter later without breaking callers | Callers cannot self-document a call with divide(numerator=10, denominator=4) |
Keyword-only (*) | Callers must name the argument — self-documenting, order-mistake-proof | An extra * in the signature to remember; positional-only callers get TypeError |
*args/**kwargs forwarding | A wrapper works with any signature the wrapped function has | inspect.signature on the wrapper alone shows (*args, **kwargs), hiding the real parameters (see Decorators for the functools.wraps fix) |
Unpacking (*/**) at a call site | Build the argument list/dict dynamically, e.g. from config or another function’s return value | A mismatch between the unpacked structure and the signature only fails at call time, not at definition time |
Mutable default (items=[]) / datetime.now() default | Looks 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 |
Common Misconceptions
Section titled “Common Misconceptions”- “Positional-only parameters are just a style preference — you can always pass them by name” — fact:
/makes it a realTypeErrorat the call site to pass those parameters by keyword, not a lint warning. - “
**kwargsbehaves like a mutable default — mutating it leaks into the next call” — fact:**kwargsis 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 thedefstatement executes, sowhenis 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. - “
*argsand**kwargsin a signature and*args/**kwargsat 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
TypeErrorabout 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.
Reading AI-generated code
Section titled “Reading AI-generated code”- 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)) whereconfig’s keys are not guaranteed to matchf’s parameter names? A typo’d or extra key raisesTypeError: f() got an unexpected keyword argument. - Is
*args/**kwargsused 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().