Create your own
Lesson illustration

Designing Function Signatures with Parameter Types

Good to see you again. The previous lesson focused on an object’s contract with hash-based collections: equality, hashing, and mutability must agree. Function signatures serve a comparable purpose at an API boundary: they define not only which values a function accepts, but also the allowed ways callers may supply them.

This lesson develops a practical signature-design vocabulary. You will use positional-only parameters to protect an API’s internal parameter names, keyword-only parameters to make configuration explicit, and variadic parameters to accept deliberately open-ended inputs. The aim is not to make every signature clever; it is to make the call contract clear, safe, and evolvable.


A function signature is a calling contract

In an ordinary function definition, each parameter is positional-or-keyword:

def connect(host, port, timeout_s=3.0):
    ...

Each of these calls is valid:

connect("198.51.100.10", 443)
connect(host="198.51.100.10", port=443)
connect("198.51.100.10", port=443, timeout_s=5.0)

This flexibility is often appropriate. But in a public utility, library, or shared automation module, unrestricted calling can create problems:

  • Boolean flags and similarly typed settings are easy to mix up positionally.
  • A parameter name may become an accidental part of your public API.
  • An API may need to accept a variable number of checks, labels, or options.
  • A long positional call becomes difficult to review accurately.

Python lets a signature express these choices directly:

def inspect_target(target, /, timeout_s=3.0, *, include_routes=False):
    ...

Read this signature in three regions:

RegionParametersHow callers supply them
Before /targetPosition only
Between / and *timeout_sPosition or keyword
After *include_routesKeyword only

Thus:

inspect_target("branch-chennai")
inspect_target("branch-chennai", 5.0)
inspect_target("branch-chennai", timeout_s=5.0, include_routes=True)

are valid, while these are not:

inspect_target(target="branch-chennai")
inspect_target("branch-chennai", 5.0, True)

The first tries to supply a positional-only parameter by name. The second tries to supply a keyword-only setting positionally.

4. More Control Flow Tools — Python 3.14.0 documentation

Read the official Python tutorial’s treatment of keyword arguments, special parameters, and arbitrary argument lists. It establishes the exact syntax and then explains why /, *, *args, and **kwargs exist.

Start in Section 4.9.2, “Keyword Arguments.” Read from arbitrary keyword capture through the cheeseshop example, noting that *name receives a tuple and **name receives a dictionary. Then read all of Section 4.9.3, “Special parameters,” including its “Function Examples” and “Recap” subsections. Begin with the signature overview; focus on the three regions separated by / and *, and on the foo(name, /, **kwds) collision example. Finish with Section 4.9.4, “Arbitrary Argument Lists.” Read the variadic rule, then study the concat example and the explanation of why parameters after *args are keyword-only.

There are five parameter categories worth recognizing:

def operation(pos_only, /, normal, *items, setting, **options):
    ...
CategorySyntaxBound value inside the function
Positional-onlybefore /One value, supplied by position
Positional-or-keywordordinary parameterOne value, supplied either way
Variadic positional*itemsA tuple of remaining positional values
Keyword-onlyafter * or *itemsOne value, supplied by keyword
Variadic keyword**optionsA dictionary of remaining keywords

A useful mental model for a call is:

  1. Positional values fill positional-only parameters first, then positional-or-keyword parameters.
  2. Any additional positional values are collected by *items, if present.
  3. Keyword values bind positional-or-keyword and keyword-only parameters.
  4. Any unmatched keywords are collected by **options, if present; otherwise Python raises TypeError.
  5. Supplying a value twice, once positionally and once by keyword, is always an error.

The separators / and * are not values and do not exist at runtime. They are part of the function’s declaration of its public calling convention.


Use keyword-only parameters for meaningful configuration

A parameter should generally be keyword-only when its name helps the caller understand the action. This is especially valuable for options, flags, timeouts, limits, and parameters with compatible types but different meanings.

Consider an API that queries routes:

def query_routes(
    prefix,
    *,
    vrf="default",
    include_inactive=False,
    timeout_s=3.0,
):
    print(
        f"prefix={prefix}, vrf={vrf}, "
        f"include_inactive={include_inactive}, "
        f"timeout_s={timeout_s}"
    )

A call is explicit at the point where it matters:

query_routes(
    "10.20.0.0/16",
    vrf="production",
    include_inactive=True,
    timeout_s=5.0,
)

Compare that with an all-positional alternative:

query_routes("10.20.0.0/16", "production", True, 5.0)

The latter is valid Python, but a reviewer must remember the parameter order to know whether True means “include inactive,” “use cache,” or something else. Keyword-only parameters move that meaning into the call site.

They can also be required. A default value is not required for keyword-only status:

def create_ipsec_tunnel(site, *, peer, profile):
    return {
        "site": site,
        "peer": peer,
        "profile": profile,
    }

This forces the call to identify the two easily confused values:

