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:

  1. POSITIONAL_ONLY — before /
  2. POSITIONAL_OR_KEYWORD — between / and *
  3. VAR_POSITIONAL — the *args parameter
  4. KEYWORD_ONLY — after * or *args
  5. VAR_KEYWORD — the **kwargs parameter

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() and aggregate() 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:

  1. Audit usage — search for keyword calls in downstream code (grep for func(name=)
  2. 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")
    ...
  1. 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.

pythonfundamentalspython38

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.