Use default and keyword arguments¶
The question. You're writing a function with several parameters and you want the signature to say "here are the things you must pass, here are the ones you can tweak, and here is what the caller almost always wants" without descending into a wall of None checks.
Three tools do most of the work: sensible defaults on the right, None as the default for mutable types (never the mutable value itself), and the bare * separator to force optional arguments to be passed by keyword.
def send_email(
to: str,
subject: str,
body: str,
*,
cc: str | None = None,
bcc: str | None = None,
priority: str = 'normal',
html: bool = False,
attachments: list[str] | None = None,
) -> str:
# Rebuild any mutable argument inside the function so the
# default isn't shared across calls.
attachments = list(attachments) if attachments else []
lines = [f'To: {to}', f'Subject: {subject}']
if cc:
lines.append(f'CC: {cc}')
if bcc:
lines.append(f'BCC: {bcc}')
lines.append(f'Priority: {priority}')
lines.append(f"Format: {'HTML' if html else 'plain text'}")
if attachments:
lines.append(f"Attachments: {', '.join(attachments)}")
lines.append(f'Body: {body}')
return '\n'.join(lines)
# Simple call: everything after the first three arguments stays default.
print(send_email('bob@example.com', 'Hello', 'Just checking in.'))
print()
# Complex call: only the options you care about.
print(send_email(
'bob@example.com',
'Quarterly report',
'Please find the report attached.',
cc='manager@example.com',
priority='high',
attachments=['report.pdf', 'data.xlsx'],
))
Why it works¶
Defaults are evaluated once, at function-definition time. That's why def f(items=[]) is a bug waiting to happen: every call shares the same list, and a call that appends to it changes the default for the next caller. The fix is always the same — use None in the signature, then allocate a fresh list or dict inside the function body.
The bare * in the parameter list is the keyword-only barrier. Everything before it can be passed positionally or by name; everything after it has to be passed by name. That does two things: it stops callers from accidentally writing send_email('bob', 'hi', 'body', 'manager@example.com') and silently landing the manager's address as the body; and it makes the call site self-documenting — priority='high' explains itself, 'high' doesn't.
Under the hood both the * and its cousin / (positional-only, Python 3.8+) are just ways of carving up the argument list. Put the required positionals first, optional positionals next, then *, then every named option. The call site reads in the same order.
Trade-offs and variations¶
- Positional-only parameters (
/) are the mirror image of*.def search(query, /, *, max_results=10)locksquery's name in the function's private namespace so you can rename it in a refactor without breaking any callers. Useful in library APIs; overkill for internal functions. - Too many optional parameters is a smell. If
send_emailgrew to fifteen keyword arguments, consider a dataclass or**kwargsinstead. A configuration object you can inspect and pass around beats a ten-line call site. - Sentinel objects beat
NonewhenNoneis meaningful. If your function treatsNoneas a valid value (e.g. "clear this field"), create_MISSING = object()at module scope and use it as the default, soarg is _MISSINGcan distinguish "not given" from "given asNone". - Mutable defaults can be legitimate in the rare case where you actually want shared state across calls — e.g. a cache or counter. In that case, use a closure or a class attribute, not a default argument. The default-argument version works, but it's surprising enough that the reader will think you made the bug instead of the choice.
Related¶
- Learn — Defining functions for parameters, defaults, and the
*args/**kwargsfundamentals. - Learn —
*argsand**kwargsfor the variadic side of the same coin. - Reference — Function syntax for the
/and*separator rules. - Concepts — The Zen of functions for the design bias behind keyword-only arguments.