Python Functions: Default Arguments, Scope, and Closures
- A default argument value is evaluated once, when the function is defined — not each time it is called.
- Python resolves names using the LEGB rule: Local, Enclosing, Global, Built-in, in that order.
nonlocallets a nested function reassign a variable that lives in an enclosing function, not the module.- A closure is a function that carries its enclosing scope's variables with it, even after that scope has returned.
Defining a function
A Python function is defined with def, a name, a parenthesized parameter list, and an indented body. Parameters can have default values, and any parameter without a default must come before the parameters that do:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Ana")) # Hello, Ana!
print(greet("Ana", "Hi")) # Hi, Ana!
Arguments can be passed positionally or by keyword, and mixing the two is fine as long as positional arguments come first:
print(greet(name="Ana", greeting="Hi"))
print(greet("Ana", greeting="Hi"))
Default arguments and the mutable-default trap
Default argument values are evaluated exactly once — when Python reads the def statement — not on every call. For immutable defaults like numbers, strings, or None, this is invisible. For mutable defaults like a list or dict, it produces one of the most common Python surprises:
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("apple")) # ['apple']
print(add_item("pear")) # ['apple', 'pear'] -- not ['pear']!
Both calls share the same list object, because it was created once at definition time and reused across every call that does not supply its own basket. The fix is to default to None and create the mutable object inside the function body:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
The LEGB scope rule
When Python looks up a name, it searches four scopes in order:
- Local — names assigned inside the current function.
- Enclosing — names in any enclosing function, for nested functions.
- Global — names assigned at module level.
- Built-in — names provided by Python itself, like
lenorprint.
The first match wins. This is why a local variable can shadow a global one with the same name, and why assigning to a name anywhere inside a function body makes Python treat it as local for the entire function — even on lines that execute before the assignment:
count = 0
def broken():
print(count) # UnboundLocalError
count = 1
broken()
Python sees the assignment count = 1 later in the function and decides count is local throughout, so the print line fails before that assignment ever runs.
nonlocal and enclosing scope
Reading an enclosing variable works without any keyword, but reassigning one requires nonlocal:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
Without nonlocal, count += 1 inside increment would create a brand-new local count in that inner function instead of updating the one from make_counter, raising UnboundLocalError on the read side of +=.
Closures: functions that remember
make_counter above is a closure factory: increment is a closure because it remembers the value of count from the scope it was created in, even though make_counter has already returned. Each call to make_counter() creates a fresh, independent count:
a = make_counter()
b = make_counter()
print(a(), a(), b()) # 1 2 1
a and b do not share state — each closure carries its own copy of the enclosing variables it references. You can inspect what a closure has captured through its __closure__ attribute, though this is rarely needed in everyday code.
Closures are the mechanism behind function factories, memoisation helpers, and — most visibly — decorators, which wrap one function inside another and rely on the wrapper closing over the original.
global vs nonlocal
nonlocal reaches into an enclosing function's scope; global reaches all the way out to module-level scope, skipping any enclosing functions in between:
total = 0
def reset_and_add(amount):
global total
total = 0
total += amount
reset_and_add(5)
print(total) # 5
Without global, the assignment total = 0 inside reset_and_add would create a new local variable named total, leaving the module-level total untouched, and the following total += amount would then raise UnboundLocalError because that local total was never given an initial value before the increment tried to read it.
In practice, reaching for global is usually a sign that the function is doing more than it should. A closure that returns a value, or a small class that holds the shared state as an attribute, is normally easier to test and reason about than a function that quietly mutates a module-level variable as a side effect. nonlocal is less risky than global because the variable it modifies is still private to the enclosing function rather than visible to the entire module, but the same principle applies: prefer returning new values over mutating captured state where the logic allows it.