Defining a Function with an Arbitrary Number of Arguments in Python: Complete *args and kwargs Guide

Defining a function with an arbitrary number of arguments in python

I remember writing functions early in my Python journey that could only accept a fixed number of parameters, and running into walls whenever I needed to handle a variable amount of input — sometimes two arguments, sometimes five, sometimes none. Once I learned *args and **kwargs, that entire category of problem disappeared. In this guide, I’ll cover exactly how these work internally, when to use each, and the patterns I rely on daily.

The Core Idea

*args and **kwargs let a function accept an arbitrary number of positional and keyword arguments, respectively. The names args and kwargs are just convention — the actual mechanism is the * and ** operators, which I could technically pair with any valid variable name.

def demo(*args, **kwargs):
    print("Positional args:", args)
    print("Keyword args:", kwargs)

demo(1, 2, 3, name="Alice", age=30)

Output:

Positional args: (1, 2, 3)
Keyword args: {'name': 'Alice', 'age': 30}

How *args Works

The single asterisk collects any extra positional arguments into a tuple. Inside the function, args behaves like any other tuple — I can iterate over it, index into it, or unpack it further.

def total(*args):
    return sum(args)

print(total(1, 2, 3))       # 6
print(total(10, 20))        # 30
print(total())               # 0

I can also mix *args with regular positional parameters, as long as *args comes after them:

def greet(greeting, *names):
    for name in names:
        print(f"{greeting}, {name}!")

greet("Hello", "Alice", "Bob", "Carol")

Output:

Hello, Alice!
Hello, Bob!
Hello, Carol!

How **kwargs Works

The double asterisk collects any extra keyword arguments into a dictionary, where the keys are the argument names (as strings) and the values are whatever was passed in.

