Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·25 MIN

*args and **kwargs: two stars with different prices — and one of them makes unknown names acceptable

*args is a tuple, **kwargs is a dict, and the resemblance ends there. Passing one star is nearly free; passing two costs four times as much. And a **kwargs added for flexibility turns a misspelled argument name from a TypeError into a silently accepted default — throwing away the hint the interpreter learned to give in 3.13.

Full technical treatment

TL;DR

A star does opposite things on the two sides of a call. In a declaration it collects: one star gathers the surplus positional arguments into the tuple args, two stars gather the surplus keyword arguments into the dict kwargs. In a call it spreads: one star lays a sequence out as separate positional arguments, two stars lay a dict out as separate keyword arguments. The collected objects arrive even when there is nothing to deliver: a call with no arguments produces () and {} inside, not None.

Hence the main consequence: two stars added to a signature "for flexibility" make unknown keyword names acceptable to that function — there is now somewhere to put them. The call connect("db", timeuot=5) stops being a TypeError and quietly lands in options, while timeout stays equal to 30. The second consequence is price, and the two stars do not share it: plain(*args) is nearly free (×1.1–1.3 of a direct call), plain(**kwargs) costs four times and more (×4.0–5.2). Stable across all four versions; three decorator layers come to ×8.5–11.9, so eight ninths of the time goes into getting to the work rather than doing it.

Beyond that: numbers, versions and boundaries. The () and {} are semantics, not a claim about allocation: the empty tuple in CPython is shared by everyone, and how much gets allocated is a question for the measurement below, not for the language. What becomes acceptable is exactly the unknown names; the rest of the binding rules stay where they were — a run of bench/args-kwargs/binding_rules.py puts each of them through a real call:

strict(a=1, unknown=9)                         TypeError
loose(a=1, unknown=9)                          a=1 b=2 kwargs={'unknown': 9}
loose()                                        TypeError        ← required parameter
loose(1, a=2)                                  TypeError        ← passed twice
positional_only_loose(a=1, b=2, c=3)           TypeError        ← a name instead of a position

The last line is why the / in a signature is not decoration. And the run prints the most surprising case separately: for def f(a, /, b, *, c, **kwargs) the call f(1, b=2, c=3, a=99) is not an error — a=99 goes into kwargs, because a positional-only parameter does not reserve its name for keyword arguments.

Worse: in 3.13 the interpreter learned to suggest the right name — "Did you mean 'timeout'?". A function with **kwargs throws that help away and behaves on 3.14 exactly as it did on 3.11.

The / from PEP 570 is not decoration: while a parameter can be passed by name, that name is taken, and a key of the same name cannot reach **kwargs. This is how dict itself is declared: its signature is ($self, /, *args, **kwargs).

Where to start
Before this lesson it is enough to understand
  • how a function with parameters is declared, and how it is then called;
  • the difference between passing by position and passing by name: f(1, 2) against f(a=1, b=2);
  • that a list and a dict are containers, and that a dict holds name-to-value pairs.
You do not need to know in advance
  • co_argcount, co_kwonlyargcount, CALL_FUNCTION_EX, BUILD_MAP, DICT_MERGE;
  • why signatures sometimes contain / or a bare *, functools.wraps, Unpack[TypedDict].

Base: a star on the two sides of a call

A star has several roles in Python, and all of them are written with the same character. Until the roles are pulled apart, any conversation about *args and **kwargs goes round in circles, so let us pull them apart first.

What tells the roles apart is the side: a star means one thing where a function is declared and the opposite where it is called.

In a declaration a star collects. It takes whatever found no parameter of its own and puts it under a single name:

  • one star collects the surplus positional arguments — the ones passed by position;
  • two stars collect the surplus keyword arguments — the ones passed by name.

In a call a star spreads. It takes a ready container and supplies its contents as separate arguments:

  • one star spreads a sequence into positional arguments: f(*seq) is the same thing as f(seq[0], seq[1], …);
  • two stars spread a dict into keyword arguments: f(**mapping) is f(name=value, …) for every pair.

Here are those roles side by side, with a last row for the form that uses the star quite differently:

formwhere it standswhat it does
def f(*args, **kwargs)declarationpacks the surplus arguments into a tuple and a dict
f(*seq)callunpacks a sequence into positional arguments
f(**mapping)callunpacks a mapping into keyword arguments
def f(a, /, b, *, c)declarationpacks nothing: / and a bare * only divide the parameters into kinds

Four different operations, and the confusion between the first two is the commonest: f(*args) inside a wrapper does not build a tuple, it takes apart one that already exists. The last row has nothing to do with collecting at all, and it gets a section of its own further down.

