← Python Code Loops, Functions & Scopes
Browse Python Concepts

*args and **kwargs — What They Actually Do

Mental Model

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.

Rule: When designing function signatures, enforce keyword-only arguments using a bare asterisk * to protect API interfaces from silent positional argument swallowing.

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?

Broken code
Python
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")
Predict what the code prints. Will user_id be successfully extracted as "usr_99882"?

The Output

What actually happens
Event: login_attempt Processing for user: None

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

Corrected pattern
Python
# 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

When designing function signatures, enforce keyword-only arguments using a bare asterisk * to protect API interfaces from silent positional argument swallowing.
Common mistake: Developers inadvertently pass arguments positionally when they intended them to be keyword arguments, leading to silent data loss or incorrect processing due to *args capturing unintended values.