Calling work(x=1) looks like one operation. It includes several: evaluate the callable, evaluate argument expressions left to right, expand any starred arguments, bind values to parameters, create execution state, run the body, and return or raise. User-defined objects can participate through __call__, and descriptors can turn attribute access into bound methods before the call starts.

CPython's vectorcall protocol optimizes part of that pipeline. Instead of requiring every internal caller to construct a positional tuple and keyword dictionary, vectorcall passes arguments through a compact array plus keyword-name tuple. It is important infrastructure for builtins, Python functions, methods, classes, and extension APIs. It does not alter Python's argument semantics, and ordinary Python code cannot demand that an arbitrary callable use it.

Version boundary. Argument order, parameter binding, defaults, *args, **kwargs, descriptors, and __call__ are Python behavior. Vectorcall is a CPython protocol standardized for its C API by PEP 590 and changed across releases. Opcode sequences, frame allocation, freelists, and timings here are CPython 3.14 details. Experiments were verified on CPython 3.14.7.

Experiment 1: callable and arguments have an order

The callable expression is evaluated first, then arguments from left to right.

Pyodide / WebAssembly
events = []


def mark(value):
    events.append(value)
    return value


def choose_callable():
    events.append("callable")
    return lambda *values: values


result = choose_callable()(mark("left"), mark("right"))
print("[event] evaluation order:", events)
print("[result] call arguments:", result)

The events are ['callable', 'left', 'right']. This ordering is a language-level fact and matters when expressions mutate state or raise. Vectorcall only changes how already evaluated values are delivered internally.

Good APIs do not require users to exploit subtle ordering. If argument construction has meaningful side effects, assign intermediate values with names. It improves tracebacks and makes failure boundaries explicit.

Experiment 2: binding is richer than a dictionary update

Use inspect.Signature.bind() to inspect the public binding rules without entering the function.

Pyodide / WebAssembly
import inspect


def configure(host, /, port=443, *, secure=True, **options):
    return host, port, secure, options


signature = inspect.signature(configure)
bound = signature.bind("example.com", 8443, secure=False, retries=2)
bound.apply_defaults()
print("[result] bound arguments:", bound.arguments)

try:
    signature.bind(host="example.com")
except TypeError as error:
    print("[error] positional-only argument passed by name:", type(error).__name__)

Positional-only, positional-or-keyword, keyword-only, variadic positional, and variadic keyword parameters each have distinct rules. Duplicate values, unknown keywords, and missing required parameters fail before the body runs.

Signature.bind() is valuable for decorators and RPC adapters because it reuses a documented model. Do not hand-roll binding with zip() and dictionary updates; that misses positional-only constraints, defaults, and duplicate detection. A wrapper should also preserve metadata with functools.wraps and ideally expose an accurate __signature__ when it changes the interface.

Experiment 3: defaults belong to the function

Defaults are created when the def executes, not per call.

Pyodide / WebAssembly
def collect(item, bucket=[]):
    bucket.append(item)
    return tuple(bucket)


print("[result] first collection:", collect("a"))
print("[result] second collection reuses default:", collect("b"))
print("[state] stored function defaults:", collect.__defaults__)

The second result includes both values. The code object describes parameter slots; the function object stores positional defaults in __defaults__ and keyword-only defaults in __kwdefaults__. Calls combine those values with supplied arguments before execution.

Shared mutable defaults are occasionally deliberate caches, but they hide state in a place callers do not expect. Prefer None plus construction in the body, or expose the state explicitly. Vectorcall cannot change this semantic choice.

Experiment 4: method binding happens before calling

Functions implement the descriptor protocol. Attribute access on an instance creates a bound method carrying the instance.

class Greeter:
    def greet(self, name):
        return f"hello {name}"


greeter = Greeter()
bound = greeter.greet

print(bound.__self__ is greeter)
print(bound.__func__ is Greeter.greet)
print(bound("Ada"))
print(Greeter.greet(greeter, "Ada"))

The last two calls have the same Python meaning. CPython can optimize common obj.method(...) forms so it need not always materialize a bound-method object. Saving bound = obj.method does create and retain one, which is appropriate when passing a callback.

Do not rewrite clear method calls to unbound forms in hopes of speed. Descriptor behavior can differ for custom attributes, and modern CPython already recognizes common patterns. Measure representative code.

