Function signatures are API design. A permissive signature can make refactors expensive; a precise one lets Python reject a bad call before the function body starts.
Core answer
Use positional-only parameters when a name should not become part of the caller contract. Use keyword-only parameters when a flag, timeout, or policy should be readable at the call site. Keep *args and **kwargs for deliberate forwarding surfaces, not for avoiding signature design.
# [CURRENT - 3.10-3.14] Works on Python 3.10+ [PEP 570]from dataclasses import dataclass@dataclass(frozen=True, slots=True)class ExportRequest: tenant_id: str path: str compress: booldef create_export(tenant_id: str, /, path: str, *, compress: bool = True) -> ExportRequest: return ExportRequest(tenant_id, path, compress)request = create_export("TEN-7", "/tmp/orders.csv", compress=False)print(request)Why this design exists
PEP 3102 introduced keyword-only arguments so optional controls stop being confused by position. PEP 570 added positional-only syntax for pure Python so library authors can preserve parameter-name freedom and match long-standing C API signatures.
The design is a compatibility tool. Renaming a positional-only parameter does not break keyword callers because there are none. Making a behavior flag keyword-only forces the call site to document intent.
Mechanics and CPython internals
CPython binds arguments before the function body executes. The code object stores counts for positional-only, positional-or-keyword, and keyword-only parameters; the call machinery maps passed objects into fast local slots or raises TypeError. This is why a bad keyword fails before your validation code runs.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclassfrom inspect import signature@dataclass(frozen=True, slots=True)class Query: table: str limit: int trace: booldef build_query(table: str, /, *, limit: int = 100, trace: bool = False) -> Query: return Query(table, limit, trace)bound = signature(build_query).bind("invoices", limit=25, trace=True)print(bound.arguments)print(build_query(*bound.args, **bound.kwargs))Complexity and tradeoffs
Argument binding cost is proportional to the shape of the call, but it is normally below the cost of the work the function performs. The relevant tradeoff is semantic: stricter signatures reduce ambiguous calls and future compatibility risk, while forwarding-heavy APIs preserve flexibility at the cost of weaker static feedback and later validation.
Idiomatic patterns and refactoring
Refactor boolean positional arguments into keyword-only controls before they spread through a service boundary.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass@dataclass(frozen=True, slots=True)class PublishResult: topic: str durable: booldef publish_legacy(topic: str, durable: bool) -> PublishResult: return PublishResult(topic, durable)def publish(topic: str, /, *, durable: bool) -> PublishResult: return PublishResult(topic, durable)print(publish_legacy("audit", False))print(publish("audit", durable=False))Common mistakes and edge cases
Do not hide required API state inside **kwargs merely to postpone decisions. Do not forward unfiltered keyword dictionaries into a narrower API unless unknown keys should really fail there. Remember that defaults are evaluated at definition time, which connects signature design directly to mutable-default bugs.
When to use / When NOT to use
Use / and * when they express compatibility or call-site clarity. Use variadic arguments when implementing adapters, decorators, dispatchers, or genuinely open option surfaces.
Do not use a complicated signature as performance theater, and do not use untyped variadics as a substitute for a stable public API.