Positional-Only Parameters in Python — Deep Dive
Technical perspective
PEP 570 filled a gap that existed since Python’s creation: pure Python functions couldn’t replicate the calling conventions of C-implemented built-ins. Functions like len(), range(), and dict.get() accepted arguments only by position, but there was no syntax for user-defined functions to do the same. The / separator now provides full parity, which has implications for library design, type stubs, and function introspection.
How CPython implements it
At the bytecode level, positional-only parameters use the same LOAD_FAST instructions as regular parameters. The enforcement happens during argument binding in _PyArg_UnpackKeywords (for C functions) and in the function call machinery (for Python functions).
When you call func(a=1) where a is positional-only, CPython raises TypeError during argument binding — before the function body executes. The error message is explicit:
TypeError: func() got some positional-only arguments passed as keyword arguments: 'a'
You can inspect the parameter kinds using the inspect module:
import inspect
def example(a, b, /, c, *, d):
pass
sig = inspect.signature(example)
for name, param in sig.parameters.items():
print(f"{name}: {param.kind.name}")
# Output:
# a: POSITIONAL_ONLY
# b: POSITIONAL_ONLY
# c: POSITIONAL_OR_KEYWORD
# d: KEYWORD_ONLY
The five parameter kinds in order:
POSITIONAL_ONLY— before/POSITIONAL_OR_KEYWORD— between/and*VAR_POSITIONAL— the*argsparameterKEYWORD_ONLY— after*or*argsVAR_KEYWORD— the**kwargsparameter
Interaction with *args and **kwargs
Positional-only parameters compose naturally with variadic arguments:
def flexible(required, /, *args, **kwargs):
print(f"required={required}")
print(f"args={args}")
print(f"kwargs={kwargs}")
flexible(1, 2, 3, key="value")
# required=1
# args=(2, 3)
# kwargs={'key': 'value'}
A particularly powerful pattern for decorator factories:
import functools
def retry(func=None, /, *, max_attempts=3, delay=1.0):
"""Can be used as @retry or @retry(max_attempts=5)"""
if func is None:
return functools.partial(retry, max_attempts=max_attempts, delay=delay)
@functools.wraps(func)
async def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except Exception:
if attempt == max_attempts - 1:
raise
await asyncio.sleep(delay * (2 ** attempt))
return wrapper
@retry
async def fetch_data(): ...
@retry(max_attempts=5, delay=0.5)
async def fetch_critical(): ...
Making func positional-only is essential here. Without /, calling @retry(func=something) would bind to the func parameter instead of being treated as a decorated function.
Overload patterns with positional-only parameters
Type stubs for built-in functions use positional-only syntax extensively:
from typing import overload
@overload
def process(data: str, /) -> list[str]: ...
@overload
def process(data: bytes, /) -> list[bytes]: ...
@overload
def process(data: list[str], /) -> list[str]: ...
def process(data, /):
if isinstance(data, str):
return data.split()
elif isinstance(data, bytes):
return data.split()
elif isinstance(data, list):
return [item.strip() for item in data]
raise TypeError(f"Unsupported type: {type(data)}")
Positional-only parameters in overloads are cleaner because the parameter name is purely internal — callers interact only with the positional slot.
Enabling same-name kwargs pattern
This is the killer use case for framework authors. Consider a function that creates HTML elements:
def element(tag, /, **attributes):
attrs = " ".join(f'{k}="{v}"' for k, v in attributes.items())
return f"<{tag} {attrs} />"
# All of these work:
element("input", type="text", name="email", id="email-field")
element("meta", charset="utf-8")
Without positional-only, tag would be a reserved name — passing tag="something" in **attributes would conflict. With /, the caller can use any keyword argument freely.
This pattern appears in:
- Django’s
QuerySet.annotate()andaggregate()methods - SQLAlchemy’s
Column()constructor - Click’s decorator-based CLI builder
Migration strategy for existing libraries
Adding positional-only to an existing function is technically a breaking change if any caller uses keyword syntax:
# Before: callers might write func(name="Alice")
def func(name):
...
# After: func(name="Alice") now raises TypeError
def func(name, /):
...
Safe migration approach:
- Audit usage — search for keyword calls in downstream code (grep for
func(name=) - Deprecation period — emit a warning when positional-only candidates are called with keywords:
import warnings
def func(name=None, /, **kwargs):
if "name" in kwargs:
warnings.warn(
"Passing 'name' as keyword is deprecated. Use positional.",
DeprecationWarning,
stacklevel=2,
)
name = kwargs.pop("name")
...
- Enforce — after one major version, add the
/separator
NumPy followed this pattern when adopting PEP 570 across its API.
Testing positional-only enforcement
Verify that your API rejects keyword arguments:
import pytest
def test_positional_only_enforcement():
with pytest.raises(TypeError, match="positional-only"):
distance(x1=0, y1=0, x2=3, y2=4)
def test_positional_call_works():
assert distance(0, 0, 3, 4) == 5.0
Interaction with dataclasses and Pydantic
Dataclass __init__ methods don’t support positional-only parameters directly. However, you can use __post_init__ or a custom __init__:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
# Dataclass generates __init__(self, x, y) — not positional-only
# To enforce, override:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
In practice, most dataclasses don’t need positional-only parameters since their field names are part of the public interface.
Pydantic models similarly use keyword arguments by design — field names are the schema. Positional-only is more relevant for utility functions and framework internals than data models.
Bytecode comparison
import dis
def with_pos_only(a, b, /):
return a + b
def without(a, b):
return a + b
dis.dis(with_pos_only)
dis.dis(without)
The bytecode is identical for the function body. The difference is in the code object’s co_posonlyargcount attribute:
print(with_pos_only.__code__.co_posonlyargcount) # 2
print(without.__code__.co_posonlyargcount) # 0
This attribute is what CPython’s argument-binding code checks to raise TypeError for keyword usage.
Real-world adoption
- CPython stdlib:
str.replace(old, new, count, /),list.index(value, start, stop, /),dict.get(key, default, /) - NumPy 2.0: Adopted positional-only across most array creation functions
- FastAPI: Uses positional-only in internal dependency injection to avoid name collisions
- Click 8.x: Parameter names are positional-only in decorator signatures
The trend is clear: as Python’s type ecosystem matures, positional-only parameters are becoming a standard tool for clean API design rather than a niche feature.
The one thing to remember: Positional-only parameters give function authors control over their API’s evolution surface — parameter names become implementation details rather than public contracts, enabling renaming, overloading, and kwargs flexibility without breaking callers.
See Also
- Python 310 New Features Python 3.10 gave programmers a shape-sorting machine, friendlier error messages, and cleaner ways to say 'this or that' in type hints.
- Python 311 New Features Python 3.11 made everything faster, error messages smarter, and let you catch several mistakes at once instead of stopping at the first one.
- Python 312 New Features Python 3.12 made type hints shorter, f-strings more powerful, and started preparing Python's engine for a world without the GIL.
- Python 313 New Features Python 3.13 finally lets multiple tasks run at the same time for real, added a speed booster engine, and gave the interactive prompt a colourful makeover.
- Python Exception Groups Python's ExceptionGroup is like getting one report card that lists every mistake at once instead of stopping at the first one.