Variadic Arguments (*args and **kwargs)

Updated

September 7, 2026

Variadic Arguments (*args and **kwargs)

After reading this chapter, you will master variadic parameters, construct flexible APIs using *args and **kwargs, and write transparent argument forwarding wrappers.

Mental model

Variadic parameters allow a function to accept an arbitrary number of positional or keyword arguments: - *args: Collects extra positional arguments into a tuple. - **kwargs: Collects extra keyword arguments into a dict.

Call Site:
  send_payload("POST", 200, "fast", verbose=True, timeout=10)
      │           │         │          │            │
      ▼           └─────────┼──────────┼────────────┼────────────────┐
  method: "POST"            ▼          ▼            ▼                ▼
                     ┌───────────────┐        ┌─────────────────────────┐
                     │ args: (tuple) │        │ kwargs: (dict)          │
                     │ (200, "fast") │        │ {"verbose": True, ...}  │
                     └───────────────┘        └─────────────────────────┘

At call sites, the operators can be reversed: * unpacks an iterable into positional arguments, and ** unpacks a mapping into keyword arguments.


Minimal example

Save as variadic_demo.py:

# variadic_demo.py
def format_log(level: str, *messages: str, **metadata: str | int) -> str:
    joined_msg = " | ".join(messages)
    meta_str = " ".join(f"[{k}={v}]" for k, v in metadata.items())
    return f"[{level.upper()}] {joined_msg} {meta_str}".strip()

def main() -> None:
    # Multiple positional messages, multiple keyword attributes
    entry = format_log(
        "info",
        "Connection established",
        "Handshake completed",
        host="node-alpha",
        port=9000,
        region="us-west-2"
    )
    print(entry)

if __name__ == "__main__":
    main()

Run via uv run python variadic_demo.py:

[INFO] Connection established | Handshake completed [host=node-alpha] [port=9000] [region=us-west-2]

Worked examples

Case 1: Argument forwarding in decorator wrappers

When authoring wrappers or logging hooks, you must forward arguments cleanly to the wrapped target function:

# wrapper_forward.py
import time
from collections.abc import Callable
from typing import Any

def time_execution(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
    start = time.perf_counter()
    result = func(*args, **kwargs)  # Perfect forwarding with * and **
    elapsed = time.perf_counter() - start
    print(f"Function '{func.__name__}' executed in {elapsed * 1000:.3f}ms")
    return result

def compute_heavy_task(base: int, power: int, tag: str = "computation") -> int:
    return base ** power

if __name__ == "__main__":
    out = time_execution(compute_heavy_task, 2, 500_000, tag="benchmark")
    print(f"Task completed: result has {len(str(out))} digits")

Run:

uv run python wrapper_forward.py

Case 2: Unpacking dictionaries and lists at call sites

You can dynamically assemble parameters in dictionaries and pass them into functions using **:

# dynamic_call.py
def connect_db(host: str, port: int, database: str, timeout: int = 30) -> None:
    print(f"Connecting to {database} at {host}:{port} (timeout={timeout}s)")

if __name__ == "__main__":
    config = {
        "host": "postgresql.internal",
        "port": 5432,
        "database": "analytics",
        "timeout": 60,
    }
    # Unpack config dictionary directly into function arguments
    connect_db(**config)

Run:

uv run python dynamic_call.py

Pitfalls

Pitfall 1: Signature ordering violations

Python enforces strict parameter ordering: 1. Standard positional parameters 2. Positional-only boundary / 3. *args 4. Keyword-only parameters 5. **kwargs

Placing *args after **kwargs raises a SyntaxError.

Pitfall 2: Overusing **kwargs and destroying API readability

While **kwargs offers ultimate flexibility, overusing it hides what parameters a function actually accepts from documentation, autocomplete, and type checkers. Prefer explicit named parameters for core options.


Exercises

  1. Write a function product(*numbers: float) -> float that computes the product of all passed numbers, returning 1.0 if no arguments are supplied.
  2. Implement a function merge_configs(*configs: dict) -> dict that merges an arbitrary number of configuration dictionaries into a single dictionary in order.
  3. Write a wrapper function log_calls(func, *args, **kwargs) that prints all passed arguments before executing func.

Further reading

  • Python Tutorial: Arbitrary Argument Lists.
  • PEP 448: Additional Unpacking Generalizations.
  • Fluent Python (2nd Edition): Chapter 7: Functions as First-Class Objects.