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

Final Exam & Certification

Complete this tutorial and pass the 15-question final exam to earn a downloadable certificate of completion.

skip to exam

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. According to the Python typing documentation, P.args and P.kwargs are purpose-built attributes: the former annotates only the *args position of a wrapper (carrying the captured positional argument types), and the latter annotates only **kwargs (carrying the captured keyword argument types). Neither can be used outside that specific context. (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 Mark Mendoza, Matthew Rahtz, Pradeep Kumar Srinivasan, 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)

Real-world Python patterns

Knowing the mechanics is one thing. Recognizing the patterns that appear repeatedly in serious Python code is another.

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.

A junior developer writes the following API client wrapper. It works, but a senior engineer flags three separate design problems during review. Which option identifies all three correctly?
The team needs a function that wraps an HTTP library call, enforces a timeout, accepts optional per-request headers, and is usable as a decorator for retrying failed calls.
import requests def api_call(url, *args, **kwargs): kwargs["timeout"] = 30 return requests.get(url, *args, **kwargs) def retry(func, retries=3): def wrapper(*args, **kwargs): for i in range(retries): try: return func(*args, **kwargs) except Exception: pass return wrapper @retry def fetch(url): return api_call(url)

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}")
This function definition raises an error immediately when Python parses it. What is wrong?
The developer wants a function that accepts keyword extras and also collects extra positional arguments. It fails before a single call is made.
def process(**kwargs, *args): print(args, kwargs)

Can you use *args and **kwargs with lambda?

Lambda expressions follow the same parameter rules as def functions, including support for *args, **kwargs, and keyword-only parameters. The asterisk and double-asterisk prefixes work identically — the only constraint is that a lambda body must be a single expression, not a block of statements.

adder = lambda *args: sum(args)
print(adder(1, 2, 3, 4))   # 10
print(adder())             # 0

tagger = lambda **kwargs: ", ".join(f"{k}={v}" for k, v in kwargs.items())
print(tagger(env="prod", region="us-east"))
# env=prod, region=us-east

flexible = lambda *args, **kwargs: (args, kwargs)
print(flexible(1, 2, flag=True))
# ((1, 2), {'flag': True})

The practical value is limited — a lambda with *args or **kwargs usually signals that a named def would be clearer. The idiom does appear legitimately in sorting keys and simple callback wrappers, but if the logic inside grows beyond a single expression, convert it to a def.

One area where the lambda *args form genuinely earns its place is as a null-op placeholder in testing or configuration — a callable that accepts anything and returns a fixed value:

# A no-op handler that accepts any arguments and does nothing
noop = lambda *args, **kwargs: None

# Used in test doubles or default callbacks
def run_pipeline(data, on_complete=None):
    result = process(data)
    if on_complete:
        on_complete(result, status="ok", elapsed=0.12)

run_pipeline(data, on_complete=noop)
Pro Tip

What about def f(*args) versus def f(args)? They are fundamentally different. def f(*args) collects an arbitrary number of individual positional arguments into a tuple — the caller writes f(1, 2, 3). def f(args) is a regular single-parameter function — the caller must pass one object, typically a list or tuple explicitly: f([1, 2, 3]). With *args, the packing happens automatically; with a plain args parameter, the caller is responsible for wrapping the collection before the call.

A developer writes def total(args): return sum(args) and calls it as total(1, 2, 3). What happens?

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. For a full treatment of how functools.wraps works inside a decorator, including edge cases with class-based decorators and stacked wrappers, that article covers the complete picture.

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.

How to use *args and **kwargs

