BeginnerFunctions and scope~35 minPython

Functions and Scope

Define functions with positional, keyword, and variable arguments; understand local vs. global scope; and avoid Python's most famous gotcha, the mutable default argument.

By the end of this lesson, you can

  • Call a function with positional, keyword, *args, and **kwargs arguments correctly
  • Explain why a mutable default argument is shared across calls, and apply the None-guard fix
  • Distinguish local from global scope, including what the global keyword actually does
  • Tell a pure function apart from one with side effects

Why it matters

Functions are how a program stops being one long script and becomes a set of named, reusable pieces. Getting comfortable with how arguments bind to parameters, where a name is visible from, and — especially — the one default-argument behavior that catches nearly everyone at least once, pays off in every function written after this lesson.

Mental model

A function’s parameters are just names, bound the same way any assignment binds a name — which means everything from Names, Values, and Assignment applies directly to arguments too. Passing a mutable object as an argument passes a reference to that same object, not a copy — a function that mutates a parameter it received mutates the caller’s object.

Scope governs where a name is visible. Python looks a name up in this order: Local (inside the current function) → Enclosing (an outer function, if this one is nested) → Global (the module) → Built-in (len, print, …) — remembered as LEGB. Assigning to a name inside a function makes it local by default, even if a global with the same name already exists; the global keyword is required to explicitly opt into modifying a global from inside a function.

Worked example: the trap, and three argument styles

Before the fixed version, here’s the trap itself, run twice to make the surprise concrete:

PythonThe mutable-default-argument trap
def add_item_broken(item, basket=[]):
    basket.append(item)
    return basket


print(add_item_broken("apple"))
print(add_item_broken("banana"))
Output
['apple']
['apple', 'banana']
Pythonfunctions_and_scope.py
def add_item(item: str, basket: list[str] | None = None) -> list[str]:
    if basket is None:
        basket = []
    basket.append(item)
    return basket


def total(*numbers: float) -> float:
    return sum(numbers)


def describe(**details: str) -> str:
    parts = [f"{key}={value}" for key, value in details.items()]
    return ", ".join(parts)


counter = 0


def increment() -> None:
    global counter
    counter += 1


def main() -> None:
    first_basket = add_item("apple")
    second_basket = add_item("banana")
    print("first basket:", first_basket)
    print("second basket:", second_basket)

    print("total:", total(1.5, 2.25, 3.0))
    print("describe:", describe(name="Ada", role="engineer"))

    increment()
    increment()
    print("counter:", counter)


if __name__ == "__main__":
    main()
Output
first basket: ['apple']
second basket: ['banana']
total: 6.75
describe: name=Ada, role=engineer
counter: 2

add_item uses the standard fix: default to None, then create a genuinely fresh list inside the function body whenever the caller didn’t supply one — each call gets its own basket this time. total(*numbers) and describe(**details) show *args and **kwargs collecting a variable number of positional and keyword arguments, respectively. increment demonstrates global: without that declaration, counter += 1 inside the function would try to read a local counter before it’s ever assigned, and raise an error instead of updating the module-level one.

Common mistakes

  • Writing def f(items=[]): (or ={}) without realizing the default is shared across every call that relies on it — by far the most common way this trap gets hit.
  • Assuming a function that mutates its argument (like basket.append(item)) needs an explicit return to “send the change back” — it doesn’t; the caller already holds a reference to the same object, so the mutation is visible to them regardless of what the function returns.
  • Forgetting global when a function is meant to update a module-level variable — without it, assigning to that name inside the function creates a new, local variable instead, and the module-level one is left untouched.

Check your understanding

def append_one(items=[]): items.append(1) return items a = append_one() b = append_one() print(a is b)
Or reveal the answer without checking

Answer:True
Both calls use the same shared default list object (created once, at def time) — a and b are two names for that same object, so a is b is True.

total = 0 def add(n): total += n add(5) What happens?
Or reveal the answer without checking

Answer:An UnboundLocalError is raised, because total is assigned inside add without being declared global
total += n counts as assigning to total inside add, which makes Python treat total as local to add for its entire body — including the read on the right-hand side of +=, which happens before any local total exists yet. Without a global total declaration, this raises UnboundLocalError.

Practice: warm-up

Predict, then check: does calling a function that mutates a list argument need to return that list for the caller to see the change? Write a one-function test to confirm your answer directly.

Stuck? Reveal one hint at a time.

  1. Hint 1

    Think back to Collections and Iteration: passing a list argument passes a reference to the same object, not a copy.

  2. Hint 2

    If the function mutates the object in place (.append, .sort, item assignment) rather than rebinding the parameter name to a new object, the caller's own variable already points at the same, now-changed object.

  3. Hint 3

    No — return is not required for the caller to see an in-place mutation. It is required if the function instead builds and returns a new object (e.g. new_list = original + [x]) rather than mutating the original.

Reveal the answer

Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.

def add_silently(items):
    items.append("added")
    # no return


my_list = ["a", "b"]
add_silently(my_list)
print(my_list)  # ['a', 'b', 'added'] — visible with no return at all

The function never returns anything, and the caller still sees the change — because items and my_list are two names for the same object, and .append mutates that object in place.

Practice: apply it

Write a small calculator with *args: total_of(*numbers: float, minimum: float = 0) -> float, that sums its positional arguments but raises ValueError if the sum is below minimum. Predict whether minimum needs a * or ** prefix before writing it.

total_of(*numbers, minimum=0) — does minimum need a * or ** prefix, given it comes after *numbers in the signature?
Or reveal the answer without checking

Answer:No prefix — anything after *args in a signature must be passed by keyword, and is written as a plain named parameter
A parameter listed after *args in a definition becomes keyword-only automatically — it's written as a plain name (optionally with a default, as minimum has here), not with * or **. Only *args itself and **kwargs need the prefix.

Summary

  • A default argument value is created once, at def time — a mutable default ([], {}) is silently shared across every call that relies on it. The fix: default to None, then create a fresh object inside the function body. See Mutable Default Argument.
  • *args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict — see *args and **kwargs.
  • Assigning to a name inside a function makes it local by default; global is required to explicitly read-and-write a module-level name instead, and its absence produces an UnboundLocalError, not a silent fallback to the global.
  • Mutating a passed-in mutable argument is visible to the caller without any return — because the parameter and the caller’s variable are two names for the same object.