Python's variadic argument syntax — *args and **kwargs — gives functions the ability to accept any number of arguments without fixing the signature in advance. This article goes well past the surface-level explanation and covers how these mechanisms work, where they interact with keyword-only parameters and the / positional-only marker, how CPython handles them at the C level, and how the modern type system has evolved to annotate them precisely.
Fixed-arity functions are clean and explicit — but they break the moment you need to forward arguments to another function, wrap a callback in a decorator, or build an API that lets callers pass optional configuration. That's the problem variadic arguments solve. The term "variadic" is a modern computer-science coinage built on the Latin root varius (diverse, varying), and the concept predates Python by decades: C's printf, for example, accepts a variable number of arguments through stdarg.h using va_list and related macros. Python surfaces the same idea with syntax that is both simpler and safer — no manual stack walking, no format-string parsing, no undefined behaviour on type mismatches. If you are new to defining functions in Python, start there before continuing here.
The mechanics of *args
When you prefix a parameter name with a single asterisk in a function definition, Python collects all remaining positional arguments passed at the call site and bundles them into a tuple bound to that parameter. The name args is entirely conventional — the asterisk is what matters.
def total(*amounts):
return sum(amounts)
print(total(10, 20, 30)) # 60
print(total(5)) # 5
print(total()) # 0 — empty tuple, sum() returns 0
Because amounts is a tuple, it is immutable and iterable. You can index it, slice it, measure its length, pass it to any function that accepts an iterable, but you cannot mutate it in place. The immutability is deliberate — Python's official documentation for function definitions states that the starred parameter receives "a tuple containing the positional arguments beyond the formal parameter list." (Python Docs, Calls — Language Reference)
def inspect_args(*values):
print(type(values)) # <class 'tuple'>
print(len(values))
print(values[0] if values else "empty")
inspect_args("a", "b", "c")
# <class 'tuple'>
# 3
# a
Named parameters declared before *args consume positional arguments left to right before the variadic collection begins. Any positional argument not claimed by an earlier named parameter ends up in the tuple.
def report(title, *entries):
print(f"--- {title} ---")
for entry in entries:
print(entry)
report("Errors", "404 Not Found", "500 Internal Server Error", "503 Unavailable")
# --- Errors ---
# 404 Not Found
# 500 Internal Server Error
# 503 Unavailable
The name args is a convention, not a keyword. def f(*numbers), def f(*items), and def f(*rest) are all valid. Use a descriptive name when the context makes the intent clearer — *args works best in generic wrappers where the content is truly unknown.
What happens when named parameters before *args have default values?
You can give named parameters that appear before *args default values, but the interaction is a frequent source of confusion. Once a caller starts passing positional arguments, Python fills named parameters left to right before any remainder reaches *args. The default is only used if the caller passes nothing at all for that position.
def log(level="INFO", *messages):
for msg in messages:
print(f"[{level}] {msg}")
log("WARNING", "disk full", "retry failed")
# [WARNING] disk full
# [WARNING] retry failed
log("disk full", "retry failed")
# [disk full] retry failed <-- "disk full" consumed the `level` slot
log(*["disk full", "retry failed"])
# same result — unpacking doesn't change left-to-right consumption
In the second call, the string "disk full" is consumed by level because it is the first positional argument — the default is never used. This is the classic symptom of placing a defaulted parameter before *args: the default becomes unreachable via positional calling once the caller passes anything at all. The idiomatic fix is to move optional settings after *args as keyword-only parameters, where *args can no longer consume them:
def log(*messages, level="INFO"): # level is now keyword-only
for msg in messages:
print(f"[{level}] {msg}")
log("disk full", "retry failed")
# [INFO] disk full
# [INFO] retry failed
log("disk full", "retry failed", level="WARNING")
# [WARNING] disk full
# [WARNING] retry failed
If you see a function where a defaulted parameter sits before *args, treat the default as cosmetic — any positional call will override it. Putting optional configuration before *args is almost always a design mistake; move it after, where it becomes genuinely keyword-only.
*args parameter?list would make sense intuitively, but Python specifically packs *args into a tuple — an immutable sequence. This is intentional: it signals that the collected arguments are a fixed snapshot of what the caller passed, not a container you should be modifying inside the function. You can index it, iterate it, and measure its length, but you cannot .append() to it. If you need a mutable version, call list(args) inside the function body.
sorted(args) returns a new list without touching the original tuple.
*args is eagerly evaluated at the call site: all positional arguments are materialized into a tuple before the function body runs. There is no deferred execution here. Generators are useful when you want to process a potentially infinite stream without holding everything in memory, but that's not what variadic argument collection does — it captures everything the caller passed at that moment.
The mechanics of **kwargs
The double-asterisk prefix tells Python to collect all keyword arguments that do not match any explicitly declared parameter and store them in a dict. The keys are the argument names as strings; the values are whatever was passed.
def configure(**options):
print(type(options)) # <class 'dict'>
for key, value in options.items():
print(f"{key} = {value}")
configure(timeout=30, retries=3, debug=True)
# timeout = 30
# retries = 3
# debug = True
Because options is a standard Python dict, every dictionary method is available: .get(), .items(), .keys(), .update(), and so on. Unlike *args, a **kwargs dict is mutable — you can add or remove keys inside the function body.
Does mutating **kwargs affect the caller's dictionary?
No. When a caller unpacks a dictionary with ** at the call site, Python creates a fresh dict for the function's **kwargs parameter. Mutations inside the function — adding keys, deleting entries, replacing values — do not propagate back to whatever the caller passed.
def process(**kwargs):
kwargs["injected"] = True # mutate the local copy
kwargs.pop("secret", None)
print("inside:", kwargs)
config = {"host": "localhost", "secret": "abc123"}
process(**config)
# inside: {'host': 'localhost', 'injected': True}
print("caller:", config)
# caller: {'host': 'localhost', 'secret': 'abc123'} <-- unchanged
The isolation is a copy of the mapping, not a deep copy of the values. If a value is itself a mutable object — a list or a nested dict — mutating that object inside the function will still be visible to the caller through the shared reference. The shield only protects the key-value structure of kwargs itself.
def append_item(**kwargs):
kwargs["items"].append("extra") # mutates the shared list object
data = {"items": [1, 2, 3]}
append_item(**data)
print(data["items"]) # [1, 2, 3, 'extra'] <-- caller sees the change
def build_request(url, **kwargs):
headers = kwargs.get("headers", {})
timeout = kwargs.get("timeout", 10)
method = kwargs.get("method", "GET").upper()
print(f"{method} {url} timeout={timeout} extra={kwargs}")
build_request("https://api.example.com/data", method="post", timeout=5, auth="bearer xyz")
# POST https://api.example.com/data timeout=5 extra={'method': 'post', 'timeout': 5, 'auth': 'bearer xyz'}
Use kwargs.get("key", default) rather than kwargs["key"] when a keyword argument is optional. A direct index lookup raises KeyError if the caller omitted the argument; .get() returns your default silently.
config dict after the call — but it prints something unexpected. What is the bug?{'host': 'localhost', 'port': 5432}, but after the call they see something different.**config passes the dictionary by reference, but that is not how Python works. When you call apply_defaults(**config), Python creates a brand-new dict for kwargs. The mutations on lines 2 and 3 — adding "debug" and "timeout" — happen to that fresh copy, not to config. The output is {'host': 'localhost', 'port': 5432} exactly as expected. So there actually is no bug in terms of the caller's data being corrupted. The real issue is that all the work done inside the function is thrown away — the enriched dict is never returned or stored. If the intent was to return an augmented config, the function should return kwargs.
** at a call site, Python allocates a fresh dict for the function's **kwargs parameter. Any mutations inside the function — adding keys, deleting entries — happen to that copy. config is never touched, so print(config) outputs {'host': 'localhost', 'port': 5432}. The practical bug hiding here is different: all that work setting "debug" and "timeout" is silently discarded because the enriched dict is never returned. If the goal was to produce an augmented config, the function should end with return kwargs and the caller should capture the result: full_config = apply_defaults(**config).
**kwargs parameter is an ordinary mutable Python dict — you can add keys, delete keys, and reassign values freely. No TypeError is raised. The function runs without error; it just does nothing useful from the caller's perspective because the enriched dict is local to the function and never returned. The isolation guarantee cuts both ways: the caller's data is protected, but the function's changes are also invisible unless you explicitly return the modified dict.
Argument ordering rules and keyword-only parameters
Python enforces a strict ordering for parameters in a function signature. Violating the order produces a SyntaxError at parse time, not a runtime error. The full legal order is:
| Position | Parameter type | Example syntax |
|---|---|---|
| 1 | Positional-only (PEP 570, Python 3.8+) | def f(x, y, /) |
| 2 | Positional-or-keyword (standard) | def f(a, b) |
| 3 | Variadic positional | def f(*args) |
| 4 | Keyword-only (PEP 3102) | def f(*args, flag=False) |
| 5 | Variadic keyword | def f(**kwargs) |
The interaction between *args and keyword-only parameters is one of the more subtle corners of the language. PEP 3102, authored by Talin and accepted for Python 3.0, introduced keyword-only parameters — parameters placed after the *args collector that can only be satisfied by a named argument at the call site. According to PEP 3102, the motivation was functions that accept a variable number of positional arguments while also needing named configuration options that callers should always pass explicitly. (PEP 3102, peps.python.org)
def sort_words(*words, case_sensitive=False):
key = None if case_sensitive else str.lower
return sorted(words, key=key)
print(sort_words("Banana", "apple", "Cherry"))
# ['apple', 'Banana', 'Cherry']
print(sort_words("Banana", "apple", "Cherry", case_sensitive=True))
# ['Banana', 'Cherry', 'apple']
You can force keyword-only behavior without collecting any positional extras by using a bare * as a separator — no name, just the asterisk:
def connect(host, port, *, ssl=True, timeout=30):
print(f"{'SSL' if ssl else 'plain'} connection to {host}:{port} (timeout={timeout}s)")
connect("db.internal", 5432)
# SSL connection to db.internal:5432 (timeout=30s)
connect("db.internal", 5432, ssl=False, timeout=10)
# plain connection to db.internal:5432 (timeout=10s)
# connect("db.internal", 5432, False) # TypeError — ssl cannot be positional
The complete "maximal" signature uses all five parameter types together. DigitalOcean's Python tutorial confirms that Python enforces this order strictly — any deviation raises a SyntaxError at parse time rather than at runtime. (DigitalOcean)
def full_signature(pos_only, /, standard, *args, kw_only, **kwargs):
print(f"pos_only = {pos_only}")
print(f"standard = {standard}")
print(f"args = {args}")
print(f"kw_only = {kw_only}")
print(f"kwargs = {kwargs}")
full_signature(1, 2, 3, 4, kw_only="required", extra="bonus")
# pos_only = 1
# standard = 2
# args = (3, 4)
# kw_only = required
# kwargs = {'extra': 'bonus'}
Unpacking at the call site
The * and ** operators are not limited to function definitions — they also appear at call sites where they do the reverse: expand an iterable or mapping into individual arguments. This is called argument unpacking.
def greet(first, last, title=""):
prefix = f"{title} " if title else ""
print(f"Hello, {prefix}{first} {last}")
names = ("Ada", "Lovelace")
greet(*names) # Hello, Ada Lovelace
credentials = {"first": "Grace", "last": "Hopper", "title": "Rear Admiral"}
greet(**credentials) # Hello, Rear Admiral Grace Hopper
Multiple unpackings can appear in a single call, and they can be mixed with literal arguments. Python merges them left-to-right for positional arguments and raises TypeError if any keyword is supplied twice.
defaults = {"timeout": 10, "retries": 3}
overrides = {"timeout": 30} # override just one key
merged = {**defaults, **overrides} # {'timeout': 30, 'retries': 3}
print(merged)
The double-star merge pattern above is one of the cleanest ways to combine dictionaries in Python and avoids dict.update()'s mutation side-effect.
How CPython handles variadic arguments internally
Understanding what happens at the CPython level explains why variadic functions carry a small but real performance cost compared to fixed-signature functions. At the C layer, when CPython calls a Python function, positional arguments are passed as a PyTupleObject and keyword arguments as a PyDictObject. The interpreter's argument-parsing machinery (centered on PyArg_ParseTupleAndKeywords for C-extension functions, and the ceval.c evaluation loop for pure-Python ones) maps these into the function's local namespace.
For a function with *args, CPython allocates a new tuple on every call to hold the extra positional arguments. For **kwargs, it allocates a new dict. The Python Extension Patterns documentation explains that at the C level a Python function receives its positional arguments as a PyTupleObject and its keyword arguments as a PyDictObject — the same two structures the interpreter builds for variadic parameters. (Python Extension Patterns)
Each call to a function with *args or **kwargs allocates a new tuple or dict. In tight inner loops or high-frequency hot paths, this allocation overhead can matter. If the argument set is fixed and known at design time, prefer explicit parameters — the allocation cost disappears entirely when CPython does not need to build the variadic containers.
PEP 570 (positional-only parameters, Python 3.8) adds a related optimization angle: CPython's METH_FASTCALL calling convention is specialized for positional-only functions. PEP 570 notes that this specialization eliminates the overhead of handling empty keyword dictionaries on every call — a cost that the variadic machinery cannot avoid. (PEP 570, peps.python.org) This is an area where knowing the difference between *args, keyword-only, and positional-only parameters has real runtime consequences.
Type annotations for variadic Python functions
Typing variadic functions accurately has evolved considerably since PEP 484 introduced type hints. Several PEPs contributed to where things stand today, each addressing a different limitation.
PEP 484 (Python 3.5) — the baseline. The original type hint PEP established that annotating *args and **kwargs applies the type to each element or value, not to the container as a whole. So *args: int means every value in the tuple is an int, and **kwargs: str means every value in the dict is a str.
def add_all(*args: int) -> int:
return sum(args)
def format_fields(**kwargs: str) -> str:
return ", ".join(f"{k}={v}" for k, v in kwargs.items())
PEP 612 (Python 3.10) — ParamSpec. The original typing approach couldn't express the signature of decorators that forward arguments unchanged. PEP 612, authored by Mark Mendoza and sponsored by Guido van Rossum, introduced ParamSpec, a specialized type variable that captures the full parameter specification of a callable. The PEP's stated goal was preserving full type information when one callable's parameter types are forwarded through another — the exact problem decorators create. If you want to understand how ParamSpec fits alongside TypeVar and Generic in Python, that article covers the broader picture. (PEP 612, peps.python.org)
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def retry(func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(3):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == 2:
raise
raise RuntimeError("unreachable")
return wrapper
@retry
def fetch(url: str, timeout: int = 10) -> bytes:
... # actual HTTP call
With ParamSpec, a static type checker knows that fetch after decoration still expects (url: str, timeout: int = 10) — the decorator does not erase the signature. According to the Python typing documentation, P.args and P.kwargs are purpose-built attributes: the former annotates only the *args position of a wrapper (carrying the captured positional argument types), and the latter annotates only **kwargs (carrying the captured keyword argument types). Neither can be used outside that specific context. (Python typing docs, docs.python.org)
PEP 692 (Python 3.12) — TypedDict for **kwargs. Before Python 3.12, annotating **kwargs where different keys have different types required accepting the loss of precision or writing overloads. PEP 692, authored by Franek Magiera and sponsored by Jelle Zijlstra, solved this by allowing a TypedDict to be passed to Unpack and used as the annotation for **kwargs. The Python 3.12 release notes explain that the earlier PEP 484 approach forced all **kwargs values to share a single type, whereas PEP 692 enables per-key type precision through typed dictionaries. (Python 3.12 What's New, docs.python.org)
# Python 3.12+ — precise per-key types for **kwargs
from typing import TypedDict, Unpack
class MovieParams(TypedDict):
title: str
year: int
rating: float
def create_movie(**kwargs: Unpack[MovieParams]) -> str:
return f"{kwargs['title']} ({kwargs['year']}) — {kwargs['rating']:.1f}"
print(create_movie(title="Metropolis", year=1927, rating=9.1))
# Metropolis (1927) — 9.1
PEP 646 (Python 3.11) — TypeVarTuple. For libraries like NumPy and TensorFlow where the shape of an array is as important as its type, PEP 646 introduced TypeVarTuple — a variadic type variable enabling parameterization with an arbitrary number of types. Authored by Mark Mendoza, Matthew Rahtz, Pradeep Kumar Srinivasan, and Vincent Siles, the PEP's primary motivation was enabling array-shape parameterization in numerical computing libraries so that static type checkers could catch shape-related bugs — something no prior typing construct could express. (PEP 646, peps.python.org)
Real-world Python patterns
Knowing the mechanics is one thing. Recognizing the patterns that appear repeatedly in serious Python code is another.
Introspecting variadic signatures at runtime
One area rarely covered in tutorials is how Python's own standard library lets you read a function's variadic signature at runtime — useful for documentation generators, framework dispatch logic, and argument validators. The inspect module is the entry point.
The inspect.Parameter.kind attribute classifies each parameter with one of five constants from the inspect.Parameter class. The two you care about for variadic functions are VAR_POSITIONAL (a *args parameter) and VAR_KEYWORD (a **kwargs parameter).
import inspect
def full_signature(pos_only, /, standard, *args, kw_only, **kwargs):
pass
sig = inspect.signature(full_signature)
for name, param in sig.parameters.items():
print(f"{name:12} kind={param.kind.name}")
# pos_only kind=POSITIONAL_ONLY
# standard kind=POSITIONAL_OR_KEYWORD
# args kind=VAR_POSITIONAL
# kw_only kind=KEYWORD_ONLY
# kwargs kind=VAR_KEYWORD
This lets you write generic utilities that adapt their behaviour based on whether the callable they're wrapping actually accepts extra positional or keyword arguments. The Python documentation describes inspect.signature() as the standard introspection API for callable objects, returning a Signature object whose parameters mapping exposes each parameter's kind. (Python Docs, inspect — Inspect live objects)
A practical use case: a validation decorator that refuses to wrap functions whose signatures can't absorb extra arguments (because wrapping them with *args, **kwargs silently swallows bad calls).
import inspect
import functools
def strict_passthrough(func):
"""Raise at decoration time if func lacks *args or **kwargs."""
sig = inspect.signature(func)
kinds = {p.kind for p in sig.parameters.values()}
if inspect.Parameter.VAR_POSITIONAL not in kinds:
raise TypeError(f"{func.__name__} must accept *args to use strict_passthrough")
if inspect.Parameter.VAR_KEYWORD not in kinds:
raise TypeError(f"{func.__name__} must accept **kwargs to use strict_passthrough")
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@strict_passthrough
def valid_handler(*args, **kwargs):
print(args, kwargs)
# @strict_passthrough
# def bad_handler(x, y): # would raise TypeError at decoration time
# pass
The combination of inspect.signature() with Parameter.kind comparisons is how many major frameworks — including FastAPI's dependency injection system and pytest's fixture mechanism — discover at import time which arguments a callable can receive.
Problem 1 — timeout is silently overridable. Because
kwargs["timeout"] = 30 runs before the call, a caller who also passes timeout=5 via **kwargs will have their value overwritten without warning. The idiomatic fix is to use kwargs.setdefault("timeout", 30), which sets the value only when the caller did not provide one. Alternatively, promote timeout to a keyword-only parameter with a default: def api_call(url, *args, timeout=30, **kwargs). Named parameters take priority over **kwargs, so the caller can override it cleanly and explicitly.
Problem 2 — retry swallows every exception. A bare
except Exception: pass catches ConnectionError, Timeout, and ValueError alike. Non-transient errors — bad URLs, auth failures, malformed responses — should not be retried; retrying them wastes time and can mask bugs. The fix is to catch a specific exception class (except requests.exceptions.Timeout, for example) or re-raise after the final attempt: if i == retries - 1: raise.
Problem 3 — functools.wraps is missing. Without
@functools.wraps(func) on the wrapper, fetch.__name__ becomes "wrapper", fetch.__doc__ is erased, and inspect.signature(fetch) returns the wrapper's signature rather than the original. Logging systems, documentation generators, and type checkers all see the wrapper instead of the intended function. One line — import functools and @functools.wraps(func) above def wrapper — restores full transparency.
retries as a named parameter is correct design, not a flaw — it gives the decorator a meaningful default without consuming positional arguments. kwargs["timeout"] = 30 does not mutate the caller's original dict; **kwargs unpacking always creates a fresh copy, so the caller's data is safe. Type hints are a quality-of-life improvement but are not required for correctness. The real problems are: the timeout is silently overridable (use kwargs.setdefault or a keyword-only param instead), the bare except swallows non-transient failures, and functools.wraps is missing from the wrapper.
requests.request for method flexibility is a valid improvement, but it is an enhancement, not a design flaw in the code as written — the task description only mentions GET calls. And timeout as a keyword-only parameter is indeed a cleaner design (the correct answer covers this), but the way this option frames it misses why: the real problem is that the current implementation lets the caller unknowingly override the enforced timeout, and the fix is kwargs.setdefault or a named keyword-only param. The functools.wraps diagnosis is correct. The third problem this option misses entirely is the bare except Exception: pass, which silently swallows non-transient errors and makes bugs invisible.
Common mistakes and their error messages
Knowing what can go wrong — and why Python's error messages read the way they do — closes the gap between understanding the mechanics and being able to debug them quickly.
Passing a list where unpacking is needed. A common mistake is passing a list directly to a function that uses *args, expecting it to be spread across the parameters.
def add(*numbers):
return sum(numbers)
values = [1, 2, 3]
# Wrong — the list arrives as a single element inside the tuple
add(values) # sum(([1, 2, 3],)) — TypeError: unsupported operand type
# Correct — unpack at the call site
add(*values) # 6
Duplicate keyword argument. If you unpack a dict at the call site and also pass the same key as an explicit keyword argument, Python raises a TypeError immediately.
def connect(host, port):
pass
params = {"host": "localhost", "port": 5432}
# Raises: TypeError: connect() got multiple values for keyword argument 'host'
connect(**params, host="remotehost")
Keyword-only argument passed positionally. Once parameters appear after *args or a bare *, the interpreter will not fill them from positional arguments. The error message names the parameter explicitly.
def connect(host, port, *, ssl=True):
pass
# Raises: TypeError: connect() takes 2 positional arguments but 3 were given
connect("localhost", 5432, False)
Recognizing these three error shapes — wrong element count, duplicate keyword, keyword argument passed positionally — covers the majority of runtime mistakes with variadic argument code.
log("disk full", "retry failed") print on the first line of output?"INFO" is only used when the caller passes no positional argument for that slot. The moment you pass any positional argument, Python fills named parameters left-to-right before *args collects the remainder. So "disk full" is consumed by level, and "retry failed" is the only item in messages. The output is [disk full] retry failed. This is exactly why the idiomatic fix is to move optional settings after *args as keyword-only parameters — only then does the default become genuinely unreachable via positional calling.
*args. Since level is the first named parameter and "disk full" is the first positional argument, they bind together. The default "INFO" is ignored entirely. Only "retry failed" reaches *messages, producing one line: [disk full] retry failed. The fix is to place level after *messages so it becomes keyword-only: def log(*messages, level="INFO"). Now no positional argument can ever reach level, and the default is always available unless the caller explicitly overrides it.
*args. There is no SyntaxError. The issue is not syntactic — it is semantic. The default becomes effectively unreachable via positional calling because any positional argument will consume that named slot before *args ever collects extras. Python does issue a warning in some linters and style guides about this pattern, but the interpreter itself parses and runs it without complaint. The correct way to make an optional setting genuinely optional is to declare it after *args as a keyword-only parameter.
*args must always appear before **kwargs in a function signature. Writing (**kwargs, *args) reverses this order, and Python emits a SyntaxError: invalid syntax the moment it reads the definition — before any function call can be attempted. Fix the order: def process(*args, **kwargs).
*args, keyword-only, **kwargs. Placing **kwargs before *args violates this grammar and produces SyntaxError: invalid syntax at parse time — the file will not even begin executing. The fix is straightforward: swap the order to def process(*args, **kwargs). This is one of the few Python mistakes you genuinely cannot make at runtime; the parser catches it first.
**kwargs must always come last in a function signature. Python's parser raises SyntaxError: invalid syntax the moment it encounters (**kwargs, *args) — neither ordering produces the same behavior, because only one ordering is grammatically valid. The rule exists because once **kwargs has absorbed all remaining keyword arguments there is no coherent way for *args to follow it. Fix: def process(*args, **kwargs).
Can you use *args and **kwargs with lambda?
Lambda expressions follow the same parameter rules as def functions, including support for *args, **kwargs, and keyword-only parameters. The asterisk and double-asterisk prefixes work identically — the only constraint is that a lambda body must be a single expression, not a block of statements.
adder = lambda *args: sum(args)
print(adder(1, 2, 3, 4)) # 10
print(adder()) # 0
tagger = lambda **kwargs: ", ".join(f"{k}={v}" for k, v in kwargs.items())
print(tagger(env="prod", region="us-east"))
# env=prod, region=us-east
flexible = lambda *args, **kwargs: (args, kwargs)
print(flexible(1, 2, flag=True))
# ((1, 2), {'flag': True})
The practical value is limited — a lambda with *args or **kwargs usually signals that a named def would be clearer. The idiom does appear legitimately in sorting keys and simple callback wrappers, but if the logic inside grows beyond a single expression, convert it to a def.
One area where the lambda *args form genuinely earns its place is as a null-op placeholder in testing or configuration — a callable that accepts anything and returns a fixed value:
# A no-op handler that accepts any arguments and does nothing
noop = lambda *args, **kwargs: None
# Used in test doubles or default callbacks
def run_pipeline(data, on_complete=None):
result = process(data)
if on_complete:
on_complete(result, status="ok", elapsed=0.12)
run_pipeline(data, on_complete=noop)
What about def f(*args) versus def f(args)? They are fundamentally different. def f(*args) collects an arbitrary number of individual positional arguments into a tuple — the caller writes f(1, 2, 3). def f(args) is a regular single-parameter function — the caller must pass one object, typically a list or tuple explicitly: f([1, 2, 3]). With *args, the packing happens automatically; with a plain args parameter, the caller is responsible for wrapping the collection before the call.
def total(args): return sum(args) and calls it as total(1, 2, 3). What happens?args as a magic parameter name. Without the * prefix, args is just an ordinary single-parameter variable — the function signature accepts exactly one positional argument. Calling total(1, 2, 3) passes three positional arguments where only one is expected, producing TypeError: total() takes 1 positional argument but 3 were given. The asterisk is what instructs Python to collect multiple arguments; the name alone does nothing special.
* prefix, args is an ordinary parameter that expects exactly one argument. Calling total(1, 2, 3) passes three positional arguments where the signature allows only one, so Python immediately raises TypeError: total() takes 1 positional argument but 3 were given. The asterisk — not the name — is what enables variadic collection. To fix it, either add the asterisk (def total(*args)) so the function packs the three integers into a tuple automatically, or change the call to pass a single iterable: total([1, 2, 3]).
total([1, 2, 3]) works — the list is passed as a single argument to args, and sum([1, 2, 3]) returns 6. And you are right that total(1, 2, 3) raises a TypeError. But the reason is not just about the caller choosing correctly — it is that def total(args) defines a fixed one-argument function. Without the * prefix, the name args has no special meaning. To accept three separate positional integers without the caller wrapping them, the definition must be def total(*args).
Forwarding to super(). When subclassing a class from a framework — Django's ListView, SQLAlchemy's Base, any class you don't control — *args, **kwargs lets you forward arguments to the parent without duplicating every parameter name. The pattern is idiomatic enough that its presence is expected in well-written subclasses.
from datetime import datetime, timezone
class TimestampedModel(BaseModel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.created_at = datetime.now(timezone.utc)
Configuration merging. A common pattern in library code is to define a dictionary of defaults and allow callers to override any subset. The double-star merge idiom makes this clean and non-destructive.
DEFAULTS = {"host": "localhost", "port": 5432, "ssl": True, "pool_size": 10}
def get_connection(**overrides):
config = {**DEFAULTS, **overrides} # overrides win
print(config)
get_connection(host="prod-db.example.com", pool_size=20)
# {'host': 'prod-db.example.com', 'port': 5432, 'ssl': True, 'pool_size': 20}
Transparent logging and timing decorators. Decorators that measure execution time or log calls need to pass all arguments through unchanged. Without *args, **kwargs, you'd have to write a separate decorator for every function signature.
import time
import functools
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} completed in {elapsed:.4f}s")
return result
return wrapper
@timed
def load_data(path: str, encoding: str = "utf-8") -> list:
... # read and parse file
Note the use of @functools.wraps(func) — without it, the wrapper's __name__, __doc__, and __annotations__ would replace the wrapped function's metadata. This is a common oversight when writing Python decorators that use variadic arguments. For a full treatment of how functools.wraps works inside a decorator, including edge cases with class-based decorators and stacked wrappers, that article covers the complete picture.
Building callable interfaces from config data. When you have a dictionary of settings from a config file or environment variables, ** unpacking at the call site lets you pass them directly to a constructor or factory function without writing out each parameter.
import json
with open("db_config.json") as f:
params = json.load(f)
# params = {"host": "...", "port": 5432, "user": "app", "password": "..."}
conn = DatabaseConnection(**params) # maps keys to parameter names directly
SyntaxError.How to use *args and **kwargs
The following six steps cover everything you need to go from a basic variadic function to one that is fully typed and introspectable at runtime.
-
Define a function with *args. Prefix a parameter name with a single asterisk in the function definition. Python collects all extra positional arguments into an immutable tuple bound to that parameter.
def total(*amounts): return sum(amounts) print(total(10, 20, 30)) # 60 -
Define a function with **kwargs. Prefix a parameter name with a double asterisk to collect all extra keyword arguments into a mutable dictionary. Keys are strings; values are whatever the caller passed.
def configure(**options): print(options) configure(debug=True, timeout=30) # {'debug': True, 'timeout': 30} -
Combine *args and **kwargs in one signature. Both collectors can coexist in a single function. Follow the required ordering: positional-only params, standard params,
*args, keyword-only params, then**kwargs.def func(first, *args, label="default", **kwargs): print(first, args, label, kwargs) -
Use keyword-only parameters after *args. Any parameter declared after
*args(or a bare*separator) can only be satisfied by a named argument at the call site, never by a positional one. This avoids accidental positional consumption.def log(*messages, level="INFO"): for msg in messages: print(f"[{level}] {msg}") log("started", "connected", level="DEBUG") -
Unpack iterables and dicts at the call site. Use
*iterableto spread a list or tuple into positional arguments, and**dictto spread a dictionary into keyword arguments. These are the inverse of collection — one packs, one spreads.def connect(host, port): print(f"Connecting to {host}:{port}") params = {"host": "localhost", "port": 5432} connect(**params) # same as connect(host="localhost", port=5432) -
Add precise type annotations with ParamSpec or TypedDict+Unpack. For decorators that forward arguments unchanged, use
ParamSpec(Python 3.10+). For**kwargswith per-key types, useTypedDictwithUnpack(Python 3.12+). Both are in the standardtypingmodule and supported by mypy and pyright.from typing import ParamSpec, Callable, TypeVar P = ParamSpec("P") R = TypeVar("R") def logged(func: Callable[P, R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper
Frequently asked questions
- Can I have two
*argsparameters in one function? No. Python allows at most one variadic positional collector (
*args) and at most one variadic keyword collector (**kwargs) per function definition. Attempting to define two will produce aSyntaxError: duplicate * argumentat parse time.- Does
**kwargspreserve insertion order? Yes, since Python 3.7. The
dictbuilt-in became guaranteed to maintain insertion order as part of the language specification (CPython had implemented this behavior since 3.6 as an implementation detail). Keyword arguments are inserted in the order they appear at the call site, so iterating overkwargs.items()gives you that same order.- What is the difference between
*argsin a definition and*at a call site? In a function definition,
*argscollects extra positional arguments into a tuple. At a call site,*iterableunpacks an iterable into individual positional arguments. They are inverse operations: one packs, one spreads. A bare*in a definition (no name) acts as a separator that forces everything to its right to be keyword-only, without collecting anything.- Can I use type hints with
*argsand**kwargsin older Python versions? Basic annotations (
*args: int,**kwargs: str) have been available since PEP 484 (Python 3.5).ParamSpecfrom PEP 612 requires Python 3.10 or thetyping_extensionsbackport.TypedDictwithUnpackfor per-key**kwargstyping (PEP 692) requires Python 3.12 ortyping_extensions >= 4.4.- Is
*argsslow? There is a real but small cost: CPython allocates a new tuple on every call to hold the extra positional arguments. In programs dominated by millions of tight-loop calls to the same function, this allocation overhead can appear in profiling output. For the majority of application code — decorators, class constructors, API handlers, test fixtures — the overhead is negligible and the design flexibility is worth it.
- What happens if I pass a keyword argument that matches a named parameter when
**kwargsis also defined? Named parameters take priority. If a function defines both an explicit parameter called
timeoutand a**kwargscollector, any call usingtimeout=as a keyword argument will bind to the named parameter — it will not appear insidekwargs. Only keyword arguments that have no matching named parameter end up in the**kwargsdict.- Can
**kwargskeys be non-strings? No. Python enforces at the language level that all keyword argument names must be strings — whether passed explicitly or via
**unpacking. A regulardictcan hold non-string keys, but attempting to unpack one with**raises aTypeError: keywords must be stringsat runtime. If you need to pass a dict with integer or mixed keys into a function, pass it as a single positional argument rather than unpacking it.- Can I use
*argsand**kwargsin a lambda? Yes. Lambda expressions follow the same parameter rules as
deffunctions.lambda *args: sum(args),lambda **kwargs: kwargs, andlambda *args, **kwargs: (args, kwargs)are all valid. The only restriction specific to lambdas is that the body must be a single expression — not a multi-statement block. In practice, a lambda complex enough to need variadic arguments is usually a sign that a nameddefwould be clearer.- What is the difference between
def f(*args)anddef f(args)? They are entirely different.
def f(*args)uses the variadic collector — the caller writesf(1, 2, 3)and Python automatically packs those three values into a tuple namedargs.def f(args)is an ordinary single-parameter function — the caller must pass exactly one object, such as a list:f([1, 2, 3]). Callingf(1, 2, 3)against the plain version raisesTypeError: f() takes 1 positional argument but 3 were given. The nameargshas no special meaning on its own — only the asterisk prefix enables variadic collection.- What error does Python raise if I put
**kwargsbefore*args? A
SyntaxErrorat parse time — before any code in the file runs. Python enforces parameter ordering as a grammar rule: the legal sequence is positional-only, standard,*args, keyword-only, then**kwargs. Writing(**kwargs, *args)violates this grammar and the parser rejects the file immediately withSyntaxError: invalid syntax. The fix is to swap the order:def f(*args, **kwargs).- Does
functools.wrapsmatter when writing a variadic decorator? Yes, and it is easy to forget. Without
@functools.wraps(func), the wrapper function replaces the original's__name__,__doc__,__annotations__, and__wrapped__attributes. This breaks documentation generators,help()output, and — critically for typed code — static type checkers that inspect function metadata. With@functools.wraps(func), the wrapper copies these attributes from the original function, so the decorator is transparent to introspection tools. It is one line and should be a reflex whenever you write adef wrapper(*args, **kwargs)inside a decorator.- What happens if I unpack a dictionary with non-string keys using
**? Python raises a
TypeError: keywords must be stringsat the call site. All keyword argument names — whether passed explicitly or via**unpacking — must be strings. A plaindictcan hold integer or mixed-type keys, but attempting to unpack one with**into a function call is rejected at runtime. If you need to pass such a mapping into a function, pass it as a single positional argument instead of unpacking it.They are different. When a function body reads an optional kwarg with
kwargs.get("key", default), passingkey=Nonestores the valueNonein the dict —.get()returnsNone, not the default. Omitting the key entirely means the key is absent, and.get()returns the default. This distinction matters whenever your default is a meaningful sentinel like an empty list, a connection pool, or a flag object. The safest pattern for "use default if caller didn't pass a value" is to checkif "key" not in kwargsrather thanif kwargs.get("key") is None.
Key Takeaways
- *args collects; it does not accept lists: The
*prefix packs remaining positional arguments into an immutable tuple. Pass a list and it arrives as a single element in that tuple — use*my_listat the call site to unpack it first. - **kwargs is a real dict with all dict methods available: You can
.get(),.update(), merge with**, and iterate over it. It is not a read-only namespace — mutations inside the function do not propagate back to the caller's namespace. - Keyword-only parameters (PEP 3102) are the safe alternative to manual parsing: Instead of pulling options out of
**kwargswith.get(), declare them explicitly after*argsor a bare*. The interpreter enforces the keyword requirement; you don't have to. - Type annotations have kept pace:
ParamSpec(PEP 612, Python 3.10) preserves decorator signatures.TypedDict+Unpack(PEP 692, Python 3.12) gives per-key precision to**kwargs. Both are now stable, in the standard library, and supported by mypy and pyright. - Use
inspect.signature()to read variadic signatures at runtime:Parameter.VAR_POSITIONALandParameter.VAR_KEYWORDlet you detect and adapt to variadic callables programmatically — the foundation of framework dispatch and documentation tooling. - Performance is real but usually not the deciding factor: Variadic calls allocate a new tuple or dict per call. For functions called millions of times in a tight loop, switch to explicit parameters. For API design, decorator patterns, and framework code, the flexibility is worth the overhead.
- Default parameters before
*argsare effectively unreachable positionally: Any positional argument will consume the slot before*argsever collects extras. Move optional settings after*argsas keyword-only parameters — that is the only way to make defaults genuinely optional. **kwargsmutation is locally isolated, but mutable values are not deep-copied: Modifications to the dict itself (adding or removing keys) do not propagate back to the caller. Mutations to objects stored as values — lists, dicts — do propagate, because the values are shared references.key=Noneand omitting a key are not the same thing: When reading optional kwargs, checkif "key" not in kwargsto distinguish a caller who passedNoneintentionally from one who omitted the argument entirely.- The asterisk is what matters, not the name:
def f(*args)collects any number of positional arguments into a tuple automatically.def f(args)is an ordinary single-parameter function that expects the caller to pass one object — the nameargscarries no special meaning without the prefix. **kwargsbefore*argsis a parse-time SyntaxError: Parameter ordering is enforced by Python's grammar, not at runtime. Writing(**kwargs, *args)fails the moment the parser reads the definition. The correct order is always(*args, **kwargs).- Lambda supports variadic parameters with the same syntax:
lambda *args: sum(args)is valid. The body must remain a single expression, so lambdas with variadic parameters are best kept simple — a multi-statement variadic function belongs in adef. - Always use
@functools.wrapsin variadic decorators: Without it, the wrapper replaces the original function's__name__,__doc__, and__annotations__. One line of@functools.wraps(func)keeps the decorator transparent to introspection tools and type checkers.
Variadic arguments sit at the intersection of Python's pragmatism and its design philosophy. They give library authors the flexibility to write functions that compose cleanly with code they haven't seen yet, and they give application developers a principled way to forward arguments across abstraction boundaries. That combination is why *args and **kwargs show up in nearly every non-trivial Python codebase — from Django's class-based views to NumPy's array construction functions to the standard library's own functools module. For more hands-on python tutorials covering functions, data structures, and everything in between, the full library is on the home page.