Every Python developer hits this wall eventually. You're reading through a library's source code or a teammate's decorator, and you see it: def some_function(*args, **kwargs). The asterisks feel like a secret handshake -- clearly important, deeply embedded in Python's DNA, but never quite explained well enough to fully stick.
Here's the truth: *args and **kwargs are not keywords, not special variable names, and not magic. They are a direct expression of two unpacking operators -- * and ** -- applied to function parameters. The names args and kwargs are conventions, nothing more. You could call them *stuff and **things and Python would not care one bit. What matters is the asterisk.
This article works through *args and **kwargs from the ground up: what they actually do at the interpreter level, how to use them in real code, the specific rules governing their order and behavior, the Python Enhancement Proposals that shaped them over two decades, what happens under the hood in CPython's bytecode, the performance implications of flexible signatures, and the subtle gotchas that trip up even experienced developers. It also asks the questions many tutorials skip -- why Python made the design choices it did, what the tension between flexibility and explicitness actually means in practice, and how these operators became the invisible infrastructure of Python's entire ecosystem.
The Single Asterisk: *args
When you place a single * before a parameter name in a function definition, you're telling Python to collect all remaining positional arguments into a tuple. The function can then accept any number of positional arguments beyond whatever named parameters precede the *args parameter.
def calculate_total(tax_rate, *prices):
subtotal = sum(prices)
tax = subtotal * tax_rate
return round(subtotal + tax, 2)
result = calculate_total(0.08, 29.99, 15.50, 42.00, 8.75)
print(result) # 103.94
In this example, tax_rate captures the first argument (0.08), and *prices captures everything else as the tuple (29.99, 15.50, 42.00, 8.75). The function doesn't need to know in advance how many prices it will receive.
*args produces a tuple, not a list. This is intentional. Tuples are immutable, which means the collected arguments cannot be accidentally modified inside the function. If you need mutability, you'll need to explicitly convert with list(args).
def show_type(*args):
print(type(args))
print(args)
show_type(1, 2, 3)
# <class 'tuple'>
# (1, 2, 3)
If no extra positional arguments are passed, *args receives an empty tuple -- not None, not an error:
def flexible(*args):
count = len(args)
noun = "argument" if count == 1 else "arguments"
print(f"Received {count} {noun}: {args}")
flexible()
flexible(42)
flexible("a", "b", "c")
# Received 0 arguments: ()
# Received 1 argument: (42,)
# Received 3 arguments: ('a', 'b', 'c')
This empty-tuple behavior is a deliberate design choice. It means you never need a guard clause checking whether args exists -- it always does. You can safely iterate over it, unpack it, or pass it along without defensive None checks. This differs from how many other languages handle variadic parameters, and it's one of the small details that makes Python's argument system unusually reliable.
The Double Asterisk: **kwargs
The double asterisk does the same thing for keyword arguments that the single asterisk does for positional ones. It collects all keyword arguments that don't match any named parameter into a dictionary, with the keyword names as string keys and the passed values as values.
def create_user(username, **details):
user = {"username": username}
user.update(details)
return user
user = create_user("kandi_sec", role="instructor", platform="Udemy", verified=True)
print(user)
# {'username': 'kandi_sec', 'role': 'instructor', 'platform': 'Udemy', 'verified': True}
Here, username consumes the first positional argument, and **details collects role, platform, and verified into the dictionary {"role": "instructor", "platform": "Udemy", "verified": True}.
Prior to Python 3.6, the order of keys in **kwargs was not guaranteed. PEP 468 addressed this directly, making **kwargs insertion-order preservation a language-level specification starting in Python 3.6 -- not merely a CPython implementation detail. Python 3.7 then extended that guarantee to cover all dict objects across all conforming implementations. Today, **kwargs reliably preserves the order in which keyword arguments are passed. If you're maintaining code targeting Python 3.5 or earlier, this matters.
Using Both Together
You can combine *args and **kwargs in the same function definition. The rule is strict: *args must come before **kwargs. This ordering is not a convention -- it's enforced by the Python grammar. Violating it produces a SyntaxError.
def log_event(event_type, *args, **kwargs):
print(f"Event: {event_type}")
if args:
print(f" Positional data: {args}")
if kwargs:
print(f" Metadata: {kwargs}")
log_event("LOGIN", "192.168.1.100", user="admin", success=True)
# Event: LOGIN
# Positional data: ('192.168.1.100',)
# Metadata: {'user': 'admin', 'success': True}
The full parameter ordering rule in Python function definitions is:
- Positional-only parameters (before
/) - Regular positional-or-keyword parameters
*args(or a bare*)- Keyword-only parameters (parameters that appear after
*args) **kwargs
This ordering was formalized across multiple PEPs. PEP 3102, "Keyword-Only Arguments," established that parameters placed after *args in a function definition become keyword-only -- they can never be filled by positional arguments. PEP 570, "Python Positional-Only Parameters," added the / separator in Python 3.8 to mark parameters that can only be passed positionally. Together, these PEPs created the complete signature grammar that Python uses today:
def complete_signature(pos_only, /, normal, *, kw_only, **kwargs):
print(f"pos_only={pos_only}, normal={normal}, kw_only={kw_only}")
print(f"extra kwargs: {kwargs}")
complete_signature(1, 2, kw_only=3, extra=4)
# pos_only=1, normal=2, kw_only=3
# extra kwargs: {'extra': 4}
PEP 8 observes that code is read far more often than it is written. — PEP 8, Style Guide for Python Code (Guido van Rossum, Barry Warsaw, Nick Coghlan)
The full parameter ordering syntax is designed with this principle in mind. Reading a function signature from left to right, you can immediately understand how arguments must be provided.
The Bare Star: Keyword-Only Without Variadic Args
A common misconception is that keyword-only arguments require *args. They don't. Python allows a bare * -- a star with no name -- to act as a divider that forces everything after it to be keyword-only, without collecting any variadic positional arguments at all.
def create_report(title, *, format="pdf", include_charts=True, author=None):
print(f"Report: {title}, format={format}, charts={include_charts}")
create_report("Q1 Results", format="html", include_charts=False)
# Report: Q1 Results, format=html, charts=False
# This raises TypeError -- cannot pass keyword-only args positionally:
# create_report("Q1 Results", "html", False)
This is not *args -- there is no tuple being collected. The bare * simply tells the parser "positional argument intake ends here." What follows must be supplied by name. This design is intentional: it lets you enforce named-only calling for clarity-critical parameters without giving the function the ability to absorb arbitrary extra positionals.
The practical value of this pattern goes beyond aesthetics. Consider a function where argument order would be ambiguous or dangerous to get wrong -- say, a database deletion function or a cryptographic key derivation. Requiring keyword-only arguments creates a contract in the signature itself:
# Without bare star: easy to make silent mistakes
def delete_records(table, confirm, cascade):
pass
delete_records("users", True, False) # Which bool is which?
# With bare star: caller must be explicit
def delete_records(table, *, confirm, cascade=False):
pass
delete_records("users", confirm=True) # Unambiguous
Standard library functions adopted this pattern extensively after PEP 3102. sorted() is a clear example: its signature is sorted(iterable, /, *, key=None, reverse=False) -- the bare * makes key and reverse keyword-only. min() and max() achieve the same effect for their key= and default= options through a different mechanism (their variadic *args form), but the result for callers is identical: those options cannot be passed positionally by accident. When you see sorted(items, key=lambda x: x[1]), it's the bare star in sorted's definition that enforces the named argument.
The Unpacking Operators in Function Calls
The * and ** operators aren't limited to function definitions. They also work in function calls, where they do the reverse: they unpack a sequence or dictionary into individual arguments.
def greet(first, last, title=""):
if title:
print(f"Hello, {title} {first} {last}")
else:
print(f"Hello, {first} {last}")
# Unpack a list into positional arguments
name_parts = ["Ada", "Lovelace"]
greet(*name_parts, title="Countess")
# Unpack a dictionary into keyword arguments
info = {"first": "Grace", "last": "Hopper", "title": "Admiral"}
greet(**info)
# Hello, Countess Ada Lovelace
# Hello, Admiral Grace Hopper
PEP 448, "Additional Unpacking Generalizations," significantly expanded where * and ** could be used. Before Python 3.5, only one * unpack and one ** unpack per function call were permitted. PEP 448 removed that restriction:
def show(*args, **kwargs):
print(args, kwargs)
first = [1, 2]
second = [3, 4]
options_a = {"color": "red"}
options_b = {"size": "large"}
show(*first, *second, **options_a, **options_b)
# (1, 2, 3, 4) {'color': 'red', 'size': 'large'}
PEP 448 also enabled unpacking in container literals, making * and ** useful for merging lists and dictionaries:
defaults = {"timeout": 30, "retries": 3}
overrides = {"timeout": 60, "verbose": True}
config = {**defaults, **overrides}
print(config)
# {'timeout': 60, 'retries': 3, 'verbose': True}
When keys conflict during dictionary unpacking, the last unpacked dictionary wins. In the example above, timeout becomes 60, not 30. PEP 448 explicitly specifies this behavior. This makes {**defaults, **overrides} a clean, declarative pattern for applying configuration layers -- a technique used heavily in Django settings and similar frameworks.
Dictionary Merging: The Modern Alternative
Python 3.9 introduced the | and |= operators for dictionaries (PEP 584), giving the merge operation a dedicated syntax separate from the ** unpacking idiom. Understanding the relationship between these two approaches -- and their differences -- is important for writing clear modern Python.
defaults = {"timeout": 30, "retries": 3}
overrides = {"timeout": 60, "verbose": True}
# PEP 448 approach (works in Python 3.5+)
merged_old = {**defaults, **overrides}
# PEP 584 approach (Python 3.9+)
merged_new = defaults | overrides
print(merged_old) # {'timeout': 60, 'retries': 3, 'verbose': True}
print(merged_new) # {'timeout': 60, 'retries': 3, 'verbose': True}
print(merged_old == merged_new) # True
The results are identical, but the semantic intent differs. {**a, **b} is an unpacking expression rooted in function-call syntax -- you're constructing a new dict literal by spreading multiple mappings into it. a | b is a binary operator on two mappings -- semantically closer to set union, which is deliberate. The |= variant updates in-place:
config = {"timeout": 30, "retries": 3}
config |= {"timeout": 60, "verbose": True}
print(config) # {'timeout': 60, 'retries': 3, 'verbose': True}
Neither approach replaces the other. The ** idiom remains necessary when you need to merge more than two sources in a single expression, when you need to inject additional literal keys inline, or when you're targeting Python versions before 3.9. The | operator is cleaner for the common two-dict case and makes the intent unambiguous to a reader who might not immediately recognize the unpacking idiom. Knowing both and choosing deliberately is the mark of a developer who understands the tool rather than just the syntax.
The | operator requires the left operand to implement __or__. dict, dict subclasses (like collections.OrderedDict), and types.MappingProxyType all support it. Arbitrary custom mapping types that don't subclass dict typically do not. The {**a, **b} idiom works on any object that supports the mapping protocol -- including custom Mapping subclasses -- making it the portable choice when you don't control the input type.
Under the Hood: What CPython Actually Does
Understanding what happens beneath the syntax turns *args and **kwargs from a pattern to memorize into a mechanism you can reason about. When CPython compiles a function call that uses * or ** unpacking, it emits a different bytecode instruction than it does for a standard call.
You can observe this directly with the dis module. The output below is actual CPython 3.12 bytecode:
import dis
def example(*args, **kwargs):
pass
def caller():
items = [1, 2, 3]
opts = {"key": "value"}
example(*items, **opts)
dis.dis(caller)
# 7 0 RESUME 0
#
# 8 2 BUILD_LIST 0
# 4 LOAD_CONST 1 ((1, 2, 3))
# 6 LIST_EXTEND 1
# 8 STORE_FAST 0 (items)
#
# 9 10 LOAD_CONST 2 ('key')
# 12 LOAD_CONST 3 ('value')
# 14 BUILD_MAP 1
# 16 STORE_FAST 1 (opts)
#
# 10 18 LOAD_GLOBAL 1 (NULL + example)
# 28 LOAD_FAST 0 (items)
# 30 BUILD_MAP 0
# 32 LOAD_FAST 1 (opts)
# 34 DICT_MERGE 1
# 36 CALL_FUNCTION_EX 1 # 1 means kwargs dict present
# 38 POP_TOP
# 40 RETURN_CONST 0 (None)
The key instruction is CALL_FUNCTION_EX. The 1 flag means a keyword argument dictionary is present on the stack alongside the positional tuple. CPython builds the dict for opts key-by-key (BUILD_MAP), merges any additional keyword sources with DICT_MERGE, then dispatches via CALL_FUNCTION_EX. On Python 3.10 and earlier the standard non-unpacking instruction was CALL_FUNCTION; Python 3.11+ renamed it to CALL as part of the adaptive specializing interpreter overhaul -- but CALL_FUNCTION_EX has remained stable across this transition precisely because its semantics are tied to the unpacking mechanism, not to the hot-path optimizations the overhaul was targeting.
There is also a subtler thing happening inside the callee. When CPython's frame evaluation receives a CALL_FUNCTION_EX dispatch, it has to construct the locals array for the new frame. For a function with *args, CPython copies the positional excess into a new tuple and stores it as a local. For **kwargs, it constructs a fresh dict from the keyword excess. This means every call to a variadic function involves at minimum one allocation -- and often two. That's the memory and GC pressure cost that the performance section discusses.
The inspect module provides another lens into this system. You can examine how Python's runtime sees a function's parameter structure:
import inspect
def complex_func(a, b, /, c=10, *args, keyword_only=True, **kwargs):
pass
sig = inspect.signature(complex_func)
for name, param in sig.parameters.items():
print(f"{name}: {param.kind.name}")
# a: POSITIONAL_ONLY
# b: POSITIONAL_ONLY
# c: POSITIONAL_OR_KEYWORD
# args: VAR_POSITIONAL
# keyword_only: KEYWORD_ONLY
# kwargs: VAR_KEYWORD
Each parameter kind -- POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD, VAR_POSITIONAL, KEYWORD_ONLY, VAR_KEYWORD -- maps directly to the grammar rules discussed earlier. The inspect.Parameter.kind enum was formalized in PEP 362, "Function Signature Object," which gave Python a standardized way to introspect function signatures programmatically. This is the same mechanism that documentation generators, testing frameworks, and dependency injection systems rely on when they need to understand a function's contract without executing it.
The code object's co_flags attribute encodes whether a function uses *args and **kwargs. The flag CO_VARARGS (0x04) indicates *args, and CO_VARKEYWORDS (0x08) indicates **kwargs. You can inspect these directly: complex_func.__code__.co_flags & 0x04 returns non-zero if the function accepts *args. These two specific flags have been stable across CPython versions for a long time, but co_flags as a whole encodes many internal details that have shifted across releases. For production introspection, prefer inspect.signature() -- it provides a stable, version-independent interface backed by the same underlying data.
Real-World Pattern: Forwarding Arguments
The single most common use of *args and **kwargs in production Python code is argument forwarding -- passing all received arguments through to another function, typically in decorators, wrappers, or subclass methods.
import time
import functools
def timer(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__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def process_data(data, normalize=False):
time.sleep(0.1) # simulate work
if normalize:
return [x / max(data) for x in data]
return data
process_data([10, 20, 30], normalize=True)
# process_data took 0.1003s
The wrapper function has no idea what arguments process_data expects. It doesn't need to know. By accepting *args, **kwargs and forwarding them with func(*args, **kwargs), the decorator works with any function signature. This is the pattern that makes Python's decorator ecosystem possible.
The reason this pattern works so elegantly is a symmetry in the operators. In the function definition, *args collects positional arguments into a tuple and **kwargs collects keyword arguments into a dict. In the function call, *args expands the tuple back into positional arguments and **kwargs expands the dict back into keyword arguments. The pack-unpack cycle preserves the caller's original argument structure perfectly -- positional arguments remain positional, keyword arguments remain keyword, and the wrapped function receives exactly what the caller sent.
functools.wraps preserves the original function's name, docstring, and module. Without it, introspection tools and documentation generators would see wrapper instead of process_data. The __wrapped__ attribute it sets also allows inspect.signature to see through the decorator and report the original function's signature rather than the generic (*args, **kwargs). Always include it in decorators.
Real-World Pattern: Stacking Decorators
When decorators are stacked, the *args/**kwargs forwarding chain must stay intact through every layer. Each wrapper receives arguments from the one above it and passes them to the one below. A break anywhere in that chain -- such as a decorator that unpacks args and reconstructs them incorrectly -- silently changes what the innermost function receives.
import functools
import time
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
def retry(max_attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}. Retrying...")
return wrapper
return decorator
@timer
@retry(max_attempts=3)
def fetch_data(url, timeout=10):
# simulate occasional failure
import random
if random.random() < 0.5:
raise ConnectionError("Network timeout")
return f"Data from {url}"
fetch_data("https://api.example.com/data", timeout=30)
The stacking order matters here. @timer is applied last (outermost), so it wraps @retry's wrapper. When fetch_data is called, the arguments flow inward: timer's wrapper receives them first, forwards to retry's wrapper, which may call the inner function multiple times, each time forwarding the same args and kwargs unchanged. This works correctly because each layer treats *args, **kwargs as opaque -- it never tries to understand them, just passes them through.
The __wrapped__ chain created by functools.wraps allows you to navigate this stack programmatically. inspect.unwrap(fetch_data) follows the __wrapped__ attributes through every decorator layer and returns the original bare function -- useful for testing, for signature inspection, and for frameworks that need to understand the true interface rather than the decorated surface.
import inspect
# inspect.signature follows __wrapped__ automatically
print(inspect.signature(fetch_data))
# (url, timeout=10) -- not (*args, **kwargs)
# inspect.unwrap reaches the original function
original = inspect.unwrap(fetch_data)
print(original.__name__) # fetch_data (the undecorated version)
A decorator that forgets functools.wraps breaks the __wrapped__ chain. inspect.signature will then report (*args, **kwargs) for the decorated function, and inspect.unwrap will stop at the layer that dropped the attribute. This matters most in frameworks like FastAPI, pytest, and Click that read signatures to wire up behavior -- a missing functools.wraps can silently break dependency injection or route parameter mapping in ways that are hard to trace.
Real-World Pattern: Flexible Class Inheritance
When subclassing, *args and **kwargs are essential for creating classes that don't break when parent class signatures change:
class BaseLogger:
def __init__(self, name, level="INFO"):
self.name = name
self.level = level
class FileLogger(BaseLogger):
def __init__(self, *args, filepath="/var/log/app.log", **kwargs):
super().__init__(*args, **kwargs)
self.filepath = filepath
logger = FileLogger("audit", level="DEBUG", filepath="/tmp/audit.log")
print(f"Name: {logger.name}, Level: {logger.level}, Path: {logger.filepath}")
# Name: audit, Level: DEBUG, Path: /tmp/audit.log
FileLogger adds its own parameter (filepath) while transparently passing everything else to BaseLogger.__init__. If BaseLogger later adds new parameters, FileLogger doesn't need to change.
This pattern is particularly important in multiple inheritance with cooperative super() calls, where each class in the Method Resolution Order (MRO) needs to accept and forward arguments it doesn't recognize. Without **kwargs, adding a new parameter to any class in the hierarchy would require updating every other class in the MRO to explicitly accept and forward it -- a brittle coupling that grows harder to maintain as the hierarchy deepens.
Real-World Pattern: Configuration Builders
**kwargs excels at building configuration objects where the set of options is large and mostly optional:
def create_connection(host, port, **options):
config = {
"host": host,
"port": port,
"timeout": options.get("timeout", 30),
"ssl": options.get("ssl", False),
"retries": options.get("retries", 3),
"pool_size": options.get("pool_size", 5),
}
return config
conn = create_connection("db.example.com", 5432, ssl=True, pool_size=10)
print(conn)
# {'host': 'db.example.com', 'port': 5432, 'timeout': 30, 'ssl': True, 'retries': 3, 'pool_size': 10}
This pattern appears throughout Python's ecosystem. Django's QuerySet methods, Flask's route decorators, and the requests library's Session.request() all use **kwargs to accept flexible configurations.
However, this approach has a significant tradeoff: it silently accepts typos. If a caller passes timout=60 (missing the 'e'), no error is raised -- the misspelled key simply lands in options and gets ignored. A more defensive version validates unknown keys:
def create_connection(host, port, **options):
valid_keys = {"timeout", "ssl", "retries", "pool_size"}
unknown = set(options) - valid_keys
if unknown:
raise TypeError(f"Unexpected keyword arguments: {unknown}")
config = {
"host": host,
"port": port,
"timeout": options.get("timeout", 30),
"ssl": options.get("ssl", False),
"retries": options.get("retries", 3),
"pool_size": options.get("pool_size", 5),
}
return config
This validation pattern gives you the flexibility of **kwargs while catching mistakes at call time rather than leaving them as silent failures -- a design approach that balances the "Explicit is better than implicit" and "Errors should never pass silently" principles from PEP 20.
Real-World Pattern: Signature-Driven Frameworks
Here is a question that reveals a deeper level of understanding: if *args and **kwargs hide what a function actually needs, how do frameworks like FastAPI, pytest, and Click manage to wire up the right arguments automatically -- without being told explicitly?
The answer is inspect.signature. These frameworks don't call your functions blindly with *args, **kwargs. They read the function's signature first, then construct the argument set to match it. *args and **kwargs are actually the framework's tool -- used internally to accept whatever comes in from HTTP, from the test runner, from the CLI -- and inspect.signature is how the framework maps that flexible inbound stream to your explicit parameters.
import inspect
# A simplified version of what FastAPI / pytest do internally
def inject(func, available: dict):
"""Call func using only the kwargs its signature actually requests.
Handles POSITIONAL_OR_KEYWORD and KEYWORD_ONLY params only;
positional-only and variadic params are intentionally excluded."""
sig = inspect.signature(func)
wanted = {}
for name, param in sig.parameters.items():
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
):
if name in available:
wanted[name] = available[name]
elif param.default is inspect.Parameter.empty:
raise TypeError(f"No value available for required parameter '{name}'")
return func(**wanted)
def process(db, user_id, timeout=30):
return f"Processing user {user_id} with timeout {timeout}"
# Framework has a large pool of available values
context = {
"db": "",
"user_id": 42,
"request": "",
"timeout": 60,
"logger": "",
}
result = inject(process, context)
print(result) # Processing user 42 with timeout 60
The framework passes context -- which contains far more keys than process needs -- and inject uses inspect.signature to extract only what the function actually declared. The function never sees request or logger. This is the mechanism behind pytest's fixture injection, FastAPI's dependency injection, and Click's command parameter mapping. Understanding it changes how you think about *args and **kwargs: they are not just for functions that accept variable arguments. They are part of the infrastructure that makes Python's introspective, dynamically-wired frameworks possible.
inspect.signature handles VAR_POSITIONAL and VAR_KEYWORD parameter kinds (i.e., *args and **kwargs) by passing them through rather than trying to satisfy them from a pool. A framework encountering **kwargs in your function's signature typically treats it as "accept everything remaining" -- which means if you declare **kwargs in a FastAPI route handler, FastAPI will not inject specific named dependencies into it. Explicit parameter names are what the introspection machinery latches onto.
*args and **kwargs in Async Functions
async def functions support *args and **kwargs identically to synchronous ones. The packing and unpacking rules are unchanged. What changes is the call site: an async function must be awaited, and if you're forwarding arguments to another async function, the wrapper must also be async and must await the inner call.
import asyncio
import functools
import time
def async_timer(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.perf_counter()
result = await func(*args, **kwargs) # must await
print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
return result
return wrapper
@async_timer
async def fetch_user(user_id: int, *, include_profile: bool = False):
await asyncio.sleep(0.1) # simulate I/O
return {"id": user_id, "profile": include_profile}
async def main():
user = await fetch_user(42, include_profile=True)
print(user)
asyncio.run(main())
# fetch_user took 0.1003s
# {'id': 42, 'profile': True}
The common mistake is wrapping an async function with a synchronous wrapper that uses *args, **kwargs but forgets to await. This produces a coroutine object instead of a result, and if the wrapper returns it without awaiting, Python will issue a RuntimeWarning: coroutine was never awaited -- but only at runtime, not when defining the decorator. Type checkers like mypy and pyright catch this if the decorator is properly annotated with ParamSpec.
# The wrong way: sync wrapper around async function
def broken_timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs) # returns a coroutine object, doesn't execute it
return result # returns the coroutine unawaited; Python raises RuntimeWarning
return wrapper
# The right way: async wrapper for async functions
def async_timer(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
result = await func(*args, **kwargs) # actually runs it
return result
return wrapper
If you need a single decorator that works on both sync and async functions, you can inspect the wrapped function at decoration time using asyncio.iscoroutinefunction(func) and return either a sync or async wrapper accordingly. This is what production libraries like tenacity (retry logic) do internally -- they detect whether the decorated function is a coroutine function and choose the appropriate wrapper variant.
Typing *args and **kwargs: PEP 484 and Beyond
Type hinting introduced by PEP 484 provides a way to annotate *args and **kwargs, though with limitations. When you annotate *args: int, you're declaring that every positional argument should be an int. When you annotate **kwargs: str, every keyword argument value should be a str.
def process(*args: float, **kwargs: str) -> dict:
return {
"sum": sum(args),
"labels": kwargs,
}
result = process(1.5, 2.5, 3.0, name="totals", category="finance")
print(result)
# {'sum': 7.0, 'labels': {'name': 'totals', 'category': 'finance'}}
The limitation is clear: this syntax requires all *args items to share the same type and all **kwargs values to share the same type. Real-world functions rarely have such uniform signatures.
Two subsequent PEPs addressed this gap directly.
PEP 612, "Parameter Specification Variables," introduced ParamSpec in Python 3.10 to solve the long-standing problem of type checkers losing parameter information through *args, **kwargs forwarding. Before ParamSpec, decorating a function with a generic *args, **kwargs wrapper would destroy all type information about the original function's parameters. ParamSpec lets type checkers preserve that information:
from typing import Callable, TypeVar, ParamSpec # Python 3.10+
P = ParamSpec("P")
R = TypeVar("R")
def add_logging(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
@add_logging
def greet(name: str, excited: bool = False) -> str:
return f"Hello, {name}{'!' if excited else '.'}"
# Type checkers now know greet still accepts (str, bool) -> str
PEP 692, "Using TypedDict for more precise **kwargs typing," landed in Python 3.12 and addressed the other half of the problem. It extended Unpack -- a helper originally introduced by PEP 646 and available in typing since Python 3.11 -- to also work with TypedDict, allowing **kwargs to carry per-key type information:
from typing import TypedDict, Unpack # Python 3.11+; TypedDict-based **kwargs typing requires 3.12+
class ConnectOptions(TypedDict, total=False):
timeout: int
ssl: bool
retries: int
def connect(host: str, **kwargs: Unpack[ConnectOptions]) -> None:
# Type checkers know kwargs["timeout"] is int,
# kwargs["ssl"] is bool, etc.
pass
Together, PEP 612 and PEP 692 transformed *args and **kwargs from type-checking blind spots into first-class participants in Python's static analysis ecosystem. For projects using mypy, pyright, or similar type checkers, these PEPs make variadic functions as verifiable as fixed-signature ones.
Performance: When Flexibility Has a Cost
The flexibility of *args and **kwargs is not free. Every call to a function that uses these features involves allocating a new tuple (for *args) and a new dictionary (for **kwargs), even when zero variadic arguments are passed. In hot code paths -- loops executing millions of times, per-pixel image processing, tight numerical computations -- this overhead can matter.
import time
def with_kwargs(**kwargs):
return kwargs.get("x", 0) + kwargs.get("y", 0)
def with_params(x=0, y=0):
return x + y
# Benchmark: 1 million calls
start = time.perf_counter()
for _ in range(1_000_000):
with_kwargs(x=1, y=2)
kwargs_time = time.perf_counter() - start
start = time.perf_counter()
for _ in range(1_000_000):
with_params(x=1, y=2)
params_time = time.perf_counter() - start
print(f"**kwargs version: {kwargs_time:.3f}s")
print(f"explicit params: {params_time:.3f}s")
print(f"ratio: {kwargs_time / params_time:.1f}x")
On a typical machine running CPython 3.10 or earlier, benchmarks generally show the **kwargs version running roughly 1.5–3x slower than the explicit-parameter version -- though the exact ratio varies with hardware, Python version, and call pattern. CPython 3.11 and later narrowed this gap significantly through the adaptive specializing interpreter, but overhead remains measurable in tight loops. The cost comes from building the dictionary on every call and from dictionary lookups (kwargs.get()) instead of direct local variable access. CPython optimizes local variable access with the LOAD_FAST bytecode instruction, which uses a direct array index. Dictionary lookups require hashing the key string and searching the hash table -- a small cost per call, but a measurable one across millions of iterations.
Don't optimize prematurely. For the vast majority of Python code -- web handlers, scripts, data pipelines, CLI tools -- the overhead of *args and **kwargs is negligible compared to I/O operations, database queries, or network calls. Reserve explicit parameters for inner loops and performance-critical paths where profiling has identified the overhead as meaningful. Everywhere else, the readability and flexibility benefits outweigh the cost.
Debugging When *args and **kwargs Hide the Problem
Flexible signatures are powerful precisely because they are opaque -- but opacity cuts both ways. When something goes wrong in a chain of *args, **kwargs forwarding, the error often surfaces far from its source, in a function that received the wrong type or a missing key, with a traceback that points to the innermost callee rather than the outer function where the mistake was made.
There are three practical strategies for debugging these situations.
Instrument the boundary. Add a temporary print or logging call at the entry of the wrapper to see exactly what it received:
def wrapper(*args, **kwargs):
print(f"[DEBUG] args={args!r}, kwargs={kwargs!r}") # remove before shipping
return func(*args, **kwargs)
The !r format specifier uses repr(), which shows types as well as values -- critical when you're trying to distinguish "42" (a string) from 42 (an int) in an argument you thought was numeric.
Use inspect.signature to bind arguments explicitly. The Signature.bind() method takes *args and **kwargs and returns a BoundArguments object that maps each argument to its parameter name -- or raises a TypeError immediately if the arguments don't match. This lets you validate the argument set before passing it on:
import inspect
import functools
def safe_wrapper(func):
sig = inspect.signature(func)
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
except TypeError as e:
raise TypeError(
f"Argument mismatch when calling {func.__name__}: {e}"
) from e
return func(*bound.args, **bound.kwargs)
return wrapper
This pattern catches mismatches at the wrapper boundary -- before the error buries itself inside the callee -- and produces an error message that names the problematic function. It's particularly valuable in plugin systems where you don't control the callees and can't add assertions inside them.
Examine the full traceback frame by frame. Python's traceback shows the call chain but not the argument values. For difficult bugs, use traceback.extract_stack() or a debugger breakpoint at the wrapper level to inspect args and kwargs in the local scope. In production systems, structured logging at the boundary -- logging argument keys and types without logging values for privacy -- provides the audit trail needed to reconstruct what happened.
Python 3.11 improved tracebacks significantly, adding fine-grained column markers that point directly to the expression that failed. But even with these improvements, a TypeError: unsupported operand type inside a function that received its arguments via **kwargs forwarding will still not tell you which call site passed the wrong type. The fix is always at the boundary, not the interior.
Common Gotchas and Mistakes
There are several pitfalls that catch developers regularly.
Argument order errors. Python enforces a strict left-to-right ordering: positional parameters, then *args, then keyword-only parameters, then **kwargs. This code fails:
# SyntaxError: invalid syntax
# def broken(**kwargs, *args):
# pass
Duplicate keyword arguments. If you unpack a dictionary that contains a key matching a named parameter, and also pass that parameter explicitly, Python raises a TypeError:
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
data = {"name": "Alice", "greeting": "Hey"}
# This works:
greet(**data)
# This raises TypeError -- 'name' is given twice:
# greet("Alice", **data)
Mutating kwargs. **kwargs is always a fresh dictionary -- the function receives its own copy, so modifying it does not affect the caller's data. The real risk appears in forwarding patterns: if you modify kwargs in place before passing it along, downstream functions receive your modified version, which can produce surprising behavior. If you need to alter kwargs before forwarding, create a new dictionary rather than mutating the existing one:
def safe_wrapper(**kwargs):
modified = {**kwargs, "extra": True} # new dict; kwargs remains unmodified for any further use
return modified
Confusing unpacking contexts. The * operator means different things in different positions. In a function definition, *args collects. In a function call, *args expands. In an assignment, *rest captures remaining items. This last use was formalized in PEP 3132, "Extended Iterable Unpacking":
first, *middle, last = [1, 2, 3, 4, 5]
print(first, middle, last)
# 1 [2, 3, 4] 5
The starred target in assignment unpacking produces a list, not a tuple -- different from *args in function definitions. This inconsistency has historical reasons but is a genuine source of confusion.
Mutable default arguments in combination with kwargs. A particularly subtle bug occurs when combining **kwargs with mutable default values in the functions you forward to. If the receiving function has a default like def process(items=[]) and you forward via **kwargs, the mutable default can accumulate state across calls. This isn't unique to **kwargs, but the indirection makes it harder to spot.
# Dangerous: mutable default plus kwargs forwarding
def process(items=[]):
items.append("new")
return items
def wrapper(**kwargs):
return process(**kwargs)
print(wrapper()) # ['new']
print(wrapper()) # ['new', 'new'] -- unexpected!
# Safe: use None sentinel
def process_safe(items=None):
if items is None:
items = []
items.append("new")
return items
When Not to Use *args and **kwargs
Flexible signatures come with a cost: they hide information. A function signature like def do_something(*args, **kwargs) tells the caller nothing about what arguments are expected. The function's docstring and source code become the only documentation.
The Zen of Python (PEP 20) advises that explicit approaches are preferable to implicit ones. — PEP 20, The Zen of Python (Tim Peters)
If your function takes a known, fixed set of parameters, spell them out. Use *args and **kwargs when the set of arguments is genuinely variable -- decorators, forwarding wrappers, plugin systems, and API builders. Don't use them to avoid thinking about your function's interface.
There is also a testing dimension. Functions with explicit signatures are easier to test because the parameter names serve as documentation of the function's contract. Testing a function that accepts **kwargs requires reading the function body to understand which keys it expects -- a burden that grows with codebase size and team turnover.
PEP 3102's keyword-only arguments (using a bare *) give you a middle ground: you can accept flexible positional arguments with *args while still requiring certain options to be explicitly named:
def search(query, *filters, case_sensitive=False, limit=100):
print(f"Searching '{query}' with {len(filters)} filters")
print(f" case_sensitive={case_sensitive}, limit={limit}")
search("python args", "date:2024", "type:article", limit=10)
# Searching 'python args' with 2 filters
# case_sensitive=False, limit=10
Here, case_sensitive and limit cannot be passed positionally -- the *filters parameter consumes all remaining positional arguments, forcing case_sensitive and limit to be keyword-only. This is exactly the design PEP 3102 intended.
For the strongest possible interface guarantees, consider using dataclasses or TypedDict to define your parameter contracts explicitly, and then pass instances of those classes rather than spreading arguments across **kwargs. This makes type checkers, IDE autocompletion, and refactoring tools far more effective:
from dataclasses import dataclass
@dataclass
class SearchConfig:
query: str
case_sensitive: bool = False
limit: int = 100
def search(config: SearchConfig):
print(f"Searching '{config.query}', limit={config.limit}")
search(SearchConfig(query="python args", limit=10))
The Design Philosophy: Why Python Did This
It's worth stepping back and asking a question that many tutorials never pose: why does Python have both *args and **kwargs? Why not just one mechanism? And why did Guido van Rossum make named arguments first-class citizens of the language in the first place?
Many languages offer variadic functions -- C's ... syntax, Java's varargs, JavaScript's rest parameters -- but almost none of them treat keyword arguments as a core language concept with dedicated syntax. In C, you pass a struct. In Java, you pass a Map. Python chose to bake named arguments into the calling convention itself, at the interpreter level, which is a significant commitment. The payoff is that Python functions are self-documenting at the call site in a way most languages are not: create_user(username="kandi", role="admin") reads like a sentence, not a positional incantation.
The ** operator exists because keyword arguments exist as a distinct concept. Once Python committed to treating key=value pairs in function calls as a named mapping rather than positional syntax sugar, it became natural to want a way to accept an open-ended set of them. **kwargs is the collection mechanism that flows from that commitment.
The design also reflects a deliberate stance on the tension between flexibility and correctness. Python could have required every function to declare all its arguments up front -- this is what statically typed languages tend to enforce. Instead, Python chose to give functions the ability to defer that decision, accepting variable argument sets and making sense of them at runtime. This makes Python excellent for prototyping, for building APIs with large optional parameter sets, and for constructing abstractions like decorators and frameworks. The cost is that mistakes surface later and errors are sometimes less precise. The Zen of Python does not pretend this tension doesn't exist -- it acknowledges both "Explicit is better than implicit" and "Special cases aren't special enough to break the rules." The design of *args and **kwargs lives in that tension deliberately.
The evolution across PEPs tells the same story. PEP 3102 added keyword-only arguments precisely to recover some explicitness -- you could now say "these parameters must be named, always" even in a function that also accepts variadic positionals. PEP 570 added positional-only parameters to recover explicitness in the other direction -- "these parameters are positional because their names are irrelevant or internal." The system today is not a simple design. It's the result of two decades of community experience learning where the original design was too permissive and adding precision where precision was needed -- while keeping the core mechanism intact because it works.
Python is not just a language. It is a set of decisions about what programmers should be able to say easily and what they should have to work hard to say. The argument system is one of the most carefully considered of those decisions -- shaped over two decades of PEPs, community debate, and hard-won experience.
Understanding this context changes how you use these features. *args and **kwargs are not escape hatches or shortcuts. They are expressions of a deliberate philosophy: that function interfaces should be composable, that abstractions should be transparent, and that the language should support building tools that build other tools. When you write def wrapper(*args, **kwargs), you're writing in that tradition.
The Bigger Picture
The * and ** operators are not isolated features. They're part of a coherent system that Python has refined over two decades of PEPs:
- PEP 3102 (2008) established keyword-only arguments by placing parameters after
*args, shipping in Python 3.0. - PEP 3132 (2008) extended the
*operator to assignment targets, shipping in Python 3.0. - PEP 362 (2012) formalized function signature introspection through the
inspectmodule, shipping in Python 3.3. - PEP 484 (2014) introduced type hints and the initial annotation syntax for
*argsand**kwargs, shipping in Python 3.5. - PEP 448 (2015) generalized unpacking to allow multiple
*and**operations in function calls and container literals, shipping in Python 3.5. - PEP 468 (2016) guaranteed that
**kwargspreserves insertion order, implemented in Python 3.6. - PEP 570 (2019) added the
/separator for positional-only parameters, shipping in Python 3.8. - PEP 612 (proposed 2019, shipped Python 3.10) introduced
ParamSpecfor proper type checking of*args/**kwargsforwarding. - PEP 646 (2022) added
TypeVarTuplefor variadic generic typing of*argswith heterogeneous types, shipping in Python 3.11. - PEP 692 (2022) enabled
TypedDict-based typing for heterogeneous**kwargs(available in Python 3.12).
Each PEP builds on the ones before it. Together, they give Python one of the most expressive and well-documented function argument systems in any mainstream programming language.
Understanding *args and **kwargs isn't about memorizing syntax. It's about understanding that Python treats function arguments as structured data -- tuples and dictionaries -- and gives you operators to pack and unpack that data fluidly. The asterisks are a precise, powerful notation for bridging the gap between rigid function signatures and the messy, variable reality of data flowing through a program. Once you see them as data transformation operators rather than syntactic decoration, the entire system clicks into place.
References and Further Reading
- PEP 8 -- Style Guide for Python Code (Guido van Rossum, Barry Warsaw, Nick Coghlan)
- PEP 20 -- The Zen of Python (Tim Peters, 2004)
- PEP 362 -- Function Signature Object (Brett Cannon, Yury Selivanov, Larry Hastings, Jiwon Seo, Python 3.3)
- PEP 448 -- Additional Unpacking Generalizations (Joshua Landau, February 2015, Python 3.5)
- PEP 468 -- Preserving the order of **kwargs in a function (Eric Snow, Python 3.6)
- PEP 484 -- Type Hints (Guido van Rossum, Jukka Lehtosalo, Lukasz Langa, September 2014, Python 3.5)
- PEP 570 -- Python Positional-Only Parameters (Pablo Galindo et al., 2019, Python 3.8)
- PEP 584 -- Add Union Operators to dict (Brandt Bucher, Steven D'Aprano, Python 3.9)
- PEP 612 -- Parameter Specification Variables (Mark Mendoza, December 2019, Python 3.10)
- PEP 646 -- Variadic Generics (Mark Mendoza, Matthew Rahtz, Pradeep Kumar Srinivasan, Vincent Siles, Python 3.11)
- PEP 692 -- Using TypedDict for more precise **kwargs typing (Franek Magiera, May 2022, Python 3.12)
- PEP 3102 -- Keyword-Only Arguments (Talin, April 2006, Python 3.0)
- PEP 3132 -- Extended Iterable Unpacking (Georg Brandl, June 2007, Python 3.0)
- Python docs -- inspect module: Introspecting live objects
- Python docs -- dis module: Disassembler for Python bytecode