*args, **kwargs and Function Arguments Deep Dive
Every way Python lets you pass arguments to a function, and how to design flexible, unambiguous function signatures.
This module opens Phase 4 — Intermediate & Functional Python. Phases 1 through 3 built your foundation: syntax, data structures, and Object-Oriented Python, including writing classes, encapsulation, and the @property decorator. Phase 4 builds directly on top of that — it is where Python stops looking like "a scripting language with functions" and starts looking like the language that powers Django, Flask, FastAPI, pandas, and virtually every serious Python codebase you will work in professionally. It starts here, with something you have already been using without fully seeing: the machinery behind how arguments actually get passed to a function.
Positional and Keyword Arguments, Revisited
Back in Module 07 (Functions), you learned that a function call can pass arguments two ways: positionally, matched to parameters left to right by their position, or by keyword, matched explicitly by parameter name regardless of order. This module assumes that is solid ground and builds the rest of Python's argument-passing model on top of it.
def describe_pet(name, species, age):
print(f"{name} is a {age}-year-old {species}")
describe_pet("Biscuit", "dog", 3) # positional — matched by order
describe_pet(name="Biscuit", species="dog", age=3) # keyword — matched by name
describe_pet(age=3, name="Biscuit", species="dog") # keyword — order no longer matters
describe_pet("Biscuit", age=3, species="dog") # mixed — positional first, then keywordOne rule carries forward from Module 07 and matters a great deal for everything in this module: once you use a keyword argument in a call, every argument after it must also be passed by keyword. You cannot follow a keyword argument with another positional one — Python would have no reliable way to know which remaining parameter it belongs to.
What this module actually covers is the layer above that: what happens when you do not know, at the time you write the function, exactly how many arguments will be passed, or exactly what their names will be. That is the entire reason *args and **kwargs exist.
*args — Any Number of Positional Arguments, Collected Into a Tuple
Prefixing a parameter name with a single asterisk tells Python: "collect every remaining positional argument the caller passes, no matter how many, and bundle them into a single tuple." The name args is purely convention — the asterisk is what does the work, not the word "args" — but essentially every Python codebase you will ever read uses args, and deviating from it without a good reason will just confuse your reviewers.
def total(*args):
print(type(args)) # <class 'tuple'>
return sum(args)
total(1, 2, 3) # 6 — args is (1, 2, 3)
total(10, 20) # 30 — args is (10, 20)
total() # 0 — args is an empty tuple, which is perfectly legalBecause args is a genuine tuple, everything you already know about tuples from Module 09 applies directly — you can index into it, slice it, iterate over it with a for loop, or unpack it. It is not some special new type invented for this feature; it is the same immutable sequence type you already understand.
def show_first_and_rest(*args):
if not args:
print("No arguments given")
return
first, *rest = args
print(f"First: {first}, rest: {rest}")
show_first_and_rest(1, 2, 3, 4)
# First: 1, rest: [2, 3, 4]*args mops up everything positional left over: def log(level, *messages): lets you call log("INFO", "starting", "connecting") with level bound to "INFO" and messages bound to ("starting", "connecting").**kwargs — Any Number of Keyword Arguments, Collected Into a Dict
A parameter prefixed with two asterisks collects every remaining keyword argument the caller passes into a single dict, with the argument names becoming keys and the passed values becoming values. Same convention story as args — the name kwargs is not enforced by the language, but every Python engineer will recognise it instantly and expect it.
def build_profile(**kwargs):
print(type(kwargs)) # <class 'dict'>
return kwargs
build_profile(name="Maria", city="Denver", role="Engineer")
# {'name': 'Maria', 'city': 'Denver', 'role': 'Engineer'}
build_profile()
# {} — an empty dict, perfectly legal, same as an empty *args tupleBecause kwargs is a genuine dict, every dict method from Module 11 works on it directly — .items(), .get(), .keys(), membership checks with in, all of it. This is precisely why **kwargs is the standard way to accept an open-ended set of optional, named configuration values without writing a parameter for every single one up front.
def create_user(username, **kwargs):
print(f"Creating user: {username}")
for key, value in kwargs.items():
print(f" {key} = {value}")
# .get() with a default — exactly like a normal dict, because it IS one
role = kwargs.get("role", "member")
print(f" Assigned role: {role}")
create_user("mkim", city="Denver", department="Platform")
# Creating user: mkim
# city = Denver
# department = Platform
# Assigned role: memberTypeError: create_user() got an unexpected keyword argument 'departmnet'. But once a function accepts **kwargs, that protection disappears: the typo is silently absorbed into the dict as its own key, and the function has no way to know you meant something else. This trade-off — flexibility in exchange for losing that built-in typo protection — is explored in the Real World section below, where it caused a genuine production bug.The Bare * Marker — Forcing Arguments to Be Passed by Name
Sometimes you want to require that certain arguments always be passed by keyword — never positionally — because the call would otherwise be ambiguous or unreadable at a glance. Python lets you enforce this directly in the function signature with a bare * marker: every parameter listed after it can only be supplied as a keyword argument.
def create_report(title, *, include_charts=False, format="pdf"):
print(f"Report: {title}, charts={include_charts}, format={format}")
create_report("Q3 Sales", include_charts=True, format="csv") # fine
create_report("Q3 Sales", True, "csv")
# TypeError: create_report() takes 1 positional argument but 3 were givenNotice that * alone — with no name attached — is not itself a parameter that collects anything; it is purely a marker in the signature. Everything before it can be positional or keyword, as usual; everything after it must be keyword only. This is genuinely common in real APIs where a boolean or a mode argument, if passed positionally, would be meaningless to a reader without checking the function's definition — create_report("Q3 Sales", True, "csv") tells you nothing about what True means at the call site, while include_charts=True does.
sorted(iterable, *, key=None, reverse=False) forces you to write reverse=True, not sorted(items, True) — precisely because a bare True at that position would be meaningless without memorising the exact parameter order.The / Marker — Forbidding Arguments to Be Passed by Name
Python 3.8 introduced the mirror image of the * marker: a forward slash / in the signature, after which every parameter listed before it can only be supplied positionally — passing it by keyword raises a TypeError. This is a much less common feature than keyword-only arguments, but it shows up in the standard library and is worth recognising.
def power(base, exponent, /):
return base ** exponent
power(2, 10) # 1024 — fine
power(base=2, exponent=10)
# TypeError: power() got some positional-only arguments passed as keyword arguments: 'base, exponent'Why would anyone want to forbid a keyword form? Two real reasons. First, parameter names that are just implementation detail — a generic x or value — are not meant to be part of the function's public contract, and forbidding the keyword form means the internal name can be freely renamed later without breaking any caller's code (this is exactly why many built-in functions like len() and abs() are positional-only). Second, it lets a function accept **kwargs alongside a positional parameter that happens to share a name with a key someone might legitimately want to pass through.
def build_request(url, /, **kwargs):
# "url" is positional-only, so it can never collide with a caller
# passing url="..." as one of the **kwargs entries meant for something else
return {"url": url, "params": kwargs}
build_request("https://api.example.com", url="ignored-if-not-for-this-conflict")
# TypeError: build_request() got multiple values for argument 'url' — WITHOUT the "/",
# this exact call would be genuinely ambiguous. WITH it, "url" the parameter and
# "url" as a possible kwargs key can never collide, because the parameter can
# never be filled by keyword in the first place.You will not reach for / often in everyday application code, but recognising it matters — Python's official documentation uses it constantly to describe the built-in functions, and you will see it in the signature help your editor shows you for functions like dict.get(key, default=None, /).
*variable and **variable — Unpacking Arguments When You Call
Everything so far has been about the function definition side of the asterisk. The same * and ** symbols have a second, completely different job on the call side: unpacking an existing list or dict into separate arguments, instead of packing loose arguments into one.
def describe_pet(name, species, age):
print(f"{name} is a {age}-year-old {species}")
pet_info = ["Biscuit", "dog", 3]
describe_pet(*pet_info)
# Identical to describe_pet("Biscuit", "dog", 3) — the * unpacks the list
# into three separate positional arguments at the call site.pet_info = {"name": "Biscuit", "species": "dog", "age": 3}
describe_pet(**pet_info)
# Identical to describe_pet(name="Biscuit", species="dog", age=3) — the **
# unpacks the dict into keyword arguments, matched by the dict's keys.This is genuinely one of the most common patterns you will see in real Python code — especially anywhere data is loaded from a JSON API response or a config file as a dict and then needs to be fed into a function or a class constructor whose parameters match the dict's keys.
class User:
def __init__(self, name, email, role="member"):
self.name = name
self.email = email
self.role = role
# A row straight from a database query or a JSON API response:
row = {"name": "Priya Nair", "email": "priya@example.com", "role": "admin"}
user = User(**row) # far cleaner than User(row["name"], row["email"], row["role"])*/** mean "gather the caller's loose arguments into one collection." At a call site, they mean "spread this one collection back out into loose arguments." They are exact inverses of each other, which is exactly why Python reused the same symbol for both — once you see it that way, it stops looking like two unrelated features you have to memorise separately.The Required Order, and a Real Worked Example
A single function signature can legally combine every form covered in this module — positional-only parameters, regular parameters, *args, keyword-only parameters, and **kwargs — but Python enforces a strict order, and getting it wrong is a SyntaxError caught before your program ever runs.
def full_signature(pos_only, /, normal, *args, kw_only, **kwargs):
print("pos_only:", pos_only)
print("normal:", normal)
print("args:", args)
print("kw_only:", kw_only)
print("kwargs:", kwargs)
full_signature(1, 2, 3, 4, kw_only=5, extra=6)
# pos_only: 1
# normal: 2
# args: (3, 4)
# kw_only: 5
# kwargs: {'extra': 6}The order is always: positional-only parameters, then /, then normal parameters, then *args (or a bare * if you want keyword-only arguments without collecting extra positional ones), then keyword-only parameters, then **kwargs. In practice, most real functions use only two or three of these forms at once — seeing all five together is rare outside of library code, but understanding the order explains why your editor's autocomplete lays out a function's signature the way it does.
Worked example — a flexible logging wrapper
The single most common real-world use of *args and **kwargs together is a wrapper function — one that adds some behaviour (logging, timing, retrying, authentication) around a call to another function, without needing to know anything about that function's specific arguments. This exact pattern is also the foundation the Decorators module (Module 29) will build on directly.
import time
def call_with_logging(func, *args, **kwargs):
"""Call func with whatever arguments were given, logging timing and errors."""
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
start = time.perf_counter()
try:
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} succeeded in {elapsed:.4f}s")
return result
except Exception as e:
elapsed = time.perf_counter() - start
print(f"{func.__name__} failed after {elapsed:.4f}s: {e}")
raise
def fetch_user(user_id, include_orders=False):
if include_orders:
return {"id": user_id, "orders": [101, 102]}
return {"id": user_id}
call_with_logging(fetch_user, 42, include_orders=True)
# Calling fetch_user with args=(42,), kwargs={'include_orders': True}
# fetch_user succeeded in 0.0000sNotice what makes this genuinely powerful: call_with_logging never needed to know that fetch_user takes a user_id and an include_orders flag. It collects whatever the caller passes with *args, **kwargs, then unpacks them right back out with func(*args, **kwargs) when calling the wrapped function. This "collect, then re-spread" pattern is precisely how every general-purpose wrapper, middleware, and decorator in Python is built.
The Silent Typo at a Denver Ride-Share Analytics Startup
A Denver-based startup building trip-analytics dashboards has an internal track_event() function that every part of the codebase calls to send an event to their analytics warehouse. To stay flexible as new event types were added over time, an engineer designed it with **kwargs, exactly as described in Part 03.
def track_event(event_name, **kwargs):
payload = {"event": event_name, "timestamp": time.time(), **kwargs}
send_to_warehouse(payload)The bug
A new engineer, tracking a completed ride, writes track_event("ride_completed", fair_amount=18.50, driver_id="D-4471") — a one-character typo, fair_amount instead of fare_amount. Because track_event accepts **kwargs, Python raises no error at all. The typo'd key is simply absorbed into the payload dict and shipped to the warehouse as-is. The dashboard that reports total fare revenue silently under-reports for three weeks, because it queries a column called fare_amount that this particular event never populated — and nothing in the system ever complained.
Why this is a genuine trade-off, not a design mistake
The team did not remove **kwargs — a function called from dozens of places with dozens of different event shapes genuinely needs that flexibility, and defining a rigid parameter list for every possible event field was never realistic. Instead, they added runtime validation: track_event now checks incoming keys against a registry of known field names per event type and raises a clear ValueError on anything unrecognised, restoring the "fail loudly on a typo" protection that a fixed signature would have given for free — while keeping the flexibility **kwargs provides.
This is the exact trade-off flagged in the Callout under Part 03: **kwargs genuinely earns its place for open-ended, evolving data, but it quietly gives up the "unexpected keyword argument" safety net that a normal function signature provides for free. Knowing that trade-off exists — and deciding when it is and is not acceptable — is the actual skill this module is teaching, not just the syntax.
Four Misconceptions About *args and **kwargs
5 Interview Questions — With Complete Answers
Argument-Handling Mistakes That Cost Real Debugging Time
Errors You Will Hit With Function Arguments — And Exactly Why
🎯 Key Takeaways
- ✓*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. The names are convention — the asterisks are what Python actually reads.
- ✓A bare * in a signature marks everything after it as keyword-only, forcing callers to name those arguments explicitly — used throughout the standard library (e.g. sorted()) to avoid ambiguous positional booleans.
- ✓A / marker (Python 3.8+) marks everything before it as positional-only, forbidding the keyword form — common in built-ins like len(), rare in everyday application code.
- ✓The same * and ** symbols mean opposite things depending on context: gathering loose arguments into a collection in a definition, and unpacking a collection back into loose arguments at a call site.
- ✓The legal parameter order in a signature is: positional-only, /, normal, *args, keyword-only, **kwargs — violating it is a SyntaxError.
- ✓**dict unpacking at a call site is the standard way to feed a dict (e.g. from a JSON API response) into a function or constructor whose parameters match its keys.
- ✓**kwargs trades away the automatic "unexpected keyword argument" TypeError a fixed signature gives you — a typo'd key is silently absorbed rather than flagged, exactly as shown in the Denver production incident above.
- ✓The "collect with *args/**kwargs, then re-spread with func(*args, **kwargs)" pattern is the foundation every general-purpose wrapper and decorator in Python is built on.
What comes next
Module 26 covers lambda functions and Python's functional toolkit — map, filter, and functools.reduce — plus an honest, non-dogmatic take on when a one-line lambda is the right call and when a named function genuinely reads better.
Module 26 → Lambda Functions and Functional ToolsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.