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.

:)
TABLE OF CONTENTS
2.2Function signatures as API design

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

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.

Core answer

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.

Mechanism and binding

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.x
import inspect
def sample(a, b=1, /, c=2, *, d=3, **kw):
return a, b, c, d, kw
print(sample.__defaults__) # positional defaults
print(sample.__kwdefaults__) # keyword-only defaults
print(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.x
def 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"))
Version context

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.

Edge cases and gotchas

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.x
def configure(required, **options):
allowed = {"timeout", "retries"}
unknown = options.keys() - allowed
if unknown:
raise TypeError(f"unknown options: {sorted(unknown)}")
return required, options

Remember 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.

Production usage

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
Further depth
  • Language reference: calls
  • Python tutorial: defining functions
  • PEP 3102: Keyword-Only Arguments
  • PEP 570: Python Positional-Only Parameters
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