Functions
Defining a function
Section titled “Defining a function”Use def followed by the function name, a parameter list in parentheses, and a colon.
The body is indented 4 spaces.
Type hints are optional but strongly recommended — they document intent and enable static analysis.
def greet(name: str, greeting: str = "Hello") -> str: """Return a greeting string for the given name.""" return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice!print(greet("Bob", greeting="Hi")) # Hi, Bob!The second parameter has a default value — callers may omit it.
Default values are evaluated once, when the def statement runs — not on every call. That single fact is behind two of the most common bugs in AI-generated Python, covered below.
Parameters and calling conventions
Section titled “Parameters and calling conventions”Python has four kinds of parameters:
def demo(pos, /, normal, *, kw_only): print(pos, normal, kw_only)
demo(1, 2, kw_only=3) # OKdemo(1, normal=2, kw_only=3) # OK- Positional-only (before
/): cannot be passed by keyword. - Normal: positional or keyword.
- Keyword-only (after
*): must be passed by keyword.
Positional-only parameters exist so a library can rename an internal parameter later without breaking any caller — nobody could have been passing it by that name, because the language forbids it:
def power(base, /, exponent=2): return base ** exponent
print(power(3)) # 9try: power(base=3) # base is positional-only -- keyword form rejectedexcept TypeError as e: print(f"power(base=3) -> TypeError: {e}")*args, **kwargs, and unpacking at the call site
Section titled “*args, **kwargs, and unpacking at the call site”A parameter prefixed with * collects any extra positional arguments into a tuple; one prefixed with ** collects extra keyword arguments into a new dict. The same */** syntax works in reverse at a call site, spreading a sequence or mapping back out into separate arguments:
def total(*args, **kwargs): print("args:", args, "kwargs:", kwargs) return sum(args) + sum(kwargs.values())
numbers = [1, 2, 3]extra = {"bonus": 10}print(total(*numbers, **extra))# args: (1, 2, 3) kwargs: {'bonus': 10}# 16This pattern — def wrapper(*args, **kwargs): return inner(*args, **kwargs) — is exactly how a generic wrapper or decorator forwards whatever it was called with, without knowing the wrapped function’s real signature.
Mutable default arguments
Section titled “Mutable default arguments”A default value is evaluated once and stored on the function object itself — so a mutable default (a list, dict, or set literal) is the same object, shared and potentially mutated, across every call that doesn’t override it:
def add_item(item, items=[]): # BUG: the same list object is reused across calls items.append(item) return items
print(add_item("apple")) # ['apple']print(add_item("banana")) # ['apple', 'banana'] -- "apple" leaked in from the last callNothing about the second call mentioned "apple" — the bug is invisible until a second call happens to run, which is exactly why it survives code review and a quick test. The fix is the None sentinel pattern: default to None, then create a fresh mutable object inside the function body when nothing was passed:
def add_item_fixed(item, items=None): if items is None: # a brand-new list every call that doesn't supply one items = [] items.append(item) return items
print(add_item_fixed("apple")) # ['apple']print(add_item_fixed("banana")) # ['banana']The same “evaluated once” rule bites any default meant to be “the current time” or “a fresh object” — def log_event(message, at=datetime.now()): freezes at at the moment the module is imported, not at call time. Use the same None-sentinel fix: at=None, then at = at if at is not None else datetime.now() inside the body.
Returning multiple values
Section titled “Returning multiple values”A function can return any number of values by separating them with commas. Python packs them into a tuple automatically.
def min_max(numbers: list) -> tuple: """Return (minimum, maximum) of a list.""" return min(numbers), max(numbers)
low, high = min_max([3, 1, 4, 1, 5, 9])print(f"min={low}, max={high}") # min=1, max=9Tuple unpacking on the left side of the assignment distributes the returned values into separate variables.
Docstrings
Section titled “Docstrings”A docstring is a string literal placed immediately after the def line.
It documents what the function does, its parameters, and what it returns.
Access it via help(fn) or fn.__doc__.
def add(a: int, b: int) -> int: """Return the sum of a and b.
Args: a: First operand. b: Second operand.
Returns: The integer sum. """ return a + bLEGB scope rules
Section titled “LEGB scope rules”Python resolves names in four scopes, searched in order:
- Local — inside the current function.
- Enclosing — any enclosing function (for nested functions).
- Global — the module level.
- Built-in — Python’s built-in names (
len,print, etc.).
x = 10 # global
def outer(): y = 20 # enclosing def inner(): z = 30 # local return x + y + z # finds x globally, y in enclosing, z locally return inner()
print(outer()) # 60Reading a name only needs LEGB lookup to find it. Assigning to a name inside a function is different: Python decides, at compile time, that any name assigned anywhere in a function body is local to that function — for the entire function, even on lines before the assignment. That is what makes count += 1 dangerous:
counter = 0
def increment_buggy(): counter += 1 # BUG: this assignment makes counter LOCAL for the whole function return counter
increment_buggy() # UnboundLocalError: cannot access local variable 'counter' # where it is not associated with a valuecounter += 1 is really counter = counter + 1 — a read and an assignment. Because counter is assigned somewhere in this function, Python treats it as local everywhere in the function, including the read on the right-hand side, which now happens before the local counter has ever been given a value. Use global name to tell Python explicitly that a reassignment should rebind the module-level variable; use nonlocal name for the same thing one level up, in an enclosing (non-global) function:
def increment_fixed(): global counter counter += 1 return counter
print(increment_fixed()) # 1print(increment_fixed()) # 2callable
Section titled “callable”callable(obj) tells you whether obj can be called with () — true for functions, classes, and any object whose class defines __call__, false for plain data like an int or a str:
print(callable(len)) # Trueprint(callable(42)) # Falseprint(callable(power)) # True -- functions are callable objects tooThis is a quick way to check “is this actually a function I can invoke” before calling something that arrived as a parameter — useful when reading code that accepts a callback.
Full runnable demo
Section titled “Full runnable demo”def greet(name: str, greeting: str = "Hello") -> str: """Return a greeting string.""" return f"{greeting}, {name}!"
def min_max(numbers: list) -> tuple: """Return (minimum, maximum) of a list.""" return min(numbers), max(numbers)
x = 10
def outer(): y = 20 def inner(): z = 30 return x + y + z return inner()
print(greet("Alice"))print(greet("Bob", greeting="Hi"))
low, high = min_max([3, 1, 4, 1, 5, 9])print(f"min={low}, max={high}")
print(f"LEGB sum: {outer()}")
def no_return(): pass
result = no_return()print(f"no_return() gives: {result}")Loading Python runtime (first run only)…
The second playground is a tour of every pitfall above, all shown failing on purpose and then fixed.
from datetime import datetimeimport time
# 1. Mutable default argument: evaluated ONCE, at def time, then shared/reused.def add_item(item, items=[]): # BUG: the same list object is reused across calls items.append(item) return items
print("call 1:", add_item("apple"))print("call 2:", add_item("banana")) # apple is still there -- shared default!
def add_item_fixed(item, items=None): if items is None: # None sentinel: a fresh list every call items = [] items.append(item) return items
print("fixed call 1:", add_item_fixed("apple"))print("fixed call 2:", add_item_fixed("banana"))
# 2. A default value that looks dynamic but is frozen at def time.def log_event(message, at=datetime.now()): # BUG: at is fixed once, at import time return f"{at.isoformat()}: {message}"
first = log_event("start")time.sleep(0.05)second = log_event("still running")print("same timestamp both calls:", first.split(":")[0:2] == second.split(":")[0:2])
def log_event_fixed(message, at=None): at = at if at is not None else datetime.now() return f"{at.isoformat()}: {message}"
# 3. *args / **kwargs and call-site unpacking.def total(*args, **kwargs): print("args:", args, "kwargs:", kwargs) return sum(args) + sum(kwargs.values())
numbers = [1, 2, 3]extra = {"bonus": 10}print("total:", total(*numbers, **extra))
# 4. Positional-only parameters.def power(base, /, exponent=2): return base ** exponent
print("power(3):", power(3))try: power(base=3) # base is positional-only -- keyword form rejectedexcept TypeError as e: print(f"power(base=3) -> TypeError: {e}")
# 5. UnboundLocalError from count += 1 without global.counter = 0
def increment_buggy(): counter += 1 # BUG: this makes counter a LOCAL name -- read before assignment return counter
try: increment_buggy()except UnboundLocalError as e: print(f"increment_buggy() -> UnboundLocalError: {e}")
def increment_fixed(): global counter counter += 1 return counter
print("increment_fixed():", increment_fixed())print("increment_fixed():", increment_fixed())
# 6. callable().print("callable(len):", callable(len))print("callable(42):", callable(42))print("callable(power):", callable(power))Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”A function call creates a fresh frame — its own local namespace — and throws it away when the call returns. Name lookup for a read walks outward through frames using LEGB. An assignment never walks outward on its own: Python decides which frame a name belongs to before the function even runs, based on whether that name is assigned anywhere in the body — which is why an unqualified counter += 1 creates a local counter instead of reaching the outer one.
flowchart TB L["Local: current function's frame"] --> E["Enclosing: any outer function's frame"] E --> G["Global: the module namespace"] G --> B["Built-in: len, print, callable, ..."] L -.->|"assignment stays here unless declared global/nonlocal"| L
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Defaults evaluated once, at def time | Fast — no per-call re-evaluation | A mutable or “current value” default freezes state across calls |
*args / **kwargs | Flexible, forwarding-friendly signatures | The signature no longer documents which arguments are actually expected |
Positional-only / | A library can rename that parameter later without breaking callers | Slightly more ceremony to read; unfamiliar to newcomers |
Implicit None return | Every function has a defined, consistent return value | Silently masks a forgotten return; easy to use the result and not notice |
LEGB with explicit global/nonlocal | Reading outer names needs no boilerplate | Reassigning one does — forget the declaration and get UnboundLocalError |
Common Misconceptions
Section titled “Common Misconceptions”- “A default of
[]gives a fresh empty list on every call.” It does not — the list is created once, when thedefstatement runs, and that same object is reused on every call that doesn’t override it. - “
items=Nonealone makes the parameter optional and safe.”Noneis only a sentinel; you still have to writeif items is None: items = []yourself inside the function body. - “Reading a global variable inside a function requires the
globalkeyword.” It does not — only reassigning it does.print(counter)inside a function needs nothing special;counter += 1does. - “A function with no
returnstatement is an error, or returns nothing at all.” It returnsNone— a real, valid object — every time, with no exception raised. - “
*argsand**kwargsmust be named exactly that.” Only the*and**matter; the names are convention, not syntax —def f(*values, **options):works identically.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a default value use a mutable literal (
[],{},set()) or a call that only runs once (datetime.now(),time.time(),uuid.uuid4())? Both freeze atdeftime, not call time. - Does the function reassign (not just read) a name that also exists at module scope, such as
count += 1orcache[key] = valuewherecacheitself is reassigned? Look for a missingglobal/nonlocal. - Does every code path actually
returna value, or can execution fall off the end of the function and silently produceNone? - Does
*args/**kwargsswallow parameters that should be explicit and documented, hiding what the function really accepts from both readers and type checkers? - If the function is meant to be called with certain arguments only positionally (to allow renaming later), is there a
/in the signature — or is that left to convention and a docstring?
🐛 Spot the bug
def add_item(item, items=[]): items.append(item) return itemsShow the answer
items=[] is evaluated once, when this def statement runs, and that single list object is attached to the function and reused on every call that omits items. The first call returns ["apple"]; a second call with a different item does not start from an empty list — it appends onto the same list from before, silently accumulating state across unrelated calls. Fix it with the None sentinel: def add_item(item, items=None): items = items if items is not None else [] (or if items is None: items = [] in the body), so each call that doesn’t supply items gets a brand-new list.