Section · 01
Positional vs keyword arguments — pick one per call
Same function, called four different ways:
def make_url(host, path, port=443, secure=True):
scheme = "https" if secure else "http"
return f"{scheme}://{host}:{port}{path}"
make_url("yorksims.com", "/learn") # all positional, defaults used
make_url("yorksims.com", "/learn", 8080, False) # all positional, no defaults
make_url("yorksims.com", "/learn", port=8080) # mix
make_url(host="yorksims.com", path="/learn", secure=False) # all keywordThe rules:
1. Positional arguments come first, in the order defined.
2. Keyword arguments come after positionals, in any order.
3. You can't pass the same argument both positionally and by keyword.
4. Required arguments (no default) must always be supplied.Once a function has more than two or three parameters, keyword arguments at the call site make the code self-documenting. Compare:
# What do these mean in 6 months?
send_email("ada@yorksims.com", "Welcome", body, True, False, None)
# Versus:
send_email(
to="ada@yorksims.com",
subject="Welcome",
body=body,
track_opens=True,
track_clicks=False,
reply_to=None,
)Section · 02
Default values — and the mutable default trap
Defaults make arguments optional:
def fetch(url, timeout=30, retries=3):
...
fetch("https://...") # timeout=30, retries=3
fetch("https://...", timeout=60) # retries=3
fetch("https://...", retries=5) # timeout=30The trap, repeated from Unit 1 because it matters that much: never use a mutable default like [] or {}:
# WRONG — the list is created ONCE when the function is defined
def add_to(item, items=[]):
items.append(item)
return items
add_to("a") # ["a"]
add_to("b") # ["a", "b"] — the SAME list across calls!
# RIGHT — use None as the sentinel and create a fresh list inside
def add_to(item, items=None):
if items is None:
items = []
items.append(item)
return itemsSame rule for dict defaults: use None and initialize inside the function.
Section · 03
*args — accept any number of positional arguments
Sometimes a function should accept “some unknown number of things.” *args collects extra positional arguments into a tuple:
def sum_all(*numbers):
total = 0
for n in numbers:
total = total + n
return total
sum_all(1, 2, 3) # 6
sum_all(1, 2, 3, 4, 5) # 15
sum_all() # 0The name args is just convention; the asterisk does the work. You can mix it with regular parameters:
def log(level, *messages):
prefix = f"[{level}]"
for m in messages:
print(f"{prefix} {m}")
log("INFO", "starting up", "config loaded", "ready")The opposite move — passing a list of values into a function that expects separate arguments — uses the same star to unpack:
numbers = [1, 2, 3, 4, 5]
sum_all(*numbers) # equivalent to sum_all(1, 2, 3, 4, 5)Section · 04
**kwargs — accept any number of keyword arguments
Double-star collects extra keyword arguments into a dictionary. Useful for wrapping or forwarding configuration:
def make_request(url, **options):
print(f"URL: {url}")
for key, value in options.items():
print(f" {key} = {value}")
make_request("https://...", timeout=30, retries=3, verify=False)
# URL: https://...
# timeout = 30
# retries = 3
# verify = FalseThe unpacking version with a double-star takes a dict and turns it into keyword arguments at the call site:
config = {"timeout": 30, "retries": 3, "verify": False}
make_request("https://...", **config)
# same as make_request("https://...", timeout=30, retries=3, verify=False)**kwargsis what makes wrapper functions and decorators possible — you can accept any keyword arguments and pass them straight through to whatever you’re wrapping, even if you don’t know what they are.
Section · 05
Returning multiple values
A function with commas in its return actually returns a tuple. You can unpack at the call site:
def stats(numbers):
total = sum(numbers)
avg = total / len(numbers)
return total, avg, len(numbers)
t, a, n = stats([10, 20, 30])
print(f"total={t}, average={a}, count={n}")When you return three or more values, consider returning a dict or a small dataclass instead — they self-document, and the caller doesn’t have to remember the order:
def stats(numbers):
return {
"total": sum(numbers),
"average": sum(numbers) / len(numbers),
"count": len(numbers),
}
result = stats([10, 20, 30])
print(result["average"])Section · 06
Signature design rules
Three rules that’ll make your functions a pleasure to call:
1. Required things first, optional things last.
Python forces this anyway, but it’s also the order people read in. Keyword arguments come after positionals.
2. Fewer parameters is better.
If your function takes 7 arguments, it’s probably doing too much. Either split it into smaller functions, or group related arguments into a dict or a small class.
3. Force keyword-only for boolean flags.
A bare True or False at a call site is meaningless to a reader. Put a * in the parameter list to force keyword arguments for everything after it:
def delete_user(user_id, *, hard=False, audit=True):
...
# Allowed:
delete_user(42)
delete_user(42, hard=True)
delete_user(42, hard=True, audit=False)
# NOT allowed — Python raises TypeError:
delete_user(42, True) # what does True mean here??This is the kind of small discipline that compounds. Three months in, a function someone else (or future-you) wrote with keyword-only booleans saves a trip to the docs every time you call it.