And here is the question the rest of the lesson answers: what does that collecting cost? It is written with one character and looks free — but it has a price in time and, on top of that, a consequence time does not measure.

That is already enough to answer the basic interview question. Everything below is about what arrives being a tuple and a dict rather than something arbitrary; about two stars costing four times what one costs at the call site; and about a name the function does not have ceasing to be an error the moment two stars appear in the declaration.

Mechanism 1: what these objects are

language contractLanguage guarantee: *args is a tuple, **kwargs a dict, and the argument-binding rules do not depend on the version.

Start with what one function settles.

PYTHON
def collect(*args, **kwargs):
    return type(args).__name__, type(kwargs).__name__

collect(1, 2, x=3) returns ('tuple', 'dict'). Not a list — a tuple, and one line settles it: args.append(3) gives AttributeError: 'tuple' object has no attribute 'append'. There will be no mutable container here, however many arguments arrive.

The second and less obvious part: the objects are created even when there is nothing to deliver.

PYTHON
def probe(*args, **kwargs):
    return args, kwargs
 
probe()   # -> ((), {})

Not None, but an empty tuple and an empty dict. The language reference puts it exactly so: *identifier "is initialized to a tuple receiving any excess positional parameters, defaulting to the empty tuple". So if args: is the right check, while if args is not None: is always true.

What the function's own code knows about the stars

PYTHON
def full(a, b=2, *args, c, d=4, **kwargs):
    pass

The signature looks tangled, but the interpreter breaks it into three independent numbers and two flags:

fieldvaluewhat it means
co_argcount2a and b are ordinary: positionally or by name
co_posonlyargcount0nothing before a /
co_kwonlyargcount2c and d are keyword-only
CO_VARARGSTruethere is a *args
CO_VARKEYWORDSTruethere is a **kwargs

From which follows the simple fact that clears up the main confusion: c and d became keyword-only not because they have defaults but because they stand after *args. That is the rule from PEP 3102, and a bare * in the signature does the same without collecting a tuple.

The order of keyword arguments is preserved: order(z=1, a=2, m=3) yields keys as ['z', 'a', 'm'], not sorted.

Mechanism 2: the bug that never crashes

A connection function. Three understandable parameters, and someone added a fourth — "in case something needs passing through to the driver".

PYTHON
def connect(host, port=5432, timeout=30, **options):
    ...

Six months later, in another file, someone writes a call:

PYTHON
connect("db", timeuot=5)

There is no error. timeuot is a legal key for **options, so the function receives timeout = 30 while the 5 lands in options, where nobody looks. The connection is made, the program carries on, and the difference shows up one day under load — as a timeout nobody expected.

Without **options the same call fails at once, and fails informatively.

Why this got worse rather than better

In 3.13 the interpreter learned to suggest the right name. What's New says: "The error message now tries to suggest the correct keyword argument when an incorrect keyword argument is passed to a function". Verified on all four versions:

versionconnect("db", timeuot=5) without **options
3.11.15got an unexpected keyword argument 'timeuot'
3.12.3the same
3.13.7... 'timeuot'. Did you mean 'timeout'?
3.14.7same as 3.13.7

So the language got better at catching this exact typo — and a function declared with **kwargs throws that help away. With it, 3.14 behaves just like 3.11: it says nothing.

What actually fixes it

Not vigilance. If **kwargs really is needed, then a check for unknown keys where they are accepted:

PYTHON
KNOWN = {"sslmode", "application_name"}
 
def connect(host, port=5432, timeout=30, **options):
    unknown = set(options) - KNOWN
    if unknown:
        raise TypeError(f"unknown parameters: {', '.join(sorted(unknown))}")

Four lines restore the refusal on an unknown name — the one that stopped happening by itself the moment those names became acceptable. And if **kwargs was not needed, its absence is the check — for free.

Mechanism 3: / and the bare * — on taking names

Two things from PEP 570 and PEP 3102 that have a measurable consequence, not merely a cosmetic one.

The bare * forbids passing anything after it positionally:

PYTHON
def open_conn(host, *, timeout=30, retries=3):
    ...

Now open_conn("db", 5) is an error rather than a five-second timeout that arrived by counting. The order of parameters stops being part of the contract, and can be changed without breaking callers.

The slash / does the opposite — it forbids passing by name. And it exists not for symmetry but for a reason only a run makes visible:

PYTHON
def name_taken(name, **kwargs):
    return name, kwargs
 
def name_free(name, /, **kwargs):
    return name, kwargs
