Skip to main content

Command Palette

Search for a command to run...

How Python Handles Unknown Arguments: A Deep Dive into *args and **kwargs

Write functions that adapt to anything, without breaking a sweat ๐Ÿ˜…

Published
โ€ข10 min read
How Python Handles Unknown Arguments: A Deep Dive into *args and **kwargs
M
I'm Moussa, a Beninese Software Engineer based in Rwanda. I took what many considered an unlikely path, pivoting from Diplomacy and International Relations to Software Engineering. That career switch taught me something I carry into everything I do: the best learning happens when you're willing to start from zero. I work as a full-time software engineer and contribute to nonprofits supporting African students and sport talents in their education. I'm passionate about EdTech and AI and their potential to empower learners across Sub-Saharan Africa. Writing is how I process what I learn and give it back โ€” every article here is something I wish I'd found when I was figuring it out myself. Fun facts: I'm a certified IJF judo instructor, I taught myself English through YouTube, and I'm always one rabbit hole away from picking up something new.

If you've spent any time reading Python code, you've probably come across function signatures that look like this:

def some_function(*args, **kwargs):
    pass

And wondered, what are those asterisks doing there? What are *args and **kwargs? Why does every Python codebase seem to have them?

By the end of the article, you'll have a complete understanding of what they are, how they work, and more importantly why they exist. We'll build up from the problem they solve, through the mechanics, all the way to a real-world decorator you can use in your own projects.


Before We Begin: Two Types of Arguments

Python functions accepts two distinct types of arguments. Understanding the difference is essential before diving into *args and **kwargs.

Positional Arguments

A positional argument is passed by position. In other words, its meaning is determined by where it appears in the function call:

def create_user(name, age, city):
    print(f"{name}, {age}, {city}")

create_user("Moussa", 27, "Porto-Novo")
# Moussa, 27, Porto-Novo

Here, "Moussa" maps to name, 27 maps to age, and Porto-Novo maps to city, purely based on their order,

Swap them and the meaning changes entirely:

create_user("Porto-Novo", 27, "Moussa")
# Porto-Novo, 27, Moussa (WRONG)

Keyword Arguments

A keyword argument is a passed by name. You explicitly state which parameter receives which value, so order no longer matters:

create_user(city="Porto-Novo", name="Moussa", age=27)
# Moussa, 27, Porto-Novo (correct, despite different order)

The same function accepts both styles. Python handles they differently under the hook, and that distinction is exactly what *args and **kwargs are built on.

๐Ÿ’ก
Parameter is the variable name defined in the function signature. Argument is the actual value passed when calling the function.
def greet(name):   # 'name' is a PARAMETER
    print(name)

greet("Moussa")    # "Moussa" is an ARGUMENT

The Problem: Functions With Fixed Arguments

Let's start with something familiar. Here's a basic Python function:

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

print(add(1, 2))  # 3

This works perfectly, as long as you always have exactly two numbers to add. But what if you want to add three? Or five? Or you don't know in advance how many you'll have?

print(add(1, 2, 3))
# TypeError: add() takes 2 positional arguments but 3 were given

Python refuses. The function signature is a "contract": exactly two arguments. No more, no less.

You could try to work around it by writing separate functions:

def add_two(a, b):
    return a + b

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

But this obviously doesn't scale. You'd need a new function for every possible number of arguments. There has to be a better way. Don't you think so ๐Ÿ˜€? That's exactly what *args was designed to solve.


Part 1: *args - Collecting Unlimited Positional Arguments

Here's the solution:

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

print(add(1, 2))           # 3
print(add(1, 2, 3))        # 6
print(add(1, 2, 3, 4, 5))  # 15

The * in the function signature tells Python: collect all positional arguments into a single tuple. Inside the function, args is just a regular tuple. You can iterate it, index it, pass it to sum(), or anything else you'd do with a normal tuple.

The * Is the Mechanism, Not the Name

This is important: the * is what does the work, not the word args. You can call it anything:

def add(*numbers):
    return sum(numbers)

def add(*values):
    return sum(values)

Both work identically. The name args is a widely adopted convention. You'll see it in almost every Python codebase, but it's just a name.

Mixing Fixed Arguments With *args

You can combine fixed arguments with args. The fixed ones must come first:

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

greet("Hello", "Fabien", "Yves", "John")
# Hello, Fabien!
# Hello, Yves!
# Hello, John!

greeting captures the first argument, and *names collects everything else into a tuple.

Common Mistake: Putting *args Before Fixed Arguments

def greet(*names, greeting):
    pass

greet("Alice", "Bob", "Hello")
# TypeError: greet() missing 1 required keyword-only argument: 'greeting'

When *args appears before a named parameter, that named parameter becomes keyword-only. As a result, it must be passed by name, not position. This is a common source of confusion. Keep fixed positional arguments before *args and you'll avoid it.

Mental Model

*args = "Pack all positional arguments into a tuple called args".


Part 2: **kwargs - Collecting Unlimited Keyword Arguments

Python functions also accept keyword arguments, arguments passed by name:

def create_user(name, age, city):
    print(f"{name}, {age}, {city}")

create_user(name="Moussa", age=27, city="Porto-Novo")

What if you don't know which keyword argument will be passed in advance? That's where **kwargs comes in:

def create_user(**kwargs):
    print(kwargs)

create_user(name="Moussa", age=27, city="Porto-Novo")
# {'name': 'Moussa', 'age': 27, 'city': 'Porto-Novo'}

The ** tells Python: collect all keyword arguments into a dictionary. The argument names become keys, and the values you pass become the dictionary values.

Inside the function, kwargs is just a regular dictionary:

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

