How Python Handles Unknown Arguments: A Deep Dive into *args and **kwargs
Write functions that adapt to anything, without breaking a sweat ๐

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.
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 calledargs".
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 calledkwargs
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
__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
Fixed positional arguments first
*argssecond**kwargslast
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.
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
*argscollects unlimited positional arguments into a tuple**kwargscollects unlimited keyword arguments into a dictInside the function, both are just normal Python data structures
The correct order is always:
fixed -> *args -> **kwargsEmpty
*argsis()and empty**kwargsis{}, neverNone*and**work in both directions: packing in definitions, unpacking at call sitesThe 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 ๐๐.