callresult
name_taken("a", name="b")TypeError: got multiple values for argument 'name'
name_free("a", name="b")('a', {'name': 'b'})

While a parameter can be passed by name, that name is taken: a key of the same name cannot reach **kwargs. PEP 570 names this very case: "A key scenario is when a function accepts any keyword argument but can also accepts a positional one".

This is how dict itself is declared. Its signature is visible from the interpreter:

PYTHON
dict.__init__.__text_signature__   # ($self, /, *args, **kwargs)
dict(self="x")                     # {'self': 'x'}

The slash releases the name self — without it, dict(self="x") would give got multiple values for argument 'self'.

Mechanism 4: what a wrapper does to a signature

*args, **kwargs in a decorator has a side effect that is invisible in the wrapper's own code.

PYTHON
def wrap(fn):
    def inner(*args, **kwargs):
        return fn(*args, **kwargs)
    return inner

inspect.signature reports (*args, **kwargs) for such a wrapper — the parameter names are gone, and with them editor hints and half the value of type checking. @functools.wraps(fn) cures that, and afterwards the signature shown is the original: (host, port=5432, timeout=30).

But it does not describe what the wrapper will accept. Verified: the wrapper passes five positional arguments and any keyword name straight through, checking nothing. The call then fails inside: connect() takes from 1 to 3 positional arguments but 5 were given. @wraps gives the signature back to the tooling without making the wrapper obey it.

It shows in the traceback too: there is one frame more than there are layers — your own call frame plus one per wrapper.

how it was calledframes in the traceback
directly1
through one wrapper2
through three4

The message always names the inner function — @wraps copied its name, so the two cannot be told apart by text. The frames differ: without @wraps the traceback shows three consecutive inner frames before the line with the typo.

Mechanism 5: typing — giving the erased names back

This is the only automatic way to restore what **kwargs erases.

Before 3.12, **kwargs could be annotated only with one type for all values. PEP 692 names the limitation outright: "Currently **kwargs can be type hinted as long as all of the keyword arguments specified by them are of the same type. However, that behaviour can be very limiting". From 3.12 there is Unpack[TypedDict]:

PYTHON
class Options(TypedDict, total=False):
    sslmode: str
    application_name: str
 
def connect(host: str, **options: Unpack[Options]) -> None:
    ...

Now a type checker — mypy, pyright — sees names again and catches timeuot=5 before the code runs: error: Unexpected keyword argument "timeuot". The call itself is unaffected: the interpreter still accepts it in silence — to it, the name is acceptable. The refusal on an unknown name comes back from outside, before the run, since inside the call it is gone.

Deeper: what the stars cost

measured observationbench/args-kwargs/cost.py, CPython 3.11–3.14 on one machine. Absolute nanoseconds depend on the machine; the ratio is what carries meaning.

Seven call forms measured back to back in one process: four are ways of passing two numbers to plain, three do the same through a function with stars and through wrappers.

The point here is the asymmetry. The two stars are written side by side, named in one breath, and priced entirely differently:

  • plain(*args) — ×1.1–1.3 of a direct positional call. Practically free.
  • plain(**kwargs) — ×4.0–5.2. More than fourfold.

The reason is visible in the bytecode, and it is more specific than "a dict is more complicated than a tuple". Identical on all four versions:

plain(*ARGS)      LOAD_GLOBAL LOAD_GLOBAL CALL_FUNCTION_EX
plain(**KWARGS)   LOAD_GLOBAL LOAD_CONST BUILD_MAP LOAD_GLOBAL DICT_MERGE CALL_FUNCTION_EX

On the calling side the tuple goes in as it is: CALL_FUNCTION_EX receives a ready object. Inside, a function with *args will build a tuple of its own — g(*T) is T is False — but that happens on an ordinary call too. Before a call with a dict, the interpreter builds a new dict (BUILD_MAP) and copies the given one into it (DICT_MERGE) — so the dict is copied on every call, before name matching even begins. Hence the fourfold gap.

Passing by name works differently and costs less: it compiles not to CALL_FUNCTION_EX but to CALL_KW, with a ready tuple of names among the constants — no dict is built at all. Hence the gap: ×1.3–1.9 against the four and more of plain(**kwargs). That is not an argument against keyword arguments — readability is worth more than ten-odd nanoseconds (on 3.13.7 the gap came to 11.3 ns) — but it is an argument against treating them as free inside a hot loop.

Wrappers stack

A decorator built on *args, **kwargs does both jobs at once: it receives with stars (building a tuple and a dict) and passes with stars (taking them apart again).

layerstimes dearer than a direct call
one×3.6–4.6
three×8.5–11.9

