A function signature is a contract you negotiate once and your callers pay for every time. A call site like resize(img, 800, 600, False, 90) is a maintenance problem waiting to surface — what does False mean here?
Think of a signature like a physical key. A key cut wrong cannot enter the lock. Python's / and * markers cut your function's key so that wrong calls are rejected before the body runs. Positional-only (/) means callers cannot use keyword form for those parameters — you can rename them later without breaking anyone. Keyword-only (*) means callers must name those arguments, eliminating position confusion with booleans and flags.
Use ordinary positional-or-keyword parameters for the stable core of the operation. Use * to force options to be keyword-only. Use / to make parameters positional-only when their names are not part of the public contract.
# [OLDER / 3.8-3.9, CURRENT - 3.10-3.14] Requires Python 3.8+ [PEP 570]def resize(image, width, height, /, *, keep_aspect=True, quality=85): return (image, width, height, keep_aspect, quality)resize("photo.jpg", 800, 600, keep_aspect=False, quality=90)Keyword-only options prevent opaque call sites such as resize(img, 800, 600, False, 90) where booleans and tuning numbers lose meaning.
Before the function body runs, Python binds arguments to parameters according to the call rules in the language reference. Extra positional arguments become a tuple via *args. Extra keyword arguments become a dict via **kwargs.
# [CURRENT - 3.10-3.14] Works on Python 3.ximport inspectdef sample(a, b=1, /, c=2, *, d=3, **kw): return a, b, c, d, kwprint(sample.__defaults__) # positional defaultsprint(sample.__kwdefaults__) # keyword-only defaultsprint(inspect.signature(sample))On CPython, modern call performance is helped by optimizations such as vectorcall, but that does not change the semantic model: *args still means a tuple object is logically created, and **kwargs still means a mapping of extra keywords.
# [CURRENT - 3.10-3.14] Works on Python 3.xdef tag(name, *content, class_=None, **attrs): if class_ is not None: attrs["class"] = class_ attr_text = "".join(f' {k}="{v}"' for k, v in sorted(attrs.items())) body = "".join(content) return f"<{name}{attr_text}>{body}</{name}>"print(tag("p", "hello", class_="lead", id="intro"))Keyword-only parameters came from PEP 3102. Positional-only syntax for Python functions came from PEP 570 in Python 3.8. Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.
Open-ended **kwargs signatures weaken the API boundary if you do not validate them. Unknown options should usually fail immediately with TypeError, not drift deeper into the call graph.
# [CURRENT - 3.10-3.14] Works on Python 3.xdef configure(required, **options): allowed = {"timeout", "retries"} unknown = options.keys() - allowed if unknown: raise TypeError(f"unknown options: {sorted(unknown)}") return required, optionsRemember that defaults are stored on the function object and reused. Mutable defaults therefore become shared state; see .
Prefer explicit parameters until you have a real adaptation or forwarding boundary. Open-ended signatures move errors from review time to runtime.
Use / when parameter names are an implementation detail or may change later. Use * when readability and caller safety matter more than brevity. Use explicit sentinels when None is a real business value.
# [OLDER / 3.8-3.9, CURRENT - 3.10-3.14] Requires Python 3.8+ [PEP 570]_missing = object()def lookup(key, /, default=_missing, *, case_sensitive=True): if default is _missing: return f"raise KeyError for {key!r}" return default, case_sensitive