create_ipsec_tunnel(
    "chennai-edge",
    peer="198.51.100.20",
    profile="branch-standard",
)

These calls fail early and clearly:

create_ipsec_tunnel("chennai-edge", "198.51.100.20", "branch-standard")
create_ipsec_tunnel("chennai-edge", peer="198.51.100.20")

The first incorrectly passes keyword-only parameters by position. The second omits a required keyword-only parameter.

Positional-only and keyword-only arguments in Python

Watch “Positional-only and keyword-only arguments in Python” from mCoding for a visual walkthrough of both separators and the API-design motivation behind them.

Watch keyword only first. Focus on why a bare * rejects unintended extra positional values rather than silently absorbing them. Then watch positional only, paying particular attention to the complete signature layout and the argument-renaming rationale.

A bare * is preferable to *args when the function has no genuine reason to accept extra positional values:

def combine(left, right, *, validator=None):
    result = [*left, *right]

    if validator is not None:
        for item in result:
            if not validator(item):
                raise ValueError(f"Invalid item: {item!r}")

    return result

Here, this mistake produces an immediate call error:

combine(["a"], ["b"], "not a validator")

If the function used *args instead, "not a validator" could be absorbed as an unnoticed extra value. In API design, rejecting invalid shapes of input is often better than accepting them vaguely.

A practical guideline:

  • Keep short, central inputs positional-or-keyword when both forms are natural.
  • Make named configuration keyword-only.
  • Make boolean options keyword-only almost by default.
  • Prefer a keyword that includes a unit, such as timeout_s or max_retries, rather than an ambiguous timeout or limit where the unit is unclear.

Use positional-only parameters selectively

A slash marks all parameters to its left as positional-only:

def sample_at(series, index, /):
    return series[index]

Valid:

sample_at(["up", "down", "unknown"], 1)

Invalid:

sample_at(series=["up", "down", "unknown"], index=1)

The purpose is usually API design, not performance micro-optimization. Although positional calls can have small runtime advantages, that difference is rarely important compared with clarity, algorithmic choices, network latency, or I/O.

Use positional-only parameters when one or more of these statements is true:

  1. The parameter name is an implementation detail.
    A library may want freedom to rename value, item, or target later without breaking callers.

  2. Position carries the natural meaning.
    Mathematical-style operations and compact utility functions may be clearer when their main operands are positional.

  3. The API must accept arbitrary keyword data that could use the same names as its fixed parameters.

The third case is subtle and important. Suppose a function selects a field from a record:

def field(name, /, **record):
    return record[name]

This is valid:

field("name", **{"name": "chennai-edge", "status": "active"})
# 'chennai-edge'

The first "name" is bound positionally to the selector parameter. The keyword name="chennai-edge" is safely captured inside record.

Without /, the name collision would be ambiguous:

def field_without_slash(name, **record):
    return record[name]

field_without_slash("name", **{"name": "chennai-edge"})
# TypeError: multiple values for argument 'name'

Positional-only parameters make a fixed parameter name unavailable to keyword binding. Consequently, that same name can be used as a key inside **record.

Do not use / merely because it is available. A call such as this may be unnecessarily opaque:

def create_user(name, email, /):
    ...

For many business-domain APIs, name= and email= are useful and readable keywords. Positional-only is strongest for low-level utilities, stable library interfaces, and cases where parameter names are not valuable public vocabulary.

One syntax rule matters when defaults are involved. In the positional-only and positional-or-keyword portion of the signature, a non-default parameter cannot follow a default parameter:

def invalid(first="default", /, second):
    ...

That definition is invalid. This follows the familiar rule for ordinary parameters. Keyword-only parameters are different: they may be required even after earlier parameters have defaults.

def valid(first="default", /, *, required_option):
    ...

Variadic parameters: accept open-ended input deliberately

A variadic positional parameter captures remaining positional values in a tuple. The conventional name is args, but the name is not special:

def report_status(device, *messages):
    print(f"{device}:")
    for message in messages:
        print(f"  - {message}")

Calling it with several extra values gives the function a tuple:

report_status(
    "chennai-edge",
    "BGP established",
    "IPsec healthy",
    "No packet loss detected",
)

Inside the function, messages is:

(
    "BGP established",
    "IPsec healthy",
    "No packet loss detected",
)

Likewise, a variadic keyword parameter captures unmatched keyword arguments in a dictionary:

def log_event(event, /, **fields):
    print(f"{event}: {fields}")
log_event(
    "tunnel_established",
    site="chennai-edge",
    tunnel_id=101,
    peer="198.51.100.20",
)

Inside log_event, fields is:

{
    "site": "chennai-edge",
    "tunnel_id": 101,
    "peer": "198.51.100.20",
}

The conventional name kwargs is also not magical. The stars determine behavior:

def log_event(event, /, **fields):
    ...

The signature becomes more useful when these forms are combined intentionally:

def run_checks(subject, /, *checks, fail_fast=False, **context):
    results = []

    for check in checks:
        result = check(subject, **context)
        results.append(result)

        if fail_fast and not result:
            break

    return results

