Chapter 21

Scope and mutable defaults

Where a name lives and who can see it, the difference between reading and writing an outer name, UnboundLocalError, and Python's most famous trap — the empty list as a default.

34 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

Chapter nineteen's "when it breaks" list left one line unresolved: "A variable inside the function is not visible outside — that is expected, and why is chapter twenty-one's subject."

This is that chapter.

python
def add_tax(price):
    tax = price * 0.15
    return price + tax


print(add_tax(100))
print(tax)
text
115.0
NameError: name 'tax' is not defined. Did you mean: 'max'?

The first line worked, so tax must have existed. And outside, it does not.

This is protection rather than limitation. In a program with fifty functions, if every internal name leaked outward, two functions' total variables would overwrite each other — and nobody would notice. Names inside a function stay inside, so writing one function does not require knowing the names in the other forty-nine.

This chapter is that rule, and one consequence of it that is Python's most famous trap.

By the end of this chapter you can

  • Say where a name "lives" and who can see it
  • Read an outer name from inside a function, and say why you cannot write one
  • Read an UnboundLocalError and tell what happened
  • Explain why a list passed into a function can come back changed
  • Recognise and avoid Python's most famous trap: the mutable default

Prerequisites: Function arguments.


Inside stays inside

Every name created inside a function — parameters included — lives only for that call. When the function ends, the names are gone.

This is called scope: the region in which a name is known.

Reading an outer name works

python
RATE = 0.15


def add_tax(price):
    return price + price * RATE


print(add_tax(100))
text
115.0

There is no RATE inside the function, so Python looks outside and finds one. Reading an outer name from within is fine.

The all-capitals name is a convention rather than a Python rule — it tells a reader "this is a constant, it does not change". Used that way, an outer name is at its safest.

But writing one does not

python
count = 10


def bump():
    count = 99
    return count


print(bump())
print(count)
text
99
10

The inner count = 99 never touched the outer count. It created a new, local name that lives only inside the function and hid the outer one for the duration.

That is the rule, and it is deliberate: a function cannot change something outside by accident.

And out of it comes a strange-looking error:

python
count = 10


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


print(bump())
text
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

The line reads as though it takes the outer count and adds one. But Python inspects the whole function before running it, sees that something assigns to count, and decides that count is local to this function from beginning to end.

Then, evaluating count + 1 on the right, it looks for the local count, which has not been given a value yet — and stops.

The message is therefore unusually accurate: the name is local, and nothing has been associated with it yet.

The fix is almost always the same, and it is not global: take the value as an argument and return the result.

text
def bump(count):
    return count + 1

count = bump(count)

Python does have a global keyword, and this course does not teach it, because the problem people reach for it to solve has a better answer. A function that changes outside state cannot be understood from the place it is called — and that is the definition of code that is hard to test and hard to trust.

But the thing you passed in can change

Scope protects names, not objects. The distinction is subtle and important.

python
def add_item(items):
    items.append("pen")
    return items


basket = []
add_item(basket)
add_item(basket)

print(basket)
text
['pen', 'pen']

The outer basket changed, even though no return was kept.

The reason is chapter twelve's again: the name items is new, but it points at the same list. append modifies that list, and both names show the change.

Compare:

python
def replace(items):
    items = ["new"]
    return items


basket = ["old"]
result = replace(basket)

print(basket)
print(result)
text
['old']
['new']

Here items = [...] assigned a new value, pointing the local name at a different list. The outer basket is where it was.

The rule in one line: modifying the object (append, sort, [i] =) is visible outside; pointing the name at another object (=) is not.

Python's most famous trap

python
def add_item(item, basket=[]):
    basket.append(item)
    return basket


print(add_item("pen"))
print(add_item("bag"))
print(add_item("ink"))
text
['pen']
['pen', 'bag']
['pen', 'bag', 'ink']

Three separate calls, and each one keeps what the last ones left.

Understand the reason and you will never forget it: the default value is evaluated once, when the def line runs — not when the function is called. That empty list is a single list attached to the function, and every call shares it.

The fix is conventional and simple:

python
def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket


print(add_item("pen"))
print(add_item("bag"))
text
['pen']
['bag']

None is immutable, so sharing it costs nothing, and the new list is created fresh on every call.

As a rule: never write a list, dictionary or set as a default value. Numbers, text, True/False and None are safe, precisely because they cannot be changed.

Why is None and not == None? is asks "is this the very same object?", and there is only one None in a whole program. == tests equality, which some types define in surprising ways. For None, is is both conventional and dependable.

A complete example

basket.py:

python
# Where a name lives decides who can see it and who can change it
TAX_RATE = 0.15


def line_total(price, quantity):
    """Pure: takes values, returns a value, touches nothing outside."""
    return round(price * quantity * (1 + TAX_RATE), 2)


def add_line(basket, name, price, quantity):
    """Impure on purpose: it changes the basket it was handed."""
    basket.append({"name": name, "total": line_total(price, quantity)})
    return basket


def summarise(basket, note=None):
    """`None` as the default, so no list or dict is shared between calls."""
    notes = [] if note is None else [note]
    total = sum(line["total"] for line in basket)
    notes.append(f"{len(basket)} lines, {total:.2f} in total")
    return notes


shopping = []
add_line(shopping, "pen", 15.0, 3)
add_line(shopping, "bag", 850.0, 1)

for line in shopping:
    print(f"{line['name']:<6} {line['total']:>8.2f}")

print()
for note in summarise(shopping, "morning order"):
    print(note)

print()
print("basket outside the function:", len(shopping), "lines")
text
pen       51.75
bag      977.50

morning order
2 lines, 1029.25 in total

basket outside the function: 2 lines

Three functions with three different relationships to the world outside them, and that is the thing to look at.

line_total touches nothing. It takes values and returns a value. It reads TAX_RATE and never changes it. A function like this is called pure — the same input always gives the same output, with no side effects anywhere. It is the easiest kind to test and the hardest kind to get wrong.

add_line changes something outside on purpose. It is handed a list and adds to it — and the last line proves the outer shopping really does hold two rows. There is nothing wrong with that, as long as the name says so. Reading add_line tells you something is going to be added.

summarise uses None as its default. Written note=[], every call would share one list, and the second report would carry the first one's note.


When it breaks

NameError: name 'tax' is not defined A name from inside a function was used outside. return whatever is needed outside.

UnboundLocalError: cannot access local variable ... where it is not associated with a value A name is assigned to inside the function and read before that assignment. Python decided it was local. Take the value as an argument and return the result.

My list changed after I passed it into a function That is the expected behaviour — the name is new, the list is the same one. To avoid it, write items = items.copy() at the top of the function, or build and return a new list.

The function remembers its previous results A mutable default. Not def f(x, acc=[]) but def f(x, acc=None), with if acc is None: acc = [] inside.

I used the same name in two functions — is that a problem? It is not, and that is the entire purpose of scope. Two functions' total variables are completely separate.