Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

*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.

45 min August 2026

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.

// Part 01 — A Quick Recap

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.

Positional vs keyword — the recap
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 keyword

One 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.

// Part 02 — *args

*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.

*args in action
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 legal

Because 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.

args really is just a tuple
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]
🎯 Pro Tip
*args can be combined with named parameters. Named parameters come first, and *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").
// Part 03 — **kwargs

**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.

**kwargs in action
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 tuple

Because 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.

Iterating over kwargs, and safely checking for optional keys
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: member
⚠️ Important
A misspelled keyword argument to a function that does NOT use **kwargs raises an error immediatelyTypeError: 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.
// Part 04 — Keyword-Only Arguments

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.

Keyword-only arguments
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 given

Notice 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.

🎯 Pro Tip
You have already seen keyword-only arguments used deliberately by the standard library: 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.
// Part 05 — Positional-Only Parameters

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.

Positional-only parameters
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.

Combining / with **kwargs to avoid a name collision
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, /).

// Part 06 — Unpacking at the Call Site

*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.

Unpacking a list/tuple into positional arguments
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.
Unpacking a dict into keyword arguments
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.

Where this shows up constantly in real code
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"])
💡 Note
Same symbols, opposite jobs, and it is not a coincidence. In a function definition, */** 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.
// Part 07 — Combining Every Form

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.

The complete, legal order
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.

A wrapper that forwards ANY arguments to ANY function it wraps
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.0000s

Notice 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.

// Part 08 — Real World
💼 What This Looks Like at Work

The Silent Typo at a Denver Ride-Share Analytics Startup

Scenario — Ride-share analytics startup, Denver · Production incident

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.

The function everyone calls
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.

// Part 09 — Misconceptions

Four Misconceptions About *args and **kwargs

✕ ""args and kwargs are special keywords built into Python""
The names "args" and "kwargs" are just convention — nothing more. The asterisk(s) are what actually matter to Python. def f(*numbers, **options): works identically to *args/**kwargs, but nobody uses that naming, because breaking the convention makes your code harder for other engineers to scan at a glance.
✕ ""Every function should accept *args, **kwargs so it's maximally flexible""
Flexibility has a real cost, shown directly in the Real World example above: a fixed signature catches typos and unexpected arguments immediately with a TypeError, while **kwargs silently swallows them. Use *args/**kwargs when a function genuinely needs to accept an open-ended, evolving set of arguments (like a generic wrapper) — not as a default habit for every function you write.
✕ ""*args and **kwargs mean the same thing on both sides — definition and call""
They mean opposite things depending on which side they appear on. In a function definition, they GATHER loose arguments into a tuple/dict. At a call site, they UNPACK an existing list/dict back into loose arguments. Same symbols, inverse operations — covered directly in Part 06.
✕ ""The / and * markers in a signature are rare edge cases you'll never actually see""
The * marker (keyword-only arguments) is genuinely common in real, professional APIs — sorted(), the standard library, and most well-designed internal codebases use it to force clarity at the call site for boolean or mode flags. The / marker is rarer, but it appears throughout Python's own official documentation and built-in function signatures, so recognising it is still worth the five minutes it takes to learn.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

What are *args and **kwargs, and what data types do they actually produce inside the function?
*args collects any number of extra positional arguments into a tuple. **kwargs collects any number of extra keyword arguments into a dict. The names are pure convention — it is the asterisk(s) that matter to Python, not the words "args"/"kwargs" — but the convention is followed so consistently that deviating from it without reason will confuse reviewers.
What is the difference between how * behaves in a function definition versus at a function call?
In a definition, * gathers the caller's extra positional arguments into a tuple (*args). At a call site, * does the opposite: it unpacks an existing list or tuple back out into separate positional arguments, e.g. func(*my_list). The same duality applies to ** with dicts and keyword arguments. They are inverse operations of each other, sharing the same symbol.
What does a bare * (with no name) in a function signature do?
It marks every parameter listed after it as keyword-only — those arguments can no longer be passed positionally, only by name. This is used to force clarity at call sites, especially for boolean or mode-like arguments where a bare positional value (like True) would be meaningless without checking the function's definition. sorted(iterable, *, key=None, reverse=False) is a standard-library example.
What is the required order of parameter kinds in a Python function signature that uses all of them?
Positional-only parameters, then a "/" marker, then normal parameters, then *args, then keyword-only parameters, then **kwargs. Getting this order wrong is a SyntaxError caught before the program runs. Most real functions only combine two or three of these forms — seeing all of them together in one signature is rare outside library code.
Why might a function using **kwargs be more prone to bugs than one with a fixed, explicit signature?
A fixed signature raises an immediate TypeError if the caller misspells a keyword argument or passes one the function does not recognise. A function accepting **kwargs absorbs any keyword argument silently, including typos, with no error at all — the mistake can then propagate downstream (e.g. into a database or analytics payload) completely unnoticed. This is a genuine trade-off: **kwargs buys flexibility for open-ended or evolving arguments, at the cost of losing that built-in typo protection.
// Common Mistakes

Argument-Handling Mistakes That Cost Real Debugging Time

Putting *args after a default-valued parameter and expecting the default to still work positionally
def f(a, b=5, *args): calling f(1, 2, 3, 4) sets b=2, not the default — *args only collects what is left AFTER every named parameter has been filled. If you wanted b to keep its default while still passing extra values, those extra values need to be passed by keyword or the signature needs rethinking.
Forgetting that unpacking a dict with ** requires its keys to be valid Python identifiers that match parameter names exactly
build_profile(**{"user-name": "x"}) fails, because "user-name" (with a hyphen) cannot be a keyword argument name at all — it is not a valid identifier. And build_profile(**{"username": "x"}) fails against a function expecting "user_name" — the keys must match parameter names exactly, including case and spelling.
Assuming *args and **kwargs are required parameters
They default to an empty tuple and empty dict respectively when the caller passes nothing extra — there is no need to give them a default value in the signature, and doing so (def f(*args=None)) is actually a SyntaxError.
Mutating **kwargs and expecting it to affect the caller's original dict
**kwargs always creates a brand NEW dict inside the function — even if the caller unpacked their own dict with ** to call it. Changes made to kwargs inside the function never propagate back to the caller's original dict, unlike passing a dict directly as a single mutable argument would.
Trying to pass a positional-only parameter (marked with /) by keyword out of habit
Calling len(obj=my_list) fails, because len()'s parameter is positional-only in CPython's implementation. This trips people up specifically because most everyday functions DO accept their parameters by keyword — positional-only is the exception, not the rule, so it is worth checking a function's actual signature (with help() or your editor) rather than assuming.
// Error Library

Errors You Will Hit With Function Arguments — And Exactly Why

TypeError: describe_pet() got multiple values for argument 'name'
Cause: A positional argument and a keyword argument both tried to fill the same parameter — e.g. describe_pet("Biscuit", name="Rex") passes "Biscuit" positionally to the first parameter (name) AND passes name="Rex" by keyword, a direct conflict.
Fix: Pick one form per argument. If a function is called with both positional and keyword arguments, make sure no keyword argument names a parameter already filled by position.
SyntaxError: positional argument follows keyword argument
Cause: A call places a plain positional argument after a keyword argument, e.g. func(a=1, 2) — once you start using keywords in a call, everything after must also be a keyword argument.
Fix: Reorder the call so all positional arguments come first, followed by all keyword arguments — matching how the function itself will interpret them.
TypeError: full_signature() missing 1 required keyword-only argument: 'kw_only'
Cause: A parameter placed after a bare * (or after *args) in the function definition has no default value and was not supplied by keyword in the call.
Fix: Either pass it explicitly by keyword at the call site, or give it a default value in the function definition if it should be optional.
TypeError: power() got some positional-only arguments passed as keyword arguments: 'base, exponent'
Cause: A parameter defined before a "/" marker in the signature was called using its keyword form, which positional-only parameters explicitly forbid.
Fix: Pass the argument positionally instead — check the function's signature (help(func) or your editor's hover tooltip) to see exactly which parameters are positional-only.
TypeError: track_event() argument after ** must be a mapping, not list
Cause: Attempting to unpack a list (or any non-dict-like object) into a function call using ** — ** unpacking specifically requires something that supports key-value access, like a dict.
Fix: Use * (single asterisk) to unpack a list/tuple into positional arguments, and reserve ** exclusively for unpacking dicts into keyword arguments.

🎯 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 Tools
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...