A call might look like this:

run_checks(
    "branch-chennai",
    check_reachability,
    check_bgp_session,
    check_ipsec_sa,
    fail_fast=True,
    vrf="production",
    timeout_s=2.0,
)

The bindings are:

Part of callBound parameter
"branch-chennai"subject
The three check functionschecks tuple
fail_fast=Truefail_fast
vrf and timeout_scontext dictionary

Notice that fail_fast is keyword-only because it follows *checks. This is unavoidable and beneficial: since *checks absorbs remaining positional arguments, anything after it must be named.

Optional Arguments in Python With *args and **kwargs

Watch “Optional Arguments in Python With *args and **kwargs” from Real Python to consolidate what is captured by each variadic parameter at runtime.

Watch argument capture. Follow the examples closely enough to distinguish the tuple created by *args from the dictionary created by **kwargs; the names are conventional, while the one and two stars create the behavior.

Variadic parameters are also paired with unpacking at the call site. In a definition, stars collect values:

def connect(host, port, /, *, timeout_s):
    ...

In a call, stars unpack values from a sequence or mapping:

endpoint = ("198.51.100.20", 443)
settings = {"timeout_s": 5.0}

connect(*endpoint, **settings)

This behaves like:

connect("198.51.100.20", 443, timeout_s=5.0)

Unpacking is useful when arguments already exist as structured data. It is not a way to bypass a signature’s contract: duplicate bindings and unexpected keys still raise TypeError.

The most common design error with variadic parameters is treating them as a default convenience:

def process(*args, **kwargs):
    ...

This says almost nothing about the real API. It weakens editor assistance, makes callers guess which values are valid, and can silently accept misspelled keyword arguments. Prefer an explicit signature unless the open-ended nature is genuinely part of the domain contract, such as:

  • a logger accepting structured event fields;
  • a formatter accepting any number of fragments;
  • a wrapper that must forward calls to another callable;
  • an extension or plugin boundary with validated extra options.

When **kwargs is necessary, consider validating its keys rather than allowing every spelling:

def configure_probe(target, /, **options):
    allowed = {"timeout_s", "retries", "source_interface"}
    unknown = options.keys() - allowed

    if unknown:
        raise TypeError(f"Unsupported options: {sorted(unknown)}")

    return {"target": target, **options}

In ordinary application code, explicit keyword-only parameters are usually better than this pattern. They provide automatic validation and document the supported options in the signature itself.


Designing an API from the call site backward

When designing a signature, first write the call you want other engineers to read:

probe = create_probe(
    "198.51.100.20",
    443,
    protocol="tcp",
    timeout_s=3.0,
    retries=2,
    source_interface="wan0",
)

Then make the function declaration match that communication goal:

def create_probe(
    target,
    port,
    /,
    *,
    protocol="tcp",
    timeout_s=3.0,
    retries=2,
    source_interface=None,
):
    return {
        "target": target,
        "port": port,
        "protocol": protocol,
        "timeout_s": timeout_s,
        "retries": retries,
        "source_interface": source_interface,
    }

This design makes a deliberate tradeoff:

  • target and port are the compact core operands and must be given in their documented order.
  • Connection behavior is named configuration.
  • Adding a future option such as verify_tls=True will not disrupt valid positional calls.
  • The names target and port are not promised as keyword API names.

That last point is a policy choice, not a universal rule. If create_probe(target=..., port=...) would be clearer and valuable to callers, leave the slash out. Signature design is about preserving the right information at the call site.

Use this review checklist before publishing a shared function:

  1. Which values are the essential operands?
    Keep them positional-or-keyword by default; use positional-only only for a clear reason.

  2. Which values configure behavior?
    Make named settings keyword-only, especially booleans, timeouts, modes, limits, and values with units.

  3. Does the API truly accept an arbitrary number of values?
    If yes, use *items and give the collection a domain-specific name.

  4. Does the API truly accept arbitrary named fields?
    If yes, use **options or **fields, and validate or document accepted keys.

  5. Will the signature remain understandable six months later?
    Prefer a precise contract over maximum flexibility.


Key takeaways

Function signatures are part of API design. They define the legal shapes of calls, communicate meaning, and prevent mistakes before function body logic runs.

  • Put parameters before / to make them positional-only.
  • Put parameters after a bare * or a variadic *args parameter to make them keyword-only.
  • Use *args to collect additional positional values in a tuple.
  • Use **kwargs to collect unmatched keywords in a dictionary, but do so deliberately because it weakens automatic validation.
  • Favor keyword-only parameters for meaningful configuration and positional-only parameters when names should not become public API commitments or when they would collide with arbitrary keyword data.

Next, you will look beneath function calls at Python’s name-resolution model: how local, enclosing, global, and built-in scopes determine which binding a name refers to.

Can't find a good explanation? Sign up and we'll make it for you

Sign up