create_user(name="Moussa", age=27, city="Porto-Novo")
# name: Moussa
# age: 27
# city: Porto-Novo

Accessing Specific Keys Safely

When you know which keys you're looking for, use .get() with a default value:

def create_server(**kwargs):
    host = kwargs.get("host", "localhost")
    port = kwargs.get("port", 8000)
    debug = kwargs.get("debug", False)
    print(f"Starting {host}:{port} | debug={debug}")

create_server(port=3000)
# Starting localhost:3000 | debug=False
๐Ÿ’ก
.get() safely returns the default value if a key is missing, rather than throwing a KeyError. Note that the default value is the second argument you pass to .get()

Mental Model

**kwargs = "Pack all keyword arguments into a dictionary called kwargs

Same pattern as *args, but with a different container:

  • *args: Positional arguments (tuple)

  • **kwargs: Keyword arguments (dict)


Part 3: The * Unpack Direction - and ** at the Call Site

Here's the part that trips most people up: the * and ** operators work in both directions.

  • In a function definition, they pack arguments in

  • In a function call, they unpack a collection out

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

numbers = [1, 2, 3]
print(add(*numbers))  # โœ… Unpacks the list into positional arguments โ†’ 6

details = {"a": 1, "b": 2, "c": 3}
print(add(**details))  # โœ… Unpacks the dict into keyword arguments โ†’ 6

The * doesn't care whether you give it a list or a tuple. It works on any iterable:

my_tuple = (1, 2, 3)
my_list = [1, 2, 3]

print(add(*my_tuple))  # โœ… 6
print(add(*my_list))   # โœ… 6
๐Ÿ’ก
An iterable is any object Python can loop over. Under the hook, it is simply an object that implements the __iter__(). method, which returns an iterator that yields values one at a time.

The ** operator only works on mappings (objects with key-value pairs). For example, a set has no keys, so Python has no idea which value maps to which argument:

my_set = {"Moussa", "Porto-Novo"}
some_function(**my_set)  # โŒ TypeError โ€” sets have no keys

The Mental Model

Syntax Where What it does Result
*args Definition Packs positional args Tuple
**kwargs Definition Packs keyword args Dict
*iterable Call Unpacks into positional args Spread
**dict Call Unpacks into keyword args Spread

Same symbol, opposite direction. Once you internalize the table above, the behavior becomes predictable everywhere you encounter it.


Part 4: Combining *args and **kwargs

You can use both in the same function. Python automatically routes each argument to the right place. No name (position) argument attached goes to args, name argument attached goes to kwargs:

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

describe(1, 2, 3, name="Moussa", city="Porto-Novo")
# Positional: (1, 2, 3)
# Keyword: {'name': 'Moussa', 'city': 'Porto-Novo'}

The Order Rule

When combining everything, Python enforces a strict order:

def func(fixed, *args, **kwargs):
    pass
  1. Fixed positional arguments first

  2. *args second

  3. **kwargs last

Breaking this order produces an immediate SyntaxError and Python won't even run the file.

Empty Defaults

When no arguments are passed, *args and **kwargs don't become None. They become their empty defaults:

def safe_summary(*args, **kwargs):
    print(args)   # ()  โ† empty tuple
    print(kwargs) # {}  โ† empty dict

safe_summary()

This matters because code like for item in args still works safely with no arguments. None would crash it.


Part 5: The Wrapper Pattern - Where This Gets Powerful

The most important real-world use of *args and **kwargs I know is the wrapper pattern: forwarding arguments through a function without caring what they are.

Here is the code idea:

def log_call(func, *args, **kwargs):
    print(f"Calling {func.__name__}")
    result = func(*args, **kwargs)
    print(f"Done")
    return result

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

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

log_call(add, 1, 2)
log_call(greet, "Moussa", greeting="Hey")

log_call doesn't know or care what add or greet need. It collects everything with *args, **kwargs and passed it straight through. The structure is always the same:
Collect --> Do something --> Forward


Part 6: Building a Real Decorator

The wrapper pattern is the foundation of Python decorators. A decorator is a function that takes a function, wraps it with extra behavior, and returns the wrapped version.

๐Ÿ’ก
You can learn more about the concept of decorators in Python on my blog. I have a comprehensive series that teaches you everything from the ground up.

Here's a @timed decorator that measures how long any function takes to run:

import time

def timed(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        elapsed_time = (time.time() - start_tim) * 1000
        print(f"Ran {func.__name__} in ~{elapsed_time:.0f}ms")
        return result
    return wrapper

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

@timed
def greet(name, greeting="Hello"):
    time.sleep(0.05)
    return f"{greeting}, {name}!"

result = slow_add(1, 2)
print(result)
# Ran slow_add in ~100ms
# 3

result = greet("Moussa", greeting="Hey")
print(result)
# Ran greet in ~50ms
# Hey, Moussa!

Notice that wrapper uses *args, and **kwargs to collect and forward arguments, making @timed reusable across any function regardless of its signature. This is exactly how decorators work in Flask and FastAPI.


Key Takeaways

  • *args collects unlimited positional arguments into a tuple

  • **kwargs collects unlimited keyword arguments into a dict

  • Inside the function, both are just normal Python data structures

  • The correct order is always: fixed -> *args -> **kwargs

  • Empty *args is () and empty **kwargs is {}, never None

  • * and ** work in both directions: packing in definitions, unpacking at call sites

  • The wrapper pattern: collect, act, forward is the foundation of decorators.

Once these rules are internalized, you'll recognize the pattern everywhere: in the standard library, in popular frameworks, and in well-written Python code across the ecosystem.

Thank you for reading ๐Ÿ“–๐Ÿ™‚.