At three layers, eight ninths of the time goes into getting to the work rather than doing it. This is not an argument against decorators — it is an argument for knowing where they are expensive: in a handler that hits a database those hundreds of nanoseconds are invisible; in a function called a million times per pass they become a visible share.

Version history

VersionChangeWhat this means for your code
3.0PEP 3102 introduces the bare * and the rule that everything after it is passed by name only. The motive is written down — a function with a variable number of arguments that also has "options"; before this they were dug out of **kwargs by hand. Hence co_kwonlyargcount, a field of its own on the code object.
3.8PEP 570 introduces /. Not for symmetry: while a parameter is passable by name, that name is taken and a key of the same name cannot reach **kwargs. Verified by running it: name_taken("a", name="b") is a TypeError, name_free("a", name="b") works.
3.12PEP 692 allowed Unpack[TypedDict] for **kwargs: a checker sees names and types again. Before it, every value had to share one type — “that behaviour can be very limiting”. The name Unpack itself arrived earlier, in 3.11 (PEP 646), so the annotation is accepted at an older target level too.
3.13The interpreter suggests the right name: “Did you mean 'timeout'?”. Verified on all four versions — absent on 3.11 and 3.12. One caveat that matters: it does not reach a function with **kwargs at all, because no error occurs there.

How to answer in an interview

The short answer: a star in a declaration collects, a star in a call spreads. One star collects the surplus positional arguments into the tuple args, two collect the surplus keyword arguments into the dict kwargs; in a call, one spreads a sequence into positional arguments and two spread a dict into keyword arguments. Both objects are built even when there is nothing to pass: you get () and {}, not None.

That is enough to answer correctly. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

First, price, and the two stars do not share it: plain(*args) is almost free, while plain(**kwargs) costs four times a direct call or more — consistently across all four versions.

What separates a good answer: naming not the cost but the fact that a **kwargs added "for flexibility" makes unknown keyword names acceptable. The call connect("db", timeuot=5) stops being a TypeError and quietly lands in options, while timeout stays equal to 30. Worse, since 3.13 the interpreter can suggest the right name — and a function with **kwargs throws that help away, behaving on 3.14 exactly as it did on 3.11.

Next they ask

Next they ask

The wrapper has @functools.wraps and inspect.signature shows the original signature. So the wrapper accepts exactly what the target accepts?

Short answer

No. wraps hands the signature to tools, but it does not make the wrapper obey it: the wrapper lets five positional arguments through, and any keyword too, checking nothing. What fails is the call to the target inside — connect() takes from 1 to 3 positional arguments but 5 were given — and the traceback shows it: one frame more than there are layers.

Next they ask

Why put a bare * or a / in the signature if the argument order can simply be agreed on?

Short answer

A bare * forbids passing anything after it positionally: the order of parameters stops being part of the contract and can be changed without breaking callers. The slash does the opposite and matters where a function takes **kwargs: while a parameter can be passed by name, that name is taken, and a key of the same name cannot land in **kwargs. That is how dict itself is declared — ($self, /, *args, **kwargs) — which is why dict(self="x") works.

Common misconceptions

Claim

*args is a list

Actually

A tuple. type(args).__name__ gives tuple on all four versions. It cannot be modified — and that is not pedantry: it is assembled afresh on every call, and mutability would mean someone could keep a reference and rewrite another call's arguments.

Claim

with no arguments, args and kwargs are None

Actually

An empty tuple and an empty dict. The language reference: *identifier “is initialized to a tuple… defaulting to the empty tuple”. So if args: works while if args is not None: is always true — and both look equally plausible.

Claim

the stars are just notation; there is no work behind them

Actually

There is work, and it differs between the two stars. plain(*args) is ×1.1–1.3 of a direct call, plain(**kwargs) is ×4.0–5.2. The bytecode shows why on all four versions: the tuple goes into the call as it is, while a call with a dict runs BUILD_MAP and DICT_MERGE first — the dict is copied on every call.

Claim

**kwargs adds flexibility and takes nothing away

Actually

It takes away the refusal on an unknown name: with two stars, unknown names become acceptable to the function. connect("db", timeuot=5) is a TypeError without **kwargs and a silently accepted call with it, leaving timeout equal to 30. And the hint “Did you mean 'timeout'?”, learned in 3.13, never fires either: there is no error for it to attach to.

Claim

functools.wraps fixes the wrapper's signature

Actually

It gives the signature back TO THE TOOLING without making it true. After @wraps, inspect.signature reports (host, port=5432, timeout=30), while the wrapper itself accepts five positional arguments and any keyword name — the exception comes from the inner function, and the traceback gains one frame per layer.

