Skip to content
System nominalUTC --:--:--Articles 041
MODULE 03 Lesson 08 / 18 30 MIN

Functions and structure

Scope, Lifetime, and the Mutable Default Trap

How Python resolves a name through four scopes, why a list default is shared across calls, and why a closure reads a loop variable after the loop has finished.

Two of the most reported Python bugs have the same shape: the value a function reads is not the value you expected, and the code that produced it looks correct. One is a list that keeps growing between calls. The other is a function that returns the last item of a loop instead of the item that was current when it was defined.

Neither is a quirk of the interpreter. Both follow from decisions that are documented and consistent: names are resolved by searching outward from where the code runs, and default values are evaluated when the function is created rather than when it is called. Learn those two rules properly and the bug class disappears.

Python searches four scopes, and it stops at the first match

When a function body reads a name, the interpreter searches four places in order: local, enclosing, global, builtins. The order is usually written as LEGB, and one example shows three of the four levels at once.

message = "global"


def outer():
    message = "enclosing"

    def inner():
        print("inner sees:", message)

    inner()


outer()
print("module sees:", message)
inner sees: enclosing
module sees: global

inner has no local message, so it looks in the enclosing function scope and finds the one outer assigned. The module-level message was never consulted, and it is untouched: the assignment inside outer created a second name in a second namespace. That is all shadowing means.

The fourth level is the builtins namespace, which is where print, len and sum come from. It is searched last, which is why you can shadow a built-in by accident and only find out later:

len = 3
print(len([1, 2, 3]))
Traceback (most recent call last):
  File "shadow.py", line 2, in <module>
    print(len([1, 2, 3]))
          ~~~^^^^^^^^^^^
TypeError: 'int' object is not callable

Module-level names are found before builtins, so the module's len wins and the built-in function is unreachable for the rest of the program. Never name a variable after a built-in you use. The same rule explains a list or dict variable turning into a mysterious TypeError twenty lines further down.

Assigning a name anywhere in a body makes it local everywhere in that body

Here is the failure everyone writes once:

count = 0


def record():
    count = count + 1
    return count


print(record())
Traceback (most recent call last):
  File "bound.py", line 9, in <module>
    print(record())
          ~~~~~~^^
  File "bound.py", line 5, in record
    count = count + 1
            ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

The message reads as a contradiction: the name exists on line 1 and the function still cannot access it. The explanation is that scope is decided when the body is compiled, not when it runs. The compiler sees an assignment to count inside record and marks that name local for the entire function. At run time, the right-hand side reads the local count before anything has been assigned to it, and Python does not fall back to the module.

So reading a global is always allowed, and assigning to one needs a declaration. That asymmetry is deliberate: it keeps a function that only reads a module-level constant from acquiring the ability to change it by accident.

global and nonlocal work, and both say something about your design

visits = 0


def record():
    global visits
    visits += 1
    return visits


print(record(), record(), visits)
1 2 2

The declaration tells the compiler to bind visits in the module namespace instead of the local one. It works, and in most code it is the wrong answer. The function now depends on a name it does not own, any other module that imports this one can change that name, and a test cannot run the function twice from a known state without resetting it first. Passing the value in and returning the new one keeps the same behaviour and moves the storage decision to the caller, who knows how long the value should live.

nonlocal is the narrower version, and it has a legitimate use: private state that belongs to a closure and to nothing else.

def make_counter():
    total = 0

    def bump():
        nonlocal total
        total += 1
        return total

    return bump


counter = make_counter()
print(counter(), counter(), counter())
print("second counter starts fresh:", make_counter()())
1 2 3
second counter starts fresh: 1

The difference from global is reachability. Nothing outside make_counter can name total, and every call to make_counter gets a fresh cell, which is why the second counter starts at 1. Without the declaration, total += 1 raises the same UnboundLocalError as record did, for the same reason.

My rule of thumb: one nonlocal declaring a counter, a cache or a connection is fine and is how decorators work. Three of them in one function means the state has grown into an object, and a class carrying that state will be easier to read and to test. Module 4 covers that.

A mutable default is created once and shared by every call

The previous lesson established that a default value is computed when the def runs. With a string, nothing visible happens. With a list, the same object is handed to every call, and a function that modifies it modifies the default for every future caller.

def add_tag(tag, tags=[]):
    tags.append(tag)
    return tags


print(add_tag("draft"))
print(add_tag("review"))
print(add_tag("published"))
print("default object is now:", add_tag.__defaults__)
['draft']
['draft', 'review']
['draft', 'review', 'published']
default object is now: (['draft', 'review', 'published'],)

Three calls that pass one argument each, and the list grows. The empty list in the signature was created once when the module was imported, stored in __defaults__, and every call that omits tags receives that same object. append mutates it in place, so the default is now the accumulator.

The last line is the proof and the diagnostic. Printing __defaults__ shows the stored value, and if it is anything other than empty, the function has been mutating its own default. This is worth remembering when a function behaves differently in a long-running process than it does in a short script: the state survived between calls and nobody wrote it down.

The trap needs both halves. Defaults are evaluated once, and lists are mutable. Use an immutable default, or do not mutate the value you were given, and the signature is harmless.

