Python Functions and Argument Passing
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
A function gives a name to a piece of logic so it can be reused and tested. Most of the confusion around Python functions comes from two things: how arguments bind to parameters, and when default values are evaluated.
Environment
Examples are verified on Python 3.13 and need nothing beyond the standard library.
Defining and calling
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
print(greet("Ada")) # Hello, Ada
print(greet("Ada", "Morning")) # Morning, Ada
A function without return, or with a bare return, evaluates to None. That is not an error, but assigning the result of a function that mutates in place is a common source of None bugs.
Positional and keyword arguments
def connect(host, port, timeout=5):
print(host, port, timeout)
connect("127.0.0.1", 8080) # positional
connect("127.0.0.1", port=8080, timeout=1) # keyword
Rules:
- Positional arguments must come before keyword arguments.
- A keyword argument may not repeat a parameter already filled positionally.
- Once you pass one keyword argument, the rest of that call should usually be keywords too.
Use keywords for booleans and for anything whose meaning is not obvious at the call site:
send(data, True) # what is True?
send(data, compress=True) # readable
The mutable default trap
This is the classic Python bug:
def add_item(item, target=[]): # dangerous
target.append(item)
return target
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b']
Default values are evaluated once, when the function is defined, not on each call. Every call that omits target shares the same list object.
The fix is the None sentinel:
def add_item(item, target=None):
if target is None:
target = []
target.append(item)
return target
The same rule applies to dicts, sets, and any mutable object, including instances of your own classes.
Variable numbers of arguments
def total(*numbers):
return sum(numbers)
total(1, 2, 3) # 6
def build_url(**parts):
return "&".join(f"{k}={v}" for k, v in parts.items())
build_url(scheme="https", host="example.com")
*argscollects extra positional arguments into a tuple.**kwargscollects extra keyword arguments into a dict.
The full parameter order is fixed:
def f(positional, default=1, *args, keyword_only, **kwargs)
Keyword-only parameters appear after *args or after a bare *:
def create_user(*, name, admin=False):
print(name, admin)
create_user(name="Ada") # fine
create_user("Ada") # TypeError
Use * when a parameter’s name carries meaning and positional use would be unreadable.
Passing containers
Arguments are passed by object reference, but names are rebound locally:
def append_one(items):
items.append(1) # mutates the caller's list
def reassign(items):
items = [9] # rebinds the local name only
nums = [0]
append_one(nums)
print(nums) # [0, 1]
reassign(nums)
print(nums) # [0, 1]
Python has no “pass by value” versus “pass by reference” switch; it has assignment semantics. If you do not want the caller’s object modified, copy it first (list(items), dict(d), copy.deepcopy(x)).
Scope
Reading an outer variable works; assigning to it inside a function creates a local:
count = 0
def bump():
count += 1 # UnboundLocalError
return count
Because the function contains an assignment to count, Python treats count as local for the whole function body, and count += 1 reads it before it exists.
Options, in order of preference:
- Pass the value in and return the new value.
- Use a mutable container or a class attribute for genuine shared state.
- Fall back to
global(module scope) ornonlocal(enclosing function).
def bump(count):
return count + 1
count = bump(count)
Type annotations
Annotations document intent and feed editors and type checkers; they are not enforced at runtime.
def average(values: list[float]) -> float:
return sum(values) / len(values)
Run mypy or pyright in CI to catch mismatches before they reach production. Python 3.10+ also allows int | None instead of Optional[int].
Common mistakes
1. Mutable default values
Covered above; it affects lists, dicts, sets, and object instances.
2. Calling with the wrong argument order
Two parameters of the same type are easy to swap: send(user, message) versus send(message, user). Keyword-only parameters prevent this.
3. Using **kwargs to silence the signature
A function that accepts **kwargs and ignores unknown keys hides typos. Accept explicit parameters and let TypeError do its job.
4. Forgetting that functions are objects
handlers = {"click": on_click}
handlers["click"]() # valid
This is a feature, but storing a function and calling it later means you also need to think about closures and late binding.
5. Late binding in closures
funcs = [lambda: i for i in range(3)]
[f() for f in funcs] # [2, 2, 2]
Bind the value as a default argument: lambda i=i: i.
6. Shadowing built-ins
def list(...), sum = 0, id = "x" all break later code in the same scope.
Summary
- Positional first, keyword after; prefer keywords for booleans and optional settings.
- Never use a mutable default; use
Noneand build the value inside. *argsis a tuple,**kwargsis a dict, keyword-only parameters come after*.- Assignment inside a function creates a local; return new values instead of using
global. - Annotations are for readers and tools, not runtime checks.
Next: reading and writing files, and handling the paths around them.