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:
def add_item_broken(item, basket=[]):
basket.append(item)
return basket
print(add_item_broken("apple"))
print(add_item_broken("banana"))['apple']
['apple', 'banana']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()first basket: ['apple']
second basket: ['banana']
total: 6.75
describe: name=Ada, role=engineer
counter: 2add_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 explicitreturnto “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
globalwhen 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
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.
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.
Hint 1
Think back to Collections and Iteration: passing a list argument passes a reference to the same object, not a copy.
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.
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 allThe 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.
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
deftime — a mutable default ([],{}) is silently shared across every call that relies on it. The fix: default toNone, then create a fresh object inside the function body. See Mutable Default Argument. *argscollects extra positional arguments into a tuple;**kwargscollects extra keyword arguments into a dict — see*argsand**kwargs.- Assigning to a name inside a function makes it local by default;
globalis required to explicitly read-and-write a module-level name instead, and its absence produces anUnboundLocalError, 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.