Part of Python for Beginners

Python *args and kwargs Explained - Beginner Tutorial #12 (with mini project)

Sandy LaneSandy Lane

Video: Python *args and kwargs Explained - Beginner Tutorial #12 (with mini project) by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python args and *kwargs: Variable-Arity Functions

*args collects extra positional arguments as a tuple. **kwargs collects 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 overridessuper().__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 than def add(*args).
  • For public APIs, explicit parameters are easier to discover (IDE autocomplete, type hints, docstrings).
  • For "I forgot what arguments this takes," *args, **kwargs is 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.