def print_profile(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_profile(name="Alice", age=30, city="Boston")

Output:

name: Alice
age: 30
city: Boston

Combining Regular Parameters, *args, and **kwargs

Python enforces a strict ordering for how these can appear together in a function signature:

def func(positional, *args, keyword_only=None, **kwargs):
    ...

The order must always be:

  1. Standard positional parameters
  2. *args
  3. Keyword-only parameters (parameters that come after *args and must be passed by name)
  4. **kwargs

Here’s a realistic example combining all of them:

def build_request(url, *args, method="GET", **headers):
    print("URL:", url)
    print("Extra positional args:", args)
    print("Method:", method)
    print("Headers:", headers)

build_request("https://api.example.com", "extra1", method="POST", Authorization="Bearer xyz")

Output:

URL: https://api.example.com
Extra positional args: ('extra1',)
Method: POST
Headers: {'Authorization': 'Bearer xyz'}

Unpacking Arguments When Calling a Function

* and ** aren’t just for defining functions — I use them just as often for calling functions, to unpack a list or dictionary into individual arguments.

def add(a, b, c):
    return a + b + c

numbers = [1, 2, 3]
print(add(*numbers))  # unpacks to add(1, 2, 3) -> 6

params = {"a": 10, "b": 20, "c": 30}
print(add(**params))  # unpacks to add(a=10, b=20, c=30) -> 60

This is incredibly useful when the data I have is already structured as a list or dict and I need to feed it into a function that expects individual arguments.

Why I Use *args and **kwargs: Real-World Patterns

Writing Flexible Wrapper Functions and Decorators

This is, hands down, the most important real-world use case I run into. When writing a decorator, I don’t know in advance what arguments the wrapped function will take — *args and **kwargs let the wrapper forward anything through to the original function.

import functools
import time

def timer(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__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_add(a, b):
    time.sleep(0.1)
    return a + b

print(slow_add(3, 4))

Without *args, **kwargs, I’d have to write a separate wrapper for every possible function signature, which obviously doesn’t scale.

Subclassing and Passing Arguments to super().__init__()

I use this constantly when extending existing classes, especially in frameworks like Django or when working with class hierarchies I don’t fully control:

class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

class Dog(Animal):
    def __init__(self, *args, breed, **kwargs):
        super().__init__(*args, **kwargs)
        self.breed = breed

d = Dog("Rex", "Woof", breed="Labrador")
print(d.name, d.sound, d.breed)

Building Flexible APIs

When designing a utility function meant to be used in many different contexts, **kwargs gives me a way to accept optional configuration without bloating the function signature with a dozen optional parameters.

def create_chart(data, **options):
    title = options.get("title", "Untitled Chart")
    color = options.get("color", "blue")
    print(f"Creating '{title}' chart in {color} using {len(data)} points")

create_chart([1, 2, 3], title="Sales", color="green")
create_chart([4, 5, 6])

Internals: How Python Handles This at the Bytecode Level

When Python parses a function call, it separates the arguments into positional and keyword groups before binding them to the function’s parameters. For a function defined with *args, any positional arguments beyond the named parameters get packed into a new tuple object at call time. Similarly, **kwargs triggers the construction of a new dictionary. This packing has a small, generally negligible cost — for the overwhelming majority of use cases, the flexibility is well worth it.

I can inspect this via the inspect module, which is genuinely useful for building generic tooling around function signatures:

import inspect

def example(a, b, *args, c=1, **kwargs):
    pass

sig = inspect.signature(example)
for name, param in sig.parameters.items():
    print(name, param.kind)

Output:

a POSITIONAL_OR_KEYWORD
b POSITIONAL_OR_KEYWORD
args VAR_POSITIONAL
c KEYWORD_ONLY
kwargs VAR_KEYWORD

Common Mistakes I’ve Made

  • Assuming **kwargs preserves insertion order in older Python versions. Since Python 3.7, dictionaries (and therefore kwargs) preserve insertion order as a language guarantee, but I still see people write defensive code for this that isn’t necessary anymore on modern Python.
  • Putting *args after keyword arguments with defaults incorrectly and hitting a SyntaxError. The ordering rules aren’t optional — Python enforces them at parse time.
  • Overusing **kwargs to the point where a function’s actual accepted parameters become undiscoverable without reading the entire function body. If a function always expects specific named parameters, I write them explicitly rather than hiding them inside **kwargs.
  • Forgetting that *args and **kwargs are local copies — reassigning kwargs = {} inside the function doesn’t affect the caller at all, following the same reference/reassignment rules covered in argument mutability.

FAQs

Do I have to name them args and kwargs? No — those are just conventions. *values and **options work exactly the same way; only the * and ** symbols matter to Python, not the names.

Can I have more than one *args or **kwargs in a single function definition? No, a function can only have one *args-style parameter and one **kwargs-style parameter.

What’s the difference between *args in a function definition vs. a function call? In a definition, it collects extra positional arguments into a tuple. In a call, it unpacks an iterable into separate positional arguments. The same duality applies to **kwargs with dictionaries.

Can *args and **kwargs be empty? Yes — if no extra positional or keyword arguments are passed, args is simply an empty tuple () and kwargs is an empty dictionary {}.

Summary

*args and **kwargs are what make Python functions genuinely flexible — letting me write decorators, wrapper functions, and extensible APIs without hardcoding a fixed argument list. Once I understood that they simply pack extra positional arguments into a tuple and extra keyword arguments into a dictionary (and unpack the reverse way when calling), the pattern became second nature. My rule of thumb: use explicit named parameters whenever the function’s interface is well-defined, and reach for *args/**kwargs when I genuinely need to forward or accept an unknown, variable set of arguments.

References

Total
0
Shares

Leave a Reply

Previous Post
Defining and calling simple functions in python

Defining and Calling Simple Functions in Python: Complete Function Creation and Invocation Fundamentals Guide

Next Post
Lambda (Inline/Anonymous) Functions in python

Lambda (Inline/Anonymous) Functions in Python: Complete Anonymous Function Creation and Usage Guide

Related Posts