Claim

c and d in def f(a, *args, c, d=4) can be passed positionally

Actually

They are keyword-only, and not because of the defaults but because of position: everything after *args is passed by name only (PEP 3102). It is visible on the code object — co_kwonlyargcount is two. c has no default at all and is keyword-only regardless.

Claim

the slash in a signature is documentation decoration

Actually

Without it the parameter's name is taken for good: a key of the same name cannot reach **kwargs, and the call raises got multiple values for argument. That is exactly why dict is declared with a slash: its signature is ($self, /, *args, **kwargs), and dict(self="x") gives {'self': 'x'}. PEP 570's second argument: parameter names stop being part of the public contract and can be changed.

Claim

**kwargs cannot be annotated

Actually

Before 3.12 it could, but with one type for every value — PEP 692 calls that “very limiting”. From 3.12 there is Unpack[TypedDict], which gives the checker back names and types. It is the only automatic way to catch timeuot=5 before the code runs when **kwargs cannot be removed.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

The function has a **options parameter added for flexibility. The second call has a typo in an argument name. What does this code print?
def connect(dsn, timeout=30, **options):
  return timeout, options


print(connect("db", timeout=5))
print(connect("db", timeuot=5))

Practice · estimate

The same three-argument function: a direct call against a call through **kwargs. How many times more expensive is the second?
times

Knowledge check

Question 1 of 5

What does probe() return for def probe(*args, **kwargs): return args, kwargs?

Sources & further reading

6 SOURCES

  1. The language reference — function definitions and callsOfficial documentation. The grammar that fixes the order of the parts of a signature, and the rule this lesson verifies by running code: “If the form `*identifier` is present, it is initialized to a tuple receiving any excess positional parameters, defaulting to the empty tuple”. And for the dict: “If the form `**identifier` is present, it is initialized to a new ordered mapping receiving any excess keyword arguments, defaulting to a new empty mapping of the same type”. Verified: a call with no arguments delivers `()` and `{}`, not `None`.https://docs.python.org/3.14/reference/compound_stmts.html#function-definitions
  2. PEP 3102 — Keyword-Only ArgumentsPEP. Talin, Final, Python 3.0. The source of the bare `*` and of the rule that everything after it is passed by name only. The motive is stated outright: “One can easily envision a function which takes a variable number of arguments, but also takes one or more 'options' in the form of keyword arguments” — before this, those options had to be dug out of `**kwargs` by hand.https://peps.python.org/pep-3102/
  3. PEP 570 — Python Positional-Only ParametersPEP. Larry Hastings, Pablo Galindo Salgado, Mario Corchero, Eric N. Vander Weele; Final, Python 3.8. Two reasons for `/`, and the second is verified by running code in this lesson: “Without the ability to specify which parameters are positional-only, library authors must be careful when choosing appropriate parameter names” and “A key scenario is when a function accepts any keyword argument but can also accepts a positional one”. Verified by running it: `dict.__init__.__text_signature__` is `($self, /, *args, **kwargs)`, and the slash releases the name `self`, so `dict(self="x")` gives `{'self': 'x'}`.https://peps.python.org/pep-0570/
  4. PEP 692 — Using TypedDict for more precise **kwargs typingPEP. Franek Magiera, Final, Python 3.12. It names the limitation that made typing `**kwargs` nearly useless before: “Currently **kwargs can be type hinted as long as all of the keyword arguments specified by them are of the same type. However, that behaviour can be very limiting”. `Unpack[TypedDict]` is the only way to give a checker back the names and types that `**kwargs` erases.https://peps.python.org/pep-0692/
  5. What's New in Python 3.13 — improved error messagesOfficial documentation. A change that bears directly on this lesson: “The error message now tries to suggest the correct keyword argument when an incorrect keyword argument is passed to a function”, with the example `split() got an unexpected keyword argument 'max_split'. Did you mean 'maxsplit'?`. Verified on all four versions: absent on 3.11 and 3.12, present on 3.13 and 3.14 — and a function with `**kwargs` gets it on none of them.https://docs.python.org/3.14/whatsnew/3.13.html
  6. functools — wraps and update_wrapperOfficial documentation. The source of the behaviour that makes a wrapper's signature lie in two different ways depending on one line. Without `@wraps` the wrapper reports `(*args, **kwargs)`; with it, `inspect.signature` follows `__wrapped__` and reports the original function's signature — the one the wrapper does not obey. Both checked in bench/args-kwargs/wrapper_signature.py.https://docs.python.org/3.14/library/functools.html#functools.wraps