The fix is a sentinel default, not a defensive copy in the caller

def add_tag(tag, tags=None):
    if tags is None:
        tags = []
    tags.append(tag)
    return tags


print(add_tag("draft"))
print(add_tag("review"))
print(add_tag("published", ["pinned"]))
print(add_tag("archived"))
['draft']
['review']
['pinned', 'published']
['archived']

None is a value no caller would pass to mean "a list of tags", so the body can distinguish "nothing supplied" from "here is my list", and the new list is built inside the call. The fourth line confirms the default is no longer carrying state.

The third line shows what the fix does not solve: a caller who passes a list still gets it modified. If that matters, stop mutating and return a new value instead.

def add_tag(tag, tags=None):
    tags = [] if tags is None else list(tags)
    return [*tags, tag]


existing = ["pinned"]
print(add_tag("published", existing))
print("caller's list:", existing)
['pinned', 'published']
caller's list: ['pinned']

This version copies the caller's list and returns a new one, so nothing outside the function changes. It costs a copy and it is the version I would rather review, because "this function does not modify what you hand it" is a property you can check once instead of at every call site.

A closure captures the variable, not the value it held

handlers = {}
for name in ("alpha", "beta", "gamma"):
    def handler():
        return name
    handlers[name] = handler

print(handlers["alpha"](), handlers["beta"](), handlers["gamma"]())
gamma gamma gamma

Three function objects were created, each with its own __name__, and all three return gamma. The body of handler does not store the current value of name; it stores a reference to the variable. Name lookup happens when the function is called, and by then the loop has finished and the variable holds its last value. Every function shares one variable, so every function agrees.

This is late binding, and it is the intended behaviour for the case closures were built for: a function that reads the current state of something it was defined next to. It only surprises you when the variable is a loop variable that changed under the function.

The same shape appears in a list comprehension, where each lambda closes over the comprehension's own variable:

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])
[2, 2, 2]

Binding at definition time is the fix for late binding

handlers = {}
for name in ("alpha", "beta", "gamma"):
    def handler(captured=name):
        return captured
    handlers[name] = handler

print(handlers["alpha"](), handlers["beta"](), handlers["gamma"]())
alpha beta gamma

The default argument is evaluated once per function object, at the moment the def executes, so each handler walks away with its own copy of the name. The rule that caused the mutable default trap is the rule that fixes this one. The parameter exists only to hold the value, so name it something the reader will understand (captured here) and do not accept it from callers.

Two other fixes are common. functools.partial binds arguments when it builds the callable, which is the same idea with a library behind it, and a factory function that takes the value as a parameter and returns the closure binds it in the factory's scope. All three work; the default argument is the one that needs no import and no extra function, which is why it shows up in library code.

Lab 25 minutes

Predict the output of this file before you run it. Write your prediction down, including the identity check.

def register(name, registry={}):
    registry[name] = len(registry)
    return registry


def make_getters(names):
    return [lambda: n for n in names]


first = register("ana")
second = register("bo")
print(first, second)
print("same object:", first is second)

getters = make_getters(["ana", "bo", "cy"])
print([get() for get in getters])

Then fix both functions so the printed output is what a reasonable caller would expect, without changing any call site. Explain, in terms of the two rules in this lesson, why first is second is True in the original.

Show the solution and the reasoning

The original file prints this:

{'ana': 0, 'bo': 1} {'ana': 0, 'bo': 1}
same object: True
['cy', 'cy', 'cy']

first and second are the same dictionary, and the identity check is what proves it. The first call bound registry to the default object, returned it, and stored it in first. The second call received the same object and added bo to it, so both names now show both entries. Note that len(registry) produced the values 0 and 1, which is the only reason the damage is legible: the second call saw a dictionary that already had one entry. The getters return cy because all three lambdas share the comprehension's n, and the comprehension finished before any of them was called.

The fix keeps both signatures:

def register(name, registry=None):
    if registry is None:
        registry = {}
    registry[name] = len(registry)
    return registry


def make_getters(names):
    return [lambda n=n: n for n in names]


first = register("ana")
second = register("bo")
print(first, second)
print("same object:", first is second)
print([get() for get in make_getters(["ana", "bo", "cy"])])
{'ana': 0} {'bo': 0}
same object: False
['ana', 'bo', 'cy']

In register, the None sentinel moves the creation of the dictionary into the call, so each call that omits the argument gets a fresh one. first is second becomes False and both dictionaries hold one entry each, which is what the function's name promised.

In make_getters, the default parameter is evaluated once per lambda, so each one captures the value of n at the moment it was created. That is the same rule that produced the bug in register, used in the opposite direction: a default is evaluated when the function object is built, and here that is exactly the moment the value needs to be frozen.

The alternative fix for the getters is [functools.partial(identity, n) for n in names] with def identity(value): return value, which binds the argument at build time without leaning on default-argument evaluation. Both are correct. The default argument version needs no import, and the partial version reads as what it is instead of looking like an unused parameter.

One thing the fix does not do is stop register from mutating a dictionary the caller passes in. If that matters, copy at the top and return a new dictionary, the way add_tag does above.

Progress

Mark this lesson done and it stays marked in this browser.