Keyword-Only and Positional-Only Parameters
Reading time: ~13 minutes | Level: Foundation → Engineering
Have you ever called a Python function and been confused about which argument goes where?
# Which argument is which? You have to check the docs.
sorted(my_list, None, True) # What does True mean here?
# This is much clearer - Python enforces it:
sorted(my_list, key=None, reverse=True) # Unambiguous
Python 3 introduced keyword-only parameters (enforced with *) and Python 3.8 added positional-only parameters (enforced with /). Together they let you design function signatures that communicate intent clearly and protect your API from breaking changes.
If you have ever looked at the signature of Python's built-in open(), sorted(), or range() and wondered what those * symbols mean - this is the page.
What You Will Learn
- What keyword-only parameters are and how
*enforces them - What positional-only parameters are and how
/enforces them (Python 3.8+) - How to combine positional, positional-only, regular, keyword-only, and variadic in one signature
- The exact
TypeErrormessages these rules produce - How Python's built-in functions use these conventions
- Why keyword-only parameters prevent entire categories of bugs
- How positional-only parameters protect API stability
- When to use each in your own function designs
Prerequisites
- Python function syntax:
def, parameters, default values - How to call functions with positional and keyword arguments
- Basic understanding of
*argsand**kwargs(covered next in Topic 05)
Mental Model: The Signature Separator Rules
FULL PARAMETER SIGNATURE MAP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def func(pos_only, /, normal, *, kw_only):
───────── ────── ──────────────
│ │ │
│ │ └── After * : KEYWORD-ONLY
│ │ Must be called as func(kw_only=value)
│ │
│ └── Between / and * : REGULAR
│ Can be passed positionally OR as keyword
│
└── Before / : POSITIONAL-ONLY
Must be called as func(value)
Cannot use func(pos_only=value) - TypeError
RULE SUMMARY:
/ marks end of positional-only zone
* marks start of keyword-only zone
Between / and * : flexible (positional or keyword)
*args works the same as bare * for establishing keyword-only zone
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Watch: Python Function Parameters
Part 1 - Keyword-Only Parameters with *
A keyword-only parameter can only be passed by name, never by position. You create them by placing a bare * in the parameter list - everything after * must be passed as a keyword argument.
def create_user(name, email, *, admin=False, verified=False):
# ^
# bare * separates positional from keyword-only
return {
"name": name,
"email": email,
"admin": admin,
"verified": verified,
}
# Valid calls:
# Invalid - TypeError:
# TypeError: create_user() takes 2 positional arguments but 3 were given
The TypeError message is the enforcement mechanism. Python refuses to let you pass admin or verified positionally.
Keyword-only without defaults (required keyword arguments)
Keyword-only parameters can also be required (no default):
def send_email(subject, body, *, to, from_addr):
# ^^ required keyword-only (no defaults)
print(f"From: {from_addr}")
print(f"To: {to}")
print(f"Subject: {subject}")
print(body)
# Must pass to and from_addr explicitly:
# Missing required keyword-only → TypeError:
# TypeError: send_email() missing 1 required keyword-only argument: 'from_addr'
Part 2 - Why Keyword-Only Parameters Matter
Clarity at the call site
# WITHOUT keyword-only: what does True mean?
resize_image("photo.jpg", 800, 600, True, False, True)
# WITH keyword-only: unambiguous
resize_image("photo.jpg", 800, 600,
maintain_aspect=True,
overwrite=False,
strip_metadata=True)
Preventing boolean flag confusion
Boolean arguments are one of the most common API smells. Keyword-only enforcement forces callers to be explicit:
def deploy(app, environment, *, dry_run=False, verbose=False, force=False):
"""Deploy an application. All flags must be explicit."""
if dry_run:
print(f"[DRY RUN] Would deploy {app} to {environment}")
return
# ... actual deployment
No one can accidentally call deploy("myapp", "prod", True) and silently enable dry_run.
In Python's standard library
import inspect
# sorted() uses keyword-only for key and reverse
print(inspect.signature(sorted))
# (iterable, /, *, key=None, reverse=False)
# print() uses keyword-only for sep, end, file, flush
print(inspect.signature(print))
# (*args, sep=' ', end='\n', file=None, flush=False)
Part 3 - Positional-Only Parameters with / (Python 3.8+)
A positional-only parameter can only be passed by position - passing it as a keyword raises a TypeError. You create them by placing / in the parameter list - everything before / is positional-only.
def calculate(x, y, /):
# ^
# / marks end of positional-only zone
return x + y
# Valid:
calculate(3, 4) # 7
# Invalid - TypeError:
calculate(x=3, y=4)
# TypeError: calculate() got some positional-only arguments passed as keyword arguments: 'x, y'
Why positional-only parameters exist
API stability: If a parameter is positional-only, you can rename it in future versions without breaking callers.
# Original API - x and y are positional-only
def transform(x, y, /, scale=1.0):
return x * scale, y * scale
# Future version - renamed to px, py internally
def transform(px, py, /, scale=1.0):
return px * scale, py * scale
# Callers who wrote transform(10, 20) still work fine
# Nobody could have written transform(x=10, y=20) - it was never allowed
Performance: The CPython interpreter can use faster lookup for positional-only parameters since they cannot appear in **kwargs.
Built-in functions are positional-only
Many built-in functions use positional-only parameters for historical reasons (their C implementations don't have named parameters exposed to Python):
import inspect
print(inspect.signature(len))
# (obj, /)
print(inspect.signature(abs))
# (x, /)
print(inspect.signature(int))
# (x=0, /) or (x=0, base=10, /)
Part 4 - Combining All Parameter Kinds
Python allows all four kinds in one signature, in a strict order:
def f(pos_only_a, pos_only_b, /, regular_a, regular_b=10, *args, kw_only_a, kw_only_b=99, **kwargs):
The order rules:
- Positional-only parameters (before
/) - Regular parameters (between
/and*) *argsor bare*- Keyword-only parameters (after
*) **kwargs
def advanced(a, b, /, c, d=10, *, e, f=99):
print(f"a={a}, b={b}, c={c}, d={d}, e={e}, f={f}")
# Valid:
advanced(1, 2, 3, e=5) # a=1, b=2, c=3, d=10, e=5, f=99
advanced(1, 2, 3, 4, e=5, f=6) # a=1, b=2, c=3, d=4, e=5, f=6
advanced(1, 2, c=3, e=5) # a=1, b=2, c=3 (c is regular, can be keyword)
# Invalid:
advanced(a=1, b=2, c=3, e=5) # TypeError: a and b are positional-only
advanced(1, 2, 3) # TypeError: e is required keyword-only
Real-world example: a training function
def train_model(
model, # positional-only (implementation detail)
dataset, # positional-only
/,
epochs=10, # regular (flexible)
batch_size=32, # regular (flexible)
*,
learning_rate, # keyword-only, required - important, must be explicit
optimizer="adam", # keyword-only - algorithmic choice, must be named
verbose=True, # keyword-only - behavioral flag
):
print(f"Training for {epochs} epochs, lr={learning_rate}")
The caller must name learning_rate, making accidental misuse of the API impossible.
Part 5 - Error Messages and Enforcement
Understanding what errors these rules produce helps you diagnose issues quickly:
def strict_func(pos_only, /, regular, *, kw_only):
pass
# Passing positional-only as keyword:
strict_func(pos_only=1, regular=2, kw_only=3)
# TypeError: strict_func() got some positional-only arguments passed
# as keyword arguments: 'pos_only'
# Passing keyword-only positionally:
strict_func(1, 2, 3)
# TypeError: strict_func() takes 2 positional arguments but 3 were given
# Missing required keyword-only:
strict_func(1, 2)
# TypeError: strict_func() missing 1 required keyword-only argument: 'kw_only'
# Too many positional:
strict_func(1, 2, 3, kw_only=4)
# TypeError: strict_func() takes 2 positional arguments but 3 were given
AI/ML Real-World Connection
Keyword-only parameters are used extensively in PyTorch and scikit-learn to make model APIs explicit and safe.
# PyTorch: torch.nn.functional uses keyword-only for critical parameters
import torch
import torch.nn.functional as F
# Dropout requires p= to be explicit
output = F.dropout(input, p=0.5, training=True, inplace=False)
# Without keyword-only enforcement, you could accidentally write:
# output = F.dropout(input, 0.5, True, False)
# - which arguments are which? Keyword-only forces clarity.
# scikit-learn style: explicit keyword-only for model parameters
from sklearn.ensemble import RandomForestClassifier
# These keyword-only-style arguments must be named:
clf = RandomForestClassifier(
n_estimators=100,
max_depth=5,
random_state=42,
n_jobs=-1,
)
# You would never write RandomForestClassifier(100, 5, None, 42, ...)
# The keyword-only convention makes it self-documenting.
# Design your own ML utility with keyword-only:
def evaluate_model(model, X_test, y_test, /, *, metrics, verbose=True, threshold=0.5):
"""
Evaluate model performance.
model, X_test, y_test are positional-only (implementation details).
metrics must be named - prevents accidentally passing wrong list.
"""
results = {}
predictions = model.predict(X_test)
for metric_name in metrics:
if metric_name == "accuracy":
from sklearn.metrics import accuracy_score
results["accuracy"] = accuracy_score(y_test, predictions)
if verbose:
for name, value in results.items():
print(f"{name}: {value:.4f}")
return results
# Caller must be explicit about metrics:
evaluate_model(clf, X_test, y_test, metrics=["accuracy"])
Common Mistakes
Mistake 1: Forgetting * makes subsequent parameters keyword-only
def func(a, b, *, c, d):
pass
# Common mistake - thinking c and d can be positional:
func(1, 2, 3, 4)
# TypeError: func() takes 2 positional arguments but 4 were given
# Correct:
func(1, 2, c=3, d=4)
Mistake 2: Putting / in the wrong position
# Syntax error - / must come before *
def bad(a, *, b, /, c): # SyntaxError: invalid syntax
pass
# Correct order: positional-only / regular * keyword-only
def good(a, /, b, *, c):
pass
Mistake 3: Missing the difference between bare * and *args
# Bare * : marks keyword-only boundary, collects nothing
def f(a, *, b):
print(a, b) # no *args collected
f(1, b=2) # works
f(1, 2, b=3) # TypeError - extra positional not collected
# *args : collects extra positionals AND marks keyword-only boundary
def g(a, *args, b):
print(a, args, b)
g(1, 2, 3, b=4) # a=1, args=(2, 3), b=4
Interview Questions
Q1: What does placing * (bare asterisk) in a function signature do?
Answer: A bare * acts as a separator - all parameters after it must be passed as keyword arguments. It does not collect any arguments into a tuple (unlike *args). Example: def f(a, *, b) - b must be passed as f(1, b=2), not f(1, 2).
Q2: What does / in a function signature do, and when was it added?
Answer: / was added in Python 3.8. Parameters before / are positional-only - they cannot be passed as keyword arguments. This allows renaming internal parameter names without breaking callers, and matches how many C extension functions work. Example: len(obj) not len(obj=mylist).
Q3: Why are keyword-only parameters useful for API design?
Answer: They eliminate ambiguity at call sites, especially for boolean flags and optional configuration. They force callers to be explicit about which argument is which, making code more readable and resistant to argument-order mistakes. They also allow adding new keyword-only parameters later without breaking existing call signatures.
Q4: What is the correct parameter order in a Python function signature?
Answer: The order must be: positional-only (before /), regular (between / and *), *args or bare *, keyword-only (after *), **kwargs. Violating this order raises a SyntaxError.
Q5: How are Python's built-in functions like sorted() and len() defined with these separators?
Answer: sorted(iterable, /, *, key=None, reverse=False) - iterable is positional-only and key/reverse are keyword-only. len(obj, /) - obj is positional-only. You can inspect this with inspect.signature(sorted).
Quick Reference Cheatsheet
| Syntax | What it means | Example |
|---|---|---|
def f(a, b) | Both regular (flexible) | f(1, 2) or f(a=1, b=2) |
def f(a, /, b) | a positional-only | f(1, 2) only; f(a=1, b=2) → TypeError |
def f(a, *, b) | b keyword-only | f(1, b=2) only; f(1, 2) → TypeError |
def f(a, /, b, *, c) | a pos-only, c kw-only | f(1, 2, c=3) |
def f(a, *args, b) | b keyword-only, args collects extras | f(1, 2, 3, b=4) |
inspect.signature(f) | Inspect full signature | Shows / and * separators |
Graded Practice Challenges
Level 1 - True or False
True or false: In def f(a, *, b=10), calling f(1, 2) is valid.
Show Answer
False.
The * makes b keyword-only. f(1, 2) tries to pass 2 as a positional argument after the *, which is not allowed. The correct call is f(1) (using the default b=10) or f(1, b=2).
Level 2 - Debug the Code
def send_notification(user_id, message, urgent=False, channel="email"):
pass
# A new developer joins and calls:
send_notification(42, "Server down!", True, "sms")
What is the problem and how would you redesign the signature to prevent it?
Show Answer
Problem: urgent=True and channel="sms" are passed positionally - the call is valid Python but True and "sms" are invisible at the call site. A reader has to check the signature to understand what True and "sms" mean. This is an API clarity problem.
Better design:
def send_notification(user_id, message, /, *, urgent=False, channel="email"):
pass
# Now the only valid calls are:
send_notification(42, "Server down!", urgent=True, channel="sms")
# The flags are always explicit and self-documenting
user_id and message become positional-only (they are unambiguous), and urgent/channel become keyword-only (they are optional flags that should be explicit).
Level 3 - Design Challenge
Design a log_metric function for a machine learning experiment tracker with these requirements:
nameandvaluemust be positional (they are always the first two things you pass)stepis a regular parameter with a default ofNonetags,run_id, andtimestampmust always be passed as keyword argumentstagsshould default to an empty list (use None sentinel)
Show Reference Solution
from datetime import datetime
from typing import Optional
def log_metric(
name: str,
value: float,
step: Optional[int] = None,
*,
run_id: str, # keyword-only, required
tags: Optional[list] = None, # keyword-only with None sentinel
timestamp: Optional[datetime] = None, # keyword-only with None sentinel
) -> None:
"""
Log a metric value for a training run.
Args:
name: Metric name (e.g., "loss", "accuracy")
value: Metric value
step: Training step (epoch or batch number)
run_id: Experiment run identifier (must be explicit)
tags: Optional list of tags to group metrics
timestamp: Optional timestamp; defaults to current time
"""
if tags is None:
tags = []
if timestamp is None:
timestamp = datetime.now()
record = {
"name": name,
"value": value,
"step": step,
"run_id": run_id,
"tags": tags,
"timestamp": timestamp.isoformat(),
}
print(f"Logging metric: {record}")
# Usage - run_id must always be explicit:
log_metric("loss", 0.42, 100, run_id="exp-001")
log_metric("accuracy", 0.95, 100, run_id="exp-001", tags=["validation"])
# This is now impossible (enforced by keyword-only):
# log_metric("loss", 0.42, 100, "exp-001") → TypeError
Key Takeaways
- A bare
*in a signature makes all following parameters keyword-only - they must be named at the call site - A
/in a signature (Python 3.8+) makes all preceding parameters positional-only - they cannot be named at the call site - Keyword-only parameters prevent argument-order bugs with boolean flags and optional configuration
- Positional-only parameters protect API stability - internal parameter names can change without breaking callers
- Python's built-ins (
sorted,len,print) use these conventions extensively - The full parameter order is: positional-only
/regular*keyword-only**kwargs *argsalso marks the keyword-only boundary while collecting variadic positionals- Use
inspect.signature(func)to see the full parameter specification of any function