The following six steps cover everything you need to go from a basic variadic function to one that is fully typed and introspectable at runtime.

  1. Define a function with *args. Prefix a parameter name with a single asterisk in the function definition. Python collects all extra positional arguments into an immutable tuple bound to that parameter.
    def total(*amounts):
        return sum(amounts)
    
    print(total(10, 20, 30))  # 60
  2. Define a function with **kwargs. Prefix a parameter name with a double asterisk to collect all extra keyword arguments into a mutable dictionary. Keys are strings; values are whatever the caller passed.
    def configure(**options):
        print(options)
    
    configure(debug=True, timeout=30)
    # {'debug': True, 'timeout': 30}
  3. Combine *args and **kwargs in one signature. Both collectors can coexist in a single function. Follow the required ordering: positional-only params, standard params, *args, keyword-only params, then **kwargs.
    def func(first, *args, label="default", **kwargs):
        print(first, args, label, kwargs)
  4. Use keyword-only parameters after *args. Any parameter declared after *args (or a bare * separator) can only be satisfied by a named argument at the call site, never by a positional one. This avoids accidental positional consumption.
    def log(*messages, level="INFO"):
        for msg in messages:
            print(f"[{level}] {msg}")
    
    log("started", "connected", level="DEBUG")
  5. Unpack iterables and dicts at the call site. Use *iterable to spread a list or tuple into positional arguments, and **dict to spread a dictionary into keyword arguments. These are the inverse of collection — one packs, one spreads.
    def connect(host, port):
        print(f"Connecting to {host}:{port}")
    
    params = {"host": "localhost", "port": 5432}
    connect(**params)  # same as connect(host="localhost", port=5432)
  6. Add precise type annotations with ParamSpec or TypedDict+Unpack. For decorators that forward arguments unchanged, use ParamSpec (Python 3.10+). For **kwargs with per-key types, use TypedDict with Unpack (Python 3.12+). Both are in the standard typing module and supported by mypy and pyright.
    from typing import ParamSpec, Callable, TypeVar
    
    P = ParamSpec("P")
    R = TypeVar("R")
    
    def logged(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

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.

Can I use *args and **kwargs in a lambda?

Yes. Lambda expressions follow the same parameter rules as def functions. lambda *args: sum(args), lambda **kwargs: kwargs, and lambda *args, **kwargs: (args, kwargs) are all valid. The only restriction specific to lambdas is that the body must be a single expression — not a multi-statement block. In practice, a lambda complex enough to need variadic arguments is usually a sign that a named def would be clearer.

What is the difference between def f(*args) and def f(args)?

They are entirely different. def f(*args) uses the variadic collector — the caller writes f(1, 2, 3) and Python automatically packs those three values into a tuple named args. def f(args) is an ordinary single-parameter function — the caller must pass exactly one object, such as a list: f([1, 2, 3]). Calling f(1, 2, 3) against the plain version raises TypeError: f() takes 1 positional argument but 3 were given. The name args has no special meaning on its own — only the asterisk prefix enables variadic collection.

What error does Python raise if I put **kwargs before *args?

A SyntaxError at parse time — before any code in the file runs. Python enforces parameter ordering as a grammar rule: the legal sequence is positional-only, standard, *args, keyword-only, then **kwargs. Writing (**kwargs, *args) violates this grammar and the parser rejects the file immediately with SyntaxError: invalid syntax. The fix is to swap the order: def f(*args, **kwargs).

Does functools.wraps matter when writing a variadic decorator?

Yes, and it is easy to forget. Without @functools.wraps(func), the wrapper function replaces the original's __name__, __doc__, __annotations__, and __wrapped__ attributes. This breaks documentation generators, help() output, and — critically for typed code — static type checkers that inspect function metadata. With @functools.wraps(func), the wrapper copies these attributes from the original function, so the decorator is transparent to introspection tools. It is one line and should be a reflex whenever you write a def wrapper(*args, **kwargs) inside a decorator.

What happens if I unpack a dictionary with non-string keys using **?

Python raises a TypeError: keywords must be strings at the call site. All keyword argument names — whether passed explicitly or via ** unpacking — must be strings. A plain dict can hold integer or mixed-type keys, but attempting to unpack one with ** into a function call is rejected at runtime. If you need to pass such a mapping into a function, pass it as a single positional argument instead of unpacking it.

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.
  10. The asterisk is what matters, not the name: def f(*args) collects any number of positional arguments into a tuple automatically. def f(args) is an ordinary single-parameter function that expects the caller to pass one object — the name args carries no special meaning without the prefix.
  11. **kwargs before *args is a parse-time SyntaxError: Parameter ordering is enforced by Python's grammar, not at runtime. Writing (**kwargs, *args) fails the moment the parser reads the definition. The correct order is always (*args, **kwargs).
  12. Lambda supports variadic parameters with the same syntax: lambda *args: sum(args) is valid. The body must remain a single expression, so lambdas with variadic parameters are best kept simple — a multi-statement variadic function belongs in a def.
  13. Always use @functools.wraps in variadic decorators: Without it, the wrapper replaces the original function's __name__, __doc__, and __annotations__. One line of @functools.wraps(func) keeps the decorator transparent to introspection tools and type checkers.

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.

Certificate of Completion

Final Exam

Pass mark: 80% · Score 80% or higher to receive your certificate

Enter your name as you want it to appear on your certificate, then start the exam. Your name is used only to generate your certificate and is never transmitted or stored anywhere.

Question 1 of 10