Blogs

Python's Argument Markers: the bare *, /, *args, and **kwargs

2026-06-166 min read

I was reading an auth middleware and noticed a lone * sitting in the constructor signature. Here is what that bare *, plus /, *args, and **kwargs actually do, and why I now reach for keyword-only arguments on purpose.

Python's Argument Markers: the bare *, /, *args, and **kwargs

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.