Flexible arguments: *args, **kwargs and the default trap

Part of the free LogicWiz Python course, Module 11: Functions in depth. The examples and practice run in the app.

Shankar bhaiya wants bill() to work for one item or ten. And Aarav's add_item() keeps showing yesterday's chai on today's bill. Both problems live in the function's brackets.

You'll learn to

  • Accept any number of values with `*args` and any named values with `**kwargs`
  • Force named arguments with a bare `*` (keyword-only)
  • Unpack a list or dict into a call with `*` and `**`
  • Spot the mutable default argument trap and fix it with `None`

Learn

*args: any number of values

Put a * before a parameter name and Python collects all the extra positional values into a tuple.

The name args is only a habit. *prices works the same and reads better.

With no extra values, you get an empty tuple (), not an error.

One bill function for any number of items

def total(*prices):
    print("prices =", prices)
    return sum(prices)

print(total(20, 15, 10))
print(total(30))
print(total())

**kwargs and the order of parameters

Two stars collect the extra named values into a dict: notes={'spicy': 'extra'}.

The order inside the brackets is fixed:

def f(normal, *args, keyword_only=0, **kwargs)

Anything written after *args (or after a bare *) can only be passed by name. That is a keyword-only argument. It stops mistakes like pay(120, 'cash') where nobody can tell what 'cash' means.

All four kinds together

def order(table, *items, paid=False, **notes):
    print("Table", table, "ordered", items)
    print("Paid?", paid)
    for key, value in notes.items():
        print("  ", key, "->", value)

order(4, "samosa", "chai", spicy="extra", sugar="less")
order(7, "vada pav", paid=True)

paid comes after *items, so it is keyword-only: order(7, "vada pav", True) would make True another item.

Unpacking: * and ** in a call

def bill(price, qty, gst=5):
    return round(price * qty * (1 + gst / 100), 2)

item = [15, 4]                 # price, qty
print(bill(*item))             # same as bill(15, 4)
extras = {"gst": 12}
print(bill(*item, **extras))   # same as bill(15, 4, gst=12)

first, *rest = [10, 20, 30, 40]
print(first, rest)

In a definition, * collects. In a call, * spreads. Same symbol, opposite jobs.

The trap: a list as a default

def add_item(item, cart=[]):
    cart.append(item)
    return cart

print(add_item("chai"))
print(add_item("samosa"))   # a new customer!

Step through it: both calls point at the same list object.

The fix: default to None

def add_item(item, cart=None):
    if cart is None:
        cart = []          # a fresh list on every call
    cart.append(item)
    return cart

print(add_item("chai"))
print(add_item("samosa"))
print(add_item("vada", ["chai"]))

Default values are made once, when def runs, not on every call. A list, dict or set default is therefore shared by every call that doesn't pass its own. Use None as the default and create the list inside. Numbers, strings and tuples are safe defaults because they can't change.

Interviewers at TCS, Infosys and product companies love two questions here: "What are *args and **kwargs?" and "What does this print?" with a list default. Both answers are in this lesson.

In one line

*args collects extra values into a tuple, **kwargs into a dict; never use a mutable default, use None and create it inside.

Interview corner

What are *args and **kwargs?

*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. The names are a convention, the stars do the work. In a call, * and ** do the opposite: they unpack a list or dict into arguments, e.g. f(*[1, 2], **{"gst": 5}).

Why is a mutable default argument dangerous?

Default values are evaluated once, when the def statement runs, and stored on the function. A list or dict default is therefore shared by every call that does not pass its own, so data leaks between calls. Use None and create the object inside: if cart is None: cart = [].

What do a bare * and a / mean in a parameter list?

Parameters after * are keyword-only: def pay(amount, *, method="UPI") must be called as pay(100, method="cash"). Parameters before / are positional-only: def f(a, /) cannot be called as f(a=1). Both make an API harder to misuse.