1.2.29 · D5Introduction to Programming (Python)

Question bank — ` - args` and ` - kwargs` — flexible argument passing

1,820 words8 min readBack to topic

Before we start, three anchors so no symbol is unearned:

The picture behind every trap

Before you attack the traps, burn this routing diagram into memory. When Python receives a call, it sorts arguments left-to-right into three bins: named slots first, then the *args tuple sweeps up leftover positionals, then the **kwargs dict sweeps up leftover keywords.

Figure — ` - args` and ` - kwargs` — flexible argument passing

Look at the arrows: the burnt-orange values flow into the tuple, the teal name=value pairs flow into the dict, and only the plum-labelled named slots are filled first. Almost every trap below is just this diagram with one arrow going where you didn't expect.


True or false — justify

Star collects positionals into a list.
False. *args produces a tuple, which is immutable — you cannot args.append(x); you must do list(args) first if you want to mutate.
**kwargs produces a tuple of pairs.
False. It produces a dict, keyed by the keyword names, so kwargs["x"] works and kwargs.items() gives (name, value) pairs.
The names args and kwargs are keywords Python requires.
False. Only the * and ** carry meaning; def f(*stuff, **options) is perfectly valid — args/kwargs are just convention for readability.
def f(a, *args, b) makes b a normal positional parameter.
False. Anything after *args becomes keyword-only: b must be passed as b=..., because *args already swallowed every remaining positional value.
You may write def f(**kwargs, *args).
False — it is a SyntaxError. A catch-all dict must be the last parameter; nothing can follow something that absorbs all remaining keywords.
Calling add(nums) with nums=[1,2,3] is the same as add(*nums).
False. add(nums) passes the list as one argument; add(*nums) spreads it into three separate positional arguments. Forgetting the * is a classic bug.
In {**a, **b}, if both dicts share a key, a wins.
False. The later spread wins, so b overrides a on shared keys — dict literals evaluate left to right, and each key overwrites the previous.
*args and **kwargs can only appear in a function definition.
False. Both also work at the call site to unpack a sequence/dict into separate arguments — that mirror-image use is half the power.
A function def f(*args) will crash if called with zero arguments.
False. args simply becomes the empty tuple (); no error. Always guard division/indexing against this empty case.

The name-collision trap (worked out)

The one subtle case people trip on: can a keyword argument "collide" with a value already caught by *args? The answer is a firm no — they live in separate bins, and the code below shows exactly why. Trace it against the routing diagram above.


Spot the error

def f(a, **kwargs, b=2): ...
**kwargs is not last — SyntaxError. Move it to the end: def f(a, b=2, **kwargs).
def total(*args): return args + 10
args is a tuple, and tuple + int is a TypeError. You meant sum(args) + 10 — you must reduce the tuple to a number first.
config = {"lr": 0.1}; train(*config)
Using one star on a dict spreads only its keys as positionals (train("lr")), almost never what you want. Use train(**config) to spread key=value pairs.
def g(*args, **kwargs, extra): ...
A parameter after **kwargs is illegal — SyntaxError. Keyword-only params must sit between *args and **kwargs, e.g. def g(*args, extra, **kwargs).
log(func, *args, **kwargs); func(args, kwargs)
The re-call drops the stars, so func receives a tuple and a dict as two positional values instead of the spread-out arguments. Correct forwarding is func(*args, **kwargs).
def h(a, *args): return a; h(*[])
h(*[]) spreads an empty list, so nothing fills the required aTypeError: missing 1 required positional argument. * on an empty sequence supplies zero arguments.
def m(**kwargs): return kwargs[0]
kwargs is a dict keyed by strings, not an integer-indexed sequence; kwargs[0] raises KeyError. Use kwargs["name"] or iterate kwargs.items().

Why questions

Why must **kwargs always come last in a definition?
It is a catch-all for every leftover keyword; if anything followed it, that parameter could never receive a value — the greedy dict would already have absorbed it. So the grammar forbids it.
Why does args come back as a tuple and not a list?
Tuples are immutable, signalling "this is a fixed snapshot of what was passed" and making the captured arguments safely hashable/unchangeable — Python deliberately hands you a read-only bundle.
Why does the same * symbol both pack and unpack?
Because packing (def) and unpacking (call) are exact inverses — one gathers many-into-one, the other splits one-into-many. Reusing the symbol makes the round-trip f(*x) where def f(*x) visually symmetric.
Why do parameters after *args become keyword-only?
Once *args starts eating every remaining positional value, no positional slot is left for later parameters — so the only way to reach them is by name.
Why is *args, **kwargs the standard way decorators forward calls?
A decorator wraps an unknown function, so it can't name that function's parameters; catching everything with *args, **kwargs and re-spreading with func(*args, **kwargs) forwards any signature untouched.
Why does def f(a, b, *args) still require a and b?
*args only collects the leftover positionals after the named ones are satisfied. The required a, b are filled first; only then does the surplus flow into args.
Why prefer {**a, **b} over calling a.update(b) to merge dicts?
The star form builds a new dict without mutating a; update changes a in place. Same override rule (later wins), different side effect.

Edge cases

The traps below all live at the boundaries — empty inputs, bare stars, mixed iterables. The figure walks four of them side by side so you can see the memory-model outcome rather than simulate it in your head.

Figure — ` - args` and ` - kwargs` — flexible argument passing
What is args when you call f() where def f(*args)?
The empty tuple (). Not None, not an error — an empty container you can safely loop over (zero times).
What does f(*[42]) bind if def f(x)?
A one-element unpack fills exactly the single positional x=42. Unpacking works for any length, including one and zero.
Can def f(*args, **kwargs) accept a call with no arguments at all?
Yes. args becomes () and kwargs becomes {} — the most permissive signature possible, accepting anything including nothing.
What happens with f(1, 2, a=3) when def f(a, *args)?
TypeError: a is filled positionally by 1, then a=3 tries to fill it again — a single parameter can't be bound both by position and by keyword.
If def f(a, *, b) (bare star), what does the lone * do?
It captures no values but forces everything after it to be keyword-only: b must be given as b=..., and f(1, 2) is an error.
What does def f(a, *args, b, **kwargs) demand at minimum?
a (positional) and b (keyword-only, because it sits after *args) are both required; args and kwargs may be empty. So f(1, b=2) is the minimal valid call.
Does unpacking f(*gen) work if gen is a generator, not a list?
Yes — any iterable can be spread by *, including generators, strings, and sets (order for sets is arbitrary). The star consumes the iterable position by position.


Connections

  • Functions and Parameters — the parameter rules these traps stress-test.
  • Default Arguments — interact with keyword-only slots after *args.
  • Tuples — what *args becomes (immutable → several traps).
  • Dictionaries — what **kwargs becomes.
  • Decorators — the forwarding traps in "Spot the error".
  • Unpacking Assignment — same star idea outside function calls.