I was reading the constructor of an auth middleware in a FastAPI app the other
day, and a single character stopped me. There was a lone * sitting by itself
in the parameter list, between two normal arguments and two others:
def __init__(self, app, settings, *, user_verifier, qflow_verifier): ...
I knew *args and **kwargs. I had never really thought about what a bare *
on its own was doing there, or why some parameters sat before it and some after.
So I went down the rabbit hole, and it turns out there is a small, tidy system
of markers hiding in Python signatures. This is what I found.
The one-line answer
That bare * is a divider. Everything after it is keyword-only: callers
must pass those arguments by name. Everything before it can be passed either
way. So in the signature above, app and settings can be positional, but
user_verifier and qflow_verifier can only be passed as
user_verifier=... and qflow_verifier=....
That is the whole trick. But once I saw it, I wanted the full picture, because
* is not the only marker that shapes how arguments are passed.
The bare * (keyword-only)
Everything after a bare * must be passed by name. Try to pass it positionally
and you get a TypeError.
def f(a, *, b): ... f(1, b=2) # ok f(1, 2) # TypeError: f() takes 1 positional argument but 2 were given
You can only have one bare * in a signature. A second one is a
SyntaxError, because it is a single divider, not an item in the list. There is
only one boundary to draw.
The / (positional-only)
The mirror image of * is /, added in Python 3.8. Everything before it is
positional-only: callers must pass those by position, and cannot use the
parameter name at all.
def f(a, /): ... f(5) # ok f(a=5) # TypeError: f() got some positional-only arguments passed as keyword
If you have ever tried len(obj=x) and been surprised it fails, this is why.
Many C-implemented builtins are positional-only.
The middle zone
Parameters that sit between / and * (or anywhere, if you write neither
marker) are the normal, flexible kind. They can be passed by position or by
name, your choice. This is the default you get every time you write an ordinary
function.
def f(a): ... f(5) # ok f(a=5) # also ok
*args and **kwargs
Here is the part that confused me at first, because they share the * symbol
but do a completely different job. / and * restrict how existing
parameters are passed. *args and **kwargs collect extra arguments you did
not name.
*args scoops up any leftover positional arguments into a tuple:
def f(*args): print(args) f(1, 2, 3) # args = (1, 2, 3)
**kwargs scoops up any leftover keyword arguments into a dict:
def f(**kwargs): print(kwargs) f(x=1, y=2) # kwargs = {'x': 1, 'y': 2}
One nice side effect: *args also acts as the keyword-only divider on its own,
so anything after it is keyword-only too. That is why you cannot have both
*args and a bare * in the same signature (def f(a, *args, *, b) is a
SyntaxError). They would be fighting over the same job.
The mental model that finally made it click
There are two axes that are easy to conflate. One is position versus name. The other is restrict versus collect. Lay them out in a grid and the whole system falls into place:
| Positional (by position) | Keyword (by name) | |
|---|---|---|
| Restrict to one kind | / (before it = positional-only) | * (after it = keyword-only) |
| Collect the leftovers | *args -> tuple | **kwargs -> dict |
So / and * are dividers: they draw a line and restrict how the
parameters on one side may be passed. *args and **kwargs are collectors:
they catch the extra arguments of each kind. They share the * symbol, but
that is the only thing they have in common.
The full legal ordering, with every marker present at once, looks like this:
def f(pos_only, /, normal, *args, kw_only, **kwargs): ...
You will almost never write all of them together, but it is a useful sanity
check. The maximal form pairs / with one of (* or *args), then ends with
**kwargs.
Why I would actually reach for keyword-only
Back to the middleware that started this. Once I understood the bare *, the
design choice behind it was obvious, and I think it is a good habit worth
copying.
It makes the call site self-documenting. Compare these two calls:
Thing(app, settings, uv, vv) Thing(app, settings, user_verifier=uv, qflow_verifier=vv)
The first one is a puzzle. Which verifier is which? The second one answers the question in the call itself.
It prevents argument-order bugs. Those two verifiers have the same-ish type. If they were positional, I could silently swap them and the code would still type-check and run, just wrongly. Forcing them to be keyword-only makes that transposition impossible. You cannot accidentally pass them in the wrong order when there is no order to get wrong.
It future-proofs the signature. Keyword-only parameters can be reordered, or new ones inserted between them, without breaking a single caller, because nobody depends on their position. Positional parameters are frozen by position forever once people start calling your function.
And the mirror argument explains /: positional-only lets a library author
rename a parameter later without breaking callers who were never allowed to use
the name in the first place.
The takeaway
A bare * in a signature is not noise, it is an intention. It says "from here
on, say what you mean by name." / says the opposite for the parameters before
it. *args and **kwargs are a separate idea entirely: they collect the
extras rather than restricting the named ones. Two axes, four markers, one tidy
grid. I look at function signatures a little more carefully now.
