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.

Python 3.13

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")
  • *args collects extra positional arguments into a tuple.
  • **kwargs collects 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:

  1. Pass the value in and return the new value.
  2. Use a mutable container or a class attribute for genuine shared state.
  3. Fall back to global (module scope) or nonlocal (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 None and build the value inside.
  • *args is a tuple, **kwargs is 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.