Python *args and kwargs Explained - Beginner Tutorial #12 (with mini project)
Video: Python *args and kwargs Explained - Beginner Tutorial #12 (with mini project) by Taught by Celeste AI - AI Coding Coach
Python args and *kwargs: Variable-Arity Functions
*argscollects extra positional arguments as a tuple.**kwargscollects extra keyword arguments as a dict. Together with unpacking (*list,**dict), they power decorators, wrappers, and any function that needs to accept "whatever caller passes."
Sometimes you don't know how many arguments your function will receive. *args and **kwargs are Python's solution.
*args: variable positional arguments
def sum_all(*args):
total = 0
for num in args:
total += num
return total
print(sum_all(1, 2, 3)) # 6
print(sum_all(10, 20, 30, 40)) # 100
*args collects all extra positional arguments into a tuple. Inside the function, args is just a regular variable — you can iterate it, index it, len it.
The * is what does the work. The name args is convention; you could write *nums or *xs, but *args is what every Python programmer reads.
*args alongside regular parameters
def greet(greeting, *names):
for name in names:
print(f"{greeting}, {name}!")
greet("Hello", "Alice", "Bob", "Charlie")
# Hello, Alice!
# Hello, Bob!
# Hello, Charlie!
Regular parameters first, then *args catches the rest. The first argument fills greeting; everything after fills names.
You cannot put a regular positional parameter after *args (without a keyword-only barrier — covered below).
**kwargs: variable keyword arguments
def print_info(**kwargs):
for key, value in kwargs.items():
print(f" {key}: {value}")
print_info(name="Alice", age=25, city="NYC")
# name: Alice
# age: 25
# city: NYC
**kwargs collects extra keyword arguments into a dict. Iterate via .items() like any dict.
Order is preserved (Python 3.7+) — keys appear in the order the caller passed them.
Combining args and *kwargs
def log_event(event, *tags, **details):
print(f"Event: {event}")
print(f" Tags: {', '.join(tags)}")
for key, value in details.items():
print(f" {key}: {value}")
log_event("Login", "auth", "user", ip="10.0.0.1", status="ok")
# Event: Login
# Tags: auth, user
# ip: 10.0.0.1
# status: ok
The order is fixed: positional → *args → keyword-with-defaults → **kwargs. The full grammar:
def f(a, b=1, *args, c, d=2, **kwargs):
...
a— required positional.b=1— positional with default.*args— extra positional.c,d=2— keyword-only (must be passed by name).**kwargs— extra keyword.
Unpacking with * and **
The same * and ** work at the call site too — but they unpack instead of pack.
def add(a, b, c):
return a + b + c
numbers = [1, 2, 3]
print(add(*numbers)) # 6 — same as add(1, 2, 3)
*list spreads each element as a positional argument. Works with any iterable: lists, tuples, generators.
def greet(name, greeting, punctuation):
print(f"{greeting}, {name}{punctuation}")
config = {"name": "Alice", "greeting": "Hello", "punctuation": "!"}
greet(**config) # Hello, Alice!
**dict spreads each key-value as a keyword argument. The keys must match the parameter names.
Forwarding arguments (the wrapper pattern)
def wrapper(*args, **kwargs):
print(f" Forwarded args: {args}")
print(f" Forwarded kwargs: {kwargs}")
# Could also do: real_function(*args, **kwargs)
wrapper("Logout", "auth", ip="10.0.0.2")
The combo (*args, **kwargs) accepts anything. This is the foundation of:
- Decorators — wrap a function while accepting any signature.
- Logging wrappers — log every call, then forward.
- Mocks in tests — accept whatever the test expects.
- Subclass overrides —
super().__init__(*args, **kwargs)forwards to the parent.
Event planner: a worked example
def create_event(name, *guests, **details):
event = {"name": name, "guests": list(guests)}
event.update(details)
return event
def display_event(event):
print(f"Event: {event['name']}")
guests = event.get("guests", [])
print(f" Guests: {', '.join(guests) if guests else 'None'}")
for key, value in event.items():
if key not in ("name", "guests"):
print(f" {key}: {value}")
party = create_event("Birthday", "Alice", "Bob", "Charlie",
date="March 15", location="Park")
meeting = create_event("Team Sync", "Alice", "Diana",
time="10:00", room="A3")
create_event accepts a name, any number of guests, and any number of detail fields. The same function handles birthdays (date + location) and meetings (time + room) without separate signatures.
Keyword-only arguments
def make_pizza(size, *toppings, crust="thin"):
print(f"{size} {crust}-crust pizza with: {', '.join(toppings)}")
make_pizza("large", "pepperoni", "mushrooms", crust="thick")
Anything after *args is keyword-only — must be passed by name. Useful when the meaning is non-obvious; forces self-documenting calls.
You can also use a bare * to mark "only keywords from here on":
def f(a, b, *, c, d): # c and d are keyword-only
pass
f(1, 2, c=3, d=4) # OK
f(1, 2, 3, 4) # TypeError
Positional-only arguments (Python 3.8+)
def divide(a, b, /): # / marks end of positional-only
return a / b
divide(10, 2) # OK
divide(a=10, b=2) # TypeError — must be positional
The / says "everything before is positional-only." Less common; mostly used in stdlib to keep parameter names free for **kwargs use.
args is a tuple, *kwargs is a dict
def f(*args, **kwargs):
print(type(args)) # <class 'tuple'>
print(type(kwargs)) # <class 'dict'>
f(1, 2, x=3)
Tuples are immutable; dicts are mutable. If you mutate kwargs, you're modifying a fresh dict — the caller's dict is untouched. Same for args.
Common patterns
Pass-through wrapper:
def my_print(*args, **kwargs):
print("[LOG]", *args, **kwargs)
my_print("hello", "world", sep="-")
# [LOG] hello-world
Default-overriding wrapper:
def open_utf8(path, *args, **kwargs):
kwargs.setdefault("encoding", "utf-8")
return open(path, *args, **kwargs)
Merging dicts via call:
defaults = {"timeout": 30, "retries": 3}
user_opts = {"timeout": 60}
config = {**defaults, **user_opts} # {'timeout': 60, 'retries': 3}
(Not technically **kwargs, but the same ** unpacking syntax.)
When NOT to use args / *kwargs
- For a function that takes 2-3 well-defined parameters, name them.
def add(a, b)is clearer thandef add(*args). - For public APIs, explicit parameters are easier to discover (IDE autocomplete, type hints, docstrings).
- For "I forgot what arguments this takes,"
*args, **kwargsis too permissive and hides the real interface.
Reach for them when the function is genuinely a wrapper or has an open-ended interface.
Common stumbles
*args only collects positional. f(1, x=2) with def f(*args) → TypeError. Add **kwargs to absorb keywords too.
Confusing *args (tuple) with *list (unpacking). * in the parameter list packs; * at the call site unpacks. Same symbol, opposite directions.
Forgetting ** when unpacking dicts. f(my_dict) passes the dict as a single positional argument; f(**my_dict) spreads it as keywords.
Mutating kwargs and surprising the caller. kwargs is a fresh dict each call, so this is safe — but if you store a reference and mutate later, that's a bug.
Mismatched dict keys. f(**{"x": 1, "y": 2}) requires f to accept x and y. Otherwise TypeError.
What's next
Lesson 13: lambda functions. Anonymous one-line functions. Useful with map, filter, sorted key, etc.
Recap
*args packs extra positional args into a tuple; **kwargs packs extra keyword args into a dict. At call sites, *list and **dict unpack. Order in def: positional, defaults, *args, keyword-only, **kwargs. Use for wrappers, decorators, forwarding. For stable APIs, prefer explicit named parameters.
Next lesson: lambda functions.