Python Arbitrary Arguments: *args and **kwargs
python arbitrary arguments: Understand how Python's *args and **kwargs collect variable-length arguments, how parameter ordering works, and when to use them in product...
Python arbitrary arguments let a function accept a variable number of positional or keyword parameters at the call site. The mechanism is built on two syntax forms: a single asterisk before a parameter name and a double asterisk before a parameter name. Understanding how each form collects values, how the two interact, and where the pattern creates maintainability costs is what separates a clean API from a fragile one.
What *args Actually Collects
When a Python function is defined with a single asterisk before a parameter name, that parameter collects every extra positional argument passed at the call site into a tuple.
def collect(*args): print(args) print(type(args)) collect(1, 2, 3)
The output is (1, 2, 3) and <class 'tuple'>. The parameter name is irrelevant to the behavior; args is a widely used convention, not a keyword. The same mechanism works with any name:
def collect(*values): return sum(values) collect(1, 2, 3) # 6
Because the collected value is a plain tuple, it supports indexing, iteration, and unpacking like any other tuple. It is not a list, so mutating methods such as append are unavailable. If you need to modify the collected values, convert the tuple to a list first.
What **kwargs Actually Collects
A double asterisk collects extra keyword arguments into a dictionary.
def collect(**kwargs): print(kwargs) print(type(kwargs)) collect(name="ada", role="admin")
The output is {'name': 'ada', 'role': 'admin'} and <class 'dict'>. Keys are always strings, and values keep their original types. Dictionary order follows insertion order, which Python has guaranteed since version 3.7.
The two mechanisms are independent. A function can use either one alone, both together, or neither. A function with no fixed parameters can still accept arbitrary arguments:
def log_event(*args, **kwargs): print("positional:", args) print("keyword:", kwargs) log_event("login", user_id=42)
The Parameter Ordering Rule
Python enforces a fixed order for parameter kinds in a function definition:
- positional-only parameters
- positional-or-keyword parameters
*args- keyword-only parameters
**kwargs
def example(a, b, *args, option=True, **kwargs): print(a, b, args, option, kwargs) example(1, 2, 3, 4, option=False, mode="fast")
Here a and b are required positional parameters, 3 and 4 land in args, option is keyword-only, and mode lands in kwargs. The printed result is 1 2 (3, 4) False {'mode': 'fast'}.
A lone * with no name forces all following parameters to be keyword-only:
def connect(host, *, port=5432): print(host, port) connect("db.internal") # valid connect("db.internal", 5433) # TypeError connect("db.internal", port=5433) # valid
This is useful when a parameter is optional but should be explicit at the call site, preventing callers from accidentally passing it positionally.
Unpacking at the Call Site
The same * and ** symbols work in reverse when calling a function. A single asterisk unpacks an iterable into positional arguments; a double asterisk unpacks a mapping into keyword arguments.
def report(name, score): print(f"{name}: {score}") values = ["ada", 98] report(*values) mapping = {"name": "ada", "score": 98} report(**mapping)
Both calls produce ada: 98. The unpacking syntax is what makes forwarding patterns work. A wrapper receives arbitrary arguments and passes them through unchanged:
def logged(func): def wrapper(*args, **kwargs): print("calling", func.__name__) return func(*args, **kwargs) return wrapper
The wrapper does not need to know what the wrapped function accepts. It forwards whatever it receives, preserving both positional and keyword arguments.
Practical Use Cases
The most common production use is decorators and middleware that wrap functions of unknown signature. Logging, timing, authentication, and retry logic all benefit from accepting and forwarding arbitrary arguments.
Another common case is configuration-style APIs where a function accepts optional overrides on top of defaults:
def build_client(**settings): defaults = {"timeout": 30, "retries": 3} defaults.update(settings) return defaults
A third case is dispatch tables or registries that store callables with different signatures and invoke them uniformly. The registry stores each callable as (*args, **kwargs) and lets the caller supply whatever each callable needs.
Performance and Maintainability Tradeoffs
Each call that uses *args allocates a tuple, and each call that uses **kwargs allocates a dictionary. For typical application code this cost is negligible. In a hot loop executed millions of times, the allocation overhead can become measurable, but the correct response is to profile rather than guess.
The larger cost is maintainability. A function declared as def handler(*args, **kwargs) gives callers no information about what it accepts. Static analysis tools cannot validate calls, and IDEs cannot offer autocompletion for the parameters. When the set of parameters is stable, an explicit signature is clearer:
def handler(name: str, retries: int = 3) -> None: ...
Use arbitrary arguments when the input set is genuinely dynamic, such as a wrapper, a plugin interface, or an API that forwards options to a backend. Use explicit parameters when the contract is fixed and known in advance.
Common Mistakes and Edge Cases
One frequent mistake is treating args as a list and calling mutating methods on it. Convert it first if mutation is needed:
def process(*args): items = list(args) items.append("extra") return items
Another mistake is relying on **kwargs to accept any key, which silently absorbs typos. A misspelled option passes without error and is never applied. Validate keys explicitly when the set of valid options is known:
def configure(**kwargs): allowed = {"timeout", "retries"} unexpected = set(kwargs) - allowed if unexpected: raise TypeError(f"unexpected options: {unexpected}")
A third edge case is forwarding **kwargs to a function that does not accept keyword arguments. The call raises TypeError at runtime, which is why wrappers that forward to multiple backends must ensure every backend accepts the same keyword set.
Forwarding Arguments Without Losing Behavior
When a wrapper forwards arguments to an inner function, it should pass both *args and **kwargs together. Dropping one breaks calls that rely on the other kind:
def wrapper(*args, **kwargs): return inner(*args, **kwargs)
If the wrapper needs to inject or remove a specific keyword, it can modify the dictionary before forwarding:
def wrapper(**kwargs): kwargs.setdefault("timeout", 30) return inner(**kwargs)
This keeps the forwarding explicit while still allowing the caller to override the default. The same technique works for filtering keys that a backend does not accept, as long as the filtering logic is applied before the call.