PY-01-011-P · Primer — Functions & Arguments
The idea in one sentence
A function is a named, reusable block of code you define once with def and run whenever you need it, and arguments are the values you hand it each time you call it — passed either by position, by name, with sensible defaults, or scooped up in bulk.
Why it exists
The moment you write the same three lines twice, you have a problem: two copies to keep in sync, two places for a bug to hide, two chances to fix one and forget the other. Functions solve this by letting you name a chunk of logic once — def greet(...) — and then call it by that name as many times as you like, so the work lives in exactly one place. But a reusable block is only useful if you can feed it different inputs each time (greet Ada, then greet Bo), and that is what arguments are for: they are the slots you leave open when you define the function and fill in when you call it. Python gives you several ways to fill those slots — by their order, by their name, by leaning on a default, or by accepting "however many you throw at me" — because real code needs all of them: quick calls with a couple of positional values, self-documenting calls with named values, optional settings that usually stay the same, and flexible functions like print() that happily take one argument or twenty.
How it works
You define a function with def name(parameters):, put its body underneath (indented), and use return to hand a result back to whoever called it. The names in the parentheses are parameters; the actual values you pass in a call are arguments. You can pass arguments positionally (matched to parameters left-to-right) or as keyword arguments (matched by name, so order stops mattering). A parameter can have a default value with param=value, making it optional — callers who skip it get the default. And a *args parameter collects extra positional arguments into a tuple while **kwargs collects extra keyword arguments into a dict, so one function can accept any number of inputs:
def greet(name, greeting="Hello"): # greeting has a default
return f"{greeting}, {name}!"
print(greet("Ada")) # Hello, Ada! (positional; default used)
print(greet("Ada", "Hi")) # Hi, Ada! (both positional)
print(greet(greeting="Hey", name="Ada")) # Hey, Ada! (keyword; order flipped)
def total(*prices, tax=0.0, **labels): # *args and **kwargs
subtotal = sum(prices) # prices is a tuple: (10, 20, 30)
return subtotal + subtotal * tax, labels
print(total(10, 20, 30, tax=0.1, note="lunch")) # (66.0, {'note': 'lunch'})
Notice how greet(greeting="Hey", name="Ada") reads clearly even with the arguments out of order — that is the payoff of keyword arguments.
The one gotcha
Never use a mutable object (a list, dict, or set) as a default value. A default is evaluated once, when the function is defined — not fresh on every call — so a default list is created a single time and then quietly shared by every call that relies on it. The result looks haunted: values you added on one call show up on the next.
def add_item(item, cart=[]): # DANGER: this list is created ONCE
cart.append(item)
return cart
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['apple', 'banana'] <- the apple is still here!
The fix is a five-line habit you'll use forever: default to None, then build the real object inside the function so each call gets its own.
def add_item(item, cart=None):
if cart is None:
cart = [] # fresh list every call
cart.append(item)
return cart
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['banana'] <- correct: independent carts
Immutable defaults (None, numbers, strings, True/False) are always safe — the trap is only mutable ones.
See it — how Python fills a function's parameters at call time
flowchart TD
A["You call the function,<br/>e.g. greet('Ada')"] --> B{"Argument passed<br/>by name?"}
B -->|"Yes (name=value)"| C["Match it to the<br/>parameter of that name"]
B -->|"No"| D["Match it by position,<br/>left to right"]
D --> E{"More positional args<br/>than parameters?"}
E -->|"Yes"| F["Extras collected<br/>into *args tuple"]
E -->|"No"| G["Parameter is filled"]
C --> H{"Extra keyword args<br/>with no parameter?"}
H -->|"Yes"| I["Extras collected<br/>into **kwargs dict"]
H -->|"No"| G
G --> J{"A parameter got<br/>no argument?"}
J -->|"Has a default"| K["Use the default value"]
J -->|"No default"| L["TypeError: missing<br/>required argument"]
K --> M["Run the body,<br/>return a result"]
F --> M
I --> M
The one-line takeaway
def names reusable logic; arguments fill its parameters by position or by name, defaults make parameters optional, and *args/**kwargs accept any number of them — just never default a parameter to a list or dict.
Where you'll meet it
You already meet this every time you call print("hi", end="") — that end= is a keyword argument overriding a default, and print accepts any number of values because it uses *args under the hood. You'll write your own functions within your first hour of real Python, and the keyword-argument style becomes essential the moment a function has more than two or three settings: connect(host="localhost", port=5432, timeout=30) is readable in a way that connect("localhost", 5432, 30) never is. Every library you touch is built from these pieces — Flask routes, pandas read_csv with its dozens of optional keyword arguments, requests.get(url, params=..., headers=...) — so reading a function's signature and knowing which arguments are required, which are optional, and which get swept into **kwargs is a skill you'll use on literally every project. And the mutable-default gotcha is a genuine rite of passage: catching it (or spotting it in a code review) is one of the clearest signs you've stopped just using functions and started truly understanding them.
Named concepts: def, parameters, and return, positional vs keyword arguments, default values, *args and **kwargs
