How does the * in a Python def differ from the * in a call like f(*args)?
answer
- The same symbol, two directions
- Definition side versus call side
- Collecting here, spreading there
- Both ends chained forwards a whole call
basics
~20 sIn a def the star packs: leftover arguments are collected into a tuple or dict. In a call the star unpacks: the iterable or mapping is spread back out into separate arguments. Chaining both forwards a whole call.
solid answer
~40 sThe same symbol does opposite jobs on the two sides. In a definition, `def f(*args, **kwargs)` **collects** — leftover positional arguments become a `tuple`, leftover keyword arguments a `dict`. In a call expression, `f(*items)` **spreads** — each element of the iterable becomes its own positional argument, so `f(*[1, 2])` is `f(1, 2)`, not a call with one list. `f(**options)` spreads a mapping into individual keyword arguments and requires string keys. Put the two back to back and you get the forwarding idiom `def wrapper(*args, **kwargs): return target(*args, **kwargs)`: the wrapper relays exactly what it received, keeping the positional/keyword split intact, without ever naming `target`'s parameters. The cost is that the wrapper now accepts anything, so arity errors surface inside `target` rather than at the caller.
code
python · 8 linesdef target(a, b, c=0):
return a, b, c
def forward(*args, **kwargs):
return target(*args, **kwargs)
print(forward(1, 2, c=3))
print(forward(*("fr_FR", "1 234,56")))go deeper
Learn to read the star by asking one question first: am I looking at a definition or at a call? Remember the concrete result that f(*[1, 2]) is a two-argument call while f([1, 2]) is a one-argument call.
Be ready to write the forwarding wrapper from memory and narrate both halves: the def packs, the call spreads, and the round trip keeps positional and keyword arguments in their original roles. Know that the call side takes any iterable and any string-keyed mapping.
Show that you know what forwarding costs. An interviewer expects you to point out that the wrapper's signature now advertises nothing, that missing-argument errors appear a frame deeper with a misleading traceback, and to say when you would restate a real signature instead.
Frame it as a boundary decision: generic forwarding is how a library stays decoupled from a callee it wraps, but it also erases the documented contract for everyone downstream. Own the rule for where in a codebase that trade is acceptable.
### One symbol, two opposite jobs The single `*` and double `**` mean different things depending on where they are written, and conflating the two is one of the most common intermediate-level mistakes in Python. **In a `def`, the star packs.** `def f(*args, **kwargs)` declares parameters that *collect*: the leftover positional arguments are gathered into a `tuple` bound to `args`, and the leftover keyword arguments into a `dict` bound to `kwargs`. This is the receiving side. **In a call expression, the star spreads.** `f(*items)` takes the iterable `items` and passes each of its elements as a *separate positional argument*. `f(**options)` takes the mapping `options` and passes each key/value pair as a *separate keyword argument*, as though you had typed `f(key1=value1, key2=value2)`. This is the sending side. So `f(*[1, 2])` is exactly `f(1, 2)` — two arguments — while `f([1, 2])` is one argument that happens to be a list. Candidates who say "`f(*args)` passes the tuple" have the direction backwards. ### What each side accepts The `def` side is fixed: `args` is a tuple, `kwargs` is a dict, always. The call side is deliberately looser. `*` accepts **any iterable**, not just a tuple or list — a string (spreading into individual characters), a `set` (with no defined order), a generator object (which is consumed by the call), a `range`. `**` requires a **mapping** with string keys; anything else raises `TypeError`, and a non-string key raises `TypeError: keywords must be strings`. ### The forwarding idiom Put the two sides back to back and you get the pattern the leaf is really about: ```python def wrapper(*args, **kwargs): return target(*args, **kwargs) ``` The first line packs whatever arrived; the second unpacks it into `target`. The net effect is that `target` sees precisely the arguments the original caller wrote — same values, same positions, same keyword names — and the wrapper never had to know `target`'s parameter list. That is why every generic wrapper, adapter, retry helper, timing helper and delegating subclass method in the ecosystem is spelled this way. Two details make the round trip exact. First, the pack/unpack pair preserves the positional/keyword *split*: an argument the caller wrote positionally arrives at `target` positionally, and one written as a keyword arrives as a keyword. Forwarding does not "normalise" everything into keywords. Second, `kwargs` preserves the order the keywords were written, so relaying it is order-stable too. ### Where the errors move The wrapper's own signature now accepts anything, so **argument validation is deferred**. If the caller supplies too few arguments, the `TypeError` is raised by `target`, and the traceback points at the wrapper's forwarding line rather than at the caller's mistake. If `target` has a strict signature, that check still happens — just one frame later. If `target` *also* ends in `**kwargs`, no check happens at all. The same applies to introspection: a wrapper written this way reports "takes any arguments", so editor help, generated docs and static type checking degrade to nothing useful for its callers. That is the price of the idiom, and it is why you declare a real signature whenever you can name one. ### Adjusting a call before forwarding Because `args` is a tuple and `kwargs` a dict, you can inspect or edit them before relaying: ```python def wrapper(*args, **kwargs): kwargs.setdefault("timeout", 5) return target(*args, **kwargs) ``` Mutating `kwargs` is safe — it is a fresh dict built for this call, not the caller's mapping. To change a positional argument, convert to a list first, since the tuple is immutable. Reaching for `kwargs["name"]` blindly is a trap: the key exists only if the caller actually wrote that keyword, so use `dict.get` or `dict.pop` with a fallback. ### Reading a call site quickly Two habits make star-heavy code easy to read. First, ask whether the star sits next to a *parameter name* in a `def` (collecting) or next to a *value* in a call (spreading) — that single distinction resolves almost every confusion. Second, mentally rewrite the call: `send(*row, **headers)` becomes "pass each element of `row` positionally, then each entry of `headers` by name", which immediately tells you how many arguments the callee will see and therefore whether its signature can accept them. A related trap is the temptation to spread something that is not really a sequence of arguments. `f(*some_string)` is legal and passes one argument per character; `f(*mapping)` spreads the *keys*, because iterating a dict yields keys. Both run happily and produce nonsense, so a star applied to a value whose element count you cannot state is a smell worth pausing on. ### A mental summary Reading a star in Python is a two-step question: *am I in a definition or in a call?* In a definition, the star is a funnel pointing inward — many arguments become one object. In a call, the star is a funnel pointing outward — one object becomes many arguments. The forwarding idiom is simply both funnels connected end to end.
- Does f(*x) require x to be a tuple or a list?No — the call side accepts any iterable. A string spreads into its characters, a `range` into its numbers, a `set` in no defined order, and a generator object is consumed by the call. Only the definition side is fixed, where the collected positional arguments are always a tuple.
- Does forwarding turn positional arguments into keyword arguments?No. The pack/unpack round trip preserves the split: an argument the caller wrote positionally arrives positionally, and one written as a keyword arrives as a keyword. That exactness is what makes the idiom safe for wrapping a function whose signature you never read.
- How do you add or override an option before forwarding?Edit the dict — `kwargs.setdefault("timeout", 5)` or `kwargs.pop("internal_only", None)` — then forward it. It is a fresh dict built for this call, so mutating it cannot affect any mapping the caller unpacked. To change a positional argument you must build a `list` from `args` first, since the tuple is immutable.
saying these in an interview costs you the question
- Thinks the star means the same thing in a def and a call
- Says f(*args) passes the tuple as one argument
- Believes ** at a call site works on any object
- Claims forwarding converts everything to keyword arguments
- Cannot say where the arity error surfaces after forwarding