Experiment 5: callable objects are regular protocol users

callable() asks whether an object supports calling; __call__ defines behavior for instances.

Pyodide / WebAssembly
class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return self.factor * value


times_three = Multiplier(3)
print("[check] multiplier is callable:", callable(times_three))
print("[result] instance call:", times_three(14))
print("[result] explicit __call__ dispatch:", type(times_three).__call__(times_three, 14))

This is a Python protocol, not evidence that the object shares the internal call path of a Python function. Classes, bound methods, builtins, and extension types can all be callable with different implementations.

Callable objects are excellent when behavior needs named state, configuration, or multiple inspection methods. A closure is often smaller for one operation. Choose based on API clarity and lifecycle, not presumed vectorcall eligibility.

Experiment 6: starred calls must assemble values

Expansion has semantic work even when the eventual callee uses vectorcall.

Pyodide / WebAssembly
def report(*values, **options):
    return values, options


positionals = [1, 2]
keywords = {"mode": "fast"}
print("[result] expanded positional and keyword arguments:", report(0, *positionals, limit=3, **keywords))

try:
    report(mode="safe", **keywords)
except TypeError as error:
    print("[error] duplicate keyword rejected:", type(error).__name__)

Python must iterate starred positional inputs, validate keyword keys, combine mappings, and reject duplicates. At the function boundary, declared *values and **options also request concrete tuple and dictionary objects visible to the body.

Avoid forwarding *args, **kwargs reflexively in hot adapters when the interface is actually fixed. Explicit parameters improve signatures, static analysis, validation, and often reduce assembly. Keep forwarding where open-ended delegation is the real contract.

Experiment 7: measure calls without claiming universality

A microbenchmark can isolate relative overhead in one environment.

from timeit import repeat


def identity(value):
    return value


class Identity:
    def __call__(self, value):
        return value


callable_object = Identity()
for statement in ("identity(1)", "callable_object(1)", "abs(-1)"):
    samples = repeat(statement, globals=globals(), number=200_000, repeat=5)
    print(statement, min(samples) > 0)

This intentionally checks only that measurements completed; publishing exact numbers would require the CPU, build, load, flags, and full samples. timeit removes some noise but does not make a tiny benchmark representative. The callable object includes method lookup and another Python body; abs is a builtin with different work.

The engineering threshold matters: call overhead becomes relevant in tight loops doing almost nothing. In web handlers, database clients, serialization, and numerical kernels, larger boundaries dominate. Profile first. Batching work or moving a loop across an optimized-library boundary usually beats shaving one call.

Experiment 8: inspect public and CPython surfaces

Python exposes signatures; CPython exposes vectorcall mainly through C APIs and type flags.

import inspect
import sys


def add(left, right=0):
    return left + right


print(inspect.signature(add))
print(hasattr(add, "__vectorcall__"))
print(sys.implementation.name)
print(add(40, right=2))

On CPython 3.14 a normal function does not expose a public Python-level __vectorcall__ attribute, even though CPython's call machinery uses the protocol internally. Absence of that attribute does not mean vectorcall is absent. The stable application interface is simply calling the object.

Extension authors use APIs such as PyObject_Vectorcall and type slots. They must obey reference ownership, keyword-name layout, recursion checks, and stable-ABI availability for their target versions. Setting a vectorcall pointer is not enough if the callable's semantics or type flags are wrong. Follow current C API documentation rather than copying an older extension's struct layout.

What vectorcall actually removes

Historically, generic C-level calls commonly represented positional arguments as a tuple and keywords as a dictionary. That is convenient but can allocate containers merely to cross the boundary. Vectorcall represents positional values in a contiguous argument array; keyword names are supplied separately in a tuple, with corresponding values in the same array. A count field also carries flags used by the protocol.

This can remove temporary tuple and dictionary creation between compatible callers and callees. It does not remove evaluation, binding validation, defaults, descriptor semantics, Python frame execution, or concrete *args and **kwargs requested by the callee. Nor does it promise zero allocation for every call.

PEP 590 entered CPython in Python 3.8. Public C API details matured afterward, and individual callable types adopted or changed implementations over time. Therefore "Python uses vectorcall" is too broad for a cross-version claim. Say which callable, interpreter, and release you measured.

Calls through wrappers and boundaries

