*args and **kwargs — What They Actually Do
Think of *args as a 'catch-all box for extra positional items' and **kwargs as a 'catch-all box for extra labeled items.' If you throw an item into the function without a label, it goes into the positional box, even if you conceptually meant it to be a labeled item.
The Setup
You are writing a high-throughput logging wrapper that accepts metadata and metrics, forwarding them to third-party endpoints. You decide to use *args and **kwargs to make your wrapper completely generic.
What Does This Print?
def audit_logger(event_type, *args, **kwargs):
# Assume standard payload validation
print(f"Event: {event_type}")
# We expect "user_id" to always be a keyword argument if provided
user_id = kwargs.pop("user_id", None)
print(f"Processing for user: {user_id}")
# Developer calls it using positional argument for what they thought was 'user_id'
audit_logger("login_attempt", "usr_99882")
The Output
The wrapper fails to capture the user ID. Because "usr_99882" was passed as a positional argument, it was captured by *args rather than **kwargs. Because the function signature uses generic argument collectors, it silently accepts wrong parameter structures without throwing TypeError, hiding structural API failures from unit tests.
Why Python Does This
In CPython, *args compiles into a tuple allocation, and **kwargs compiles into a dictionary allocation. During a function call, argument resolution maps positional values first. Any remaining positional arguments are stuffed into a freshly allocated tuple bound to the args variable. Keyword arguments not matching explicit parameter names are mapped into a new dictionary bound to kwargs. If you construct generic wrappers, Python cannot enforce positional-versus-keyword contracts automatically, leading to silent dictionary retrieval misses and heap allocation overhead on every single wrapper call.
The Fix
# Force precise keyword-only arguments using the bare * separator
def audit_logger(event_type, *, user_id=None, **extra_metadata):
print(f"Event: {event_type}")
# This guarantees 'user_id' can never be passed positionally
print(f"Processing for user: {user_id}")
try:
# This call will now fail with a clear TypeError during development
audit_logger("login_attempt", "usr_99882")
except TypeError as e:
print(f"Caught expected type error: {e}")
Placing a bare asterisk * in the function signature after positional arguments and before keyword arguments makes all subsequent parameters keyword-only. This forces callers to explicitly name user_id as user_id=value, preventing it from being accidentally captured by *args.
How This Fails in Real Systems
A telemetry aggregation layer processed 50,000 requests per second. A developer introduced a global monitoring decorator using *args and **kwargs. The massive rate of tuple and dictionary allocations triggered garbage collection pauses, reducing throughput by 30% and causing ingestion timeouts.
Key Takeaway
*args capturing unintended values.