Function signatures as API design

Positional-only, keyword-only, *args, **kwargs, and safer call sites

Every function signature is a contract. Python gives you positional-only parameters before `/`, keyword-only arguments after `*`, and `*args` / `**kwargs` for variable arguments. Defaults are stored on the function object in `__defaults__` (a tuple) and `__kwdefaults__` (a dict), evaluated once at definition time. CPython 3.8+ uses vectorcall internally, which optimizes the calling convention by passing arguments in a C array instead of allocating intermediary objects. But the semantic model remains: `*args` collects extra positional values into a tuple, `**kwargs` collects extra keyword values into a dict. This page covers how to design signatures that make invalid calls structurally impossible, with examples for each parameter kind. <a href="/language-mutable-defaults">Watch out for shared mutable defaults when using default parameters</a>. <a href="/language-type-hints">Combine signatures with type hints for safer call sites</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Design the call boundary before writing the body.

:)
Python version

Targets Python 3.10–3.14. Python 3.9 and below are End-of-Life.

TABLE OF CONTENTS
2.2Function signatures as API design

Positional-only, keyword-only, *args, **kwargs, and safer call sites

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: bool
def 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 dataclass
from inspect import signature
@dataclass(frozen=True, slots=True)
class Query:
table: str
limit: int
trace: bool
def 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: bool
def 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.

Further reading

  • Official docs: function definitions
  • Official docs: inspect.Signature
  • PEP 3102: keyword-only arguments
  • PEP 570: positional-only parameters
  • CPython source: argument binding helpers
BOARD NOTESContext
WHY NO BENCHMARK?

This topic is better taught with structure, semantics, and cross-references than with a synthetic chart.

Design the call boundary before writing the body.

RELATED GUIDES
NEXT CHECKS
Contribute