Python Variadic Arguments (*args, **kwargs): Complete Guide

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
Note

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
Watch out

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.

What type does Python use to store the collected values inside a *args parameter?

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'}
Pro Tip

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.

This function is supposed to print only the caller's original config dict after the call — but it prints something unexpected. What is the bug?
The developer expects the output to be {'host': 'localhost', 'port': 5432}, but after the call they see something different.
def apply_defaults(**kwargs): kwargs["debug"] = False # set a default internally kwargs["timeout"] = 30 config = {"host": "localhost", "port": 5432} apply_defaults(**config) print(config) # what does this print?

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:

Python function parameter types and their required ordering
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)

Performance note

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.

Given this function, what does log("disk full", "retry failed") print on the first line of output?
def log(level="INFO", *messages): for msg in messages: print(f"[{level}] {msg}")

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
Diagram: Python function parameter ordering from left to right pos_only before / Python 3.8+ pos_or_kw standard named params *args extra positional → tuple kw_only after * — must be named **kwargs extra keyword → dict Python function parameter ordering — left to right
The five parameter categories in a Python function signature and their required order. Reversing or mixing this order raises a SyntaxError.

Frequently asked questions

Can I have two *args parameters 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 a SyntaxError: duplicate * argument at parse time.

Does **kwargs preserve insertion order?

Yes, since Python 3.7. The dict built-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 over kwargs.items() gives you that same order.

What is the difference between *args in a definition and * at a call site?

In a function definition, *args collects extra positional arguments into a tuple. At a call site, *iterable unpacks 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 *args and **kwargs in older Python versions?

Basic annotations (*args: int, **kwargs: str) have been available since PEP 484 (Python 3.5). ParamSpec from PEP 612 requires Python 3.10 or the typing_extensions backport. TypedDict with Unpack for per-key **kwargs typing (PEP 692) requires Python 3.12 or typing_extensions >= 4.4.

Is *args slow?

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 **kwargs is also defined?

Named parameters take priority. If a function defines both an explicit parameter called timeout and a **kwargs collector, any call using timeout= as a keyword argument will bind to the named parameter — it will not appear inside kwargs. Only keyword arguments that have no matching named parameter end up in the **kwargs dict.

Can **kwargs keys 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 regular dict can hold non-string keys, but attempting to unpack one with ** raises a TypeError: keywords must be strings at 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=None and omitting the key entirely?

They are different. When a function body reads an optional kwarg with kwargs.get("key", default), passing key=None stores the value None in the dict — .get() returns None, 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 check if "key" not in kwargs rather than if kwargs.get("key") is None.

Key Takeaways

  1. *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_list at the call site to unpack it first.
  2. **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.
  3. Keyword-only parameters (PEP 3102) are the safe alternative to manual parsing: Instead of pulling options out of **kwargs with .get(), declare them explicitly after *args or a bare *. The interpreter enforces the keyword requirement; you don't have to.
  4. 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.
  5. Use inspect.signature() to read variadic signatures at runtime: Parameter.VAR_POSITIONAL and Parameter.VAR_KEYWORD let you detect and adapt to variadic callables programmatically — the foundation of framework dispatch and documentation tooling.
  6. 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.
  7. Default parameters before *args are effectively unreachable positionally: Any positional argument will consume the slot before *args ever collects extras. Move optional settings after *args as keyword-only parameters — that is the only way to make defaults genuinely optional.
  8. **kwargs mutation 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.
  9. key=None and omitting a key are not the same thing: When reading optional kwargs, check if "key" not in kwargs to distinguish a caller who passed None intentionally 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.