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. The Python 3.12 typing documentation specifies that P.args is exclusively for annotating *args in a wrapper and represents the positional arguments of a given call, while P.kwargs is exclusively for annotating **kwargs and represents the keyword argument mapping. (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 Matthew Rahtz, Pradeep Kumar Srinivasan, Mark Mendoza, 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)
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.
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.
Real-world Python patterns
Knowing the mechanics is one thing. Recognizing the patterns that appear repeatedly in serious Python code is another.
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.
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.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.- What is the difference between passing
key=Noneand omitting the key entirely? 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.
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.