A decorator adds at least one callable boundary unless the returned object replaces work some other way. The wrapper evaluates and binds its own parameters, performs policy, then calls the wrapped object. Authentication, retries, metrics, and transactions may justify that boundary; dozens of tiny wrappers in an inner numeric loop may not.

Generic wrappers frequently use def wrapper(*args, **kwargs). This is correct for broad forwarding, but it asks Python to expose a tuple and dictionary to the body. It can also hide an API from documentation, IDEs, and static tools. functools.wraps restores identity metadata and establishes __wrapped__; inspect.signature() follows that chain by default. If a decorator changes accepted arguments, publish the new signature rather than pretending transparency.

Crossing from Python into C is not automatically fast. Conversion and validation can dominate: each Python integer may need checking and unboxing, buffers may need contiguity, callbacks may re-enter Python, and errors must become exceptions. Good extension APIs move substantial coherent work per crossing. A C function called once per element from a Python loop may lose to a bulk API despite efficient vectorcall dispatch.

The same principle applies to services. Network RPC overhead dwarfs Python call setup, but a chatty service API is still a boundary problem. Batch values, make ownership explicit, and avoid callbacks across slow boundaries. Vectorcall optimizes representation within one CPython process; it says nothing about serialization or network latency.

API evolution through parameter kinds

Positional-only parameters let an implementation rename internal parameter names without breaking callers and permit a keyword of the same spelling to flow into **kwargs. Builtins often use them for this reason. Keyword-only parameters make policy flags readable and allow positional fields to grow less ambiguously.

These markers are compatibility tools, not style decorations. Changing an existing positional-or-keyword parameter to positional-only breaks keyword callers; changing one to keyword-only breaks positional callers. Use them when designing the first public version, and use deprecation warnings and signature tests for migrations.

Parameter binding errors occur before a Python function body begins, so the function cannot customize their message from inside. A public adapter that needs domain-specific validation can expose an explicit parsing layer, bind with inspect, and translate errors carefully. Preserve the original exception as a cause when it helps developers diagnose malformed integrations.

Recursion and call depth

Each active Python call consumes execution state even though modern CPython's internal frames differ from older heap frame objects. Python enforces a recursion limit to prevent unbounded interpreter-stack growth. Raising that limit is not an algorithmic fix and can risk process failure. Convert deeply recursive traversal to an explicit stack when input depth is externally controlled.

Tail calls are not eliminated as a Python language guarantee. Tracebacks and debugging retain call history, and writing tail-recursive Python does not provide constant-space execution. A loop communicates the intended resource behavior directly.

Practical decisions

  • Design signatures for correctness and readability; positional-only and keyword-only markers can preserve API evolution space.
  • Use inspect.signature().bind() in generic adapters instead of reimplementing parameter rules.
  • Avoid accidental mutable defaults and opaque blanket forwarding.
  • Let normal method syntax benefit from interpreter optimizations; do not manually devirtualize without evidence.
  • Profile before optimizing calls, then seek batching and algorithmic reductions first.
  • Treat vectorcall as CPython C-extension infrastructure, not a Python-level feature switch.
  • Pin and test extension code against every supported CPython version and ABI mode.
  • Preserve wrapper metadata so tooling sees the call contract users actually have.

Exercises

  1. Add every parameter kind to one function and use Signature.bind() to produce one success and four distinct failures.
  2. Write a transparent decorator with functools.wraps; compare its signature and call stack with the undecorated function.
  3. Compare obj.method() with a stored bound method and explain object lifetime implications before benchmarking.
  4. Benchmark one million tiny calls versus one call processing a million values. State what work batching removed.
  5. Replace an open-ended *args, **kwargs wrapper with an explicit signature and list the compatibility tradeoffs.
  6. Read PEP 590 and identify which parts concern Python semantics and which concern C-level representation.

Keep this model

A call begins with language semantics: ordered evaluation, expansion, binding, descriptor behavior, defaults, and callable protocol dispatch. A Python function then executes its code in a new frame. CPython's vectorcall protocol makes delivery between compatible internals cheaper by avoiding mandatory temporary argument containers.

That optimization is real but bounded. It cannot rescue a chatty API, an unnecessary layer of Python callbacks, or an algorithm doing too much work. Keep vectorcall in the implementation layer and make source-level decisions from profiles and clear contracts.

Primary sources