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.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 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.
def add_tax(price):
tax = price * 0.15
return price + tax
print(add_tax(100))
print(tax)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
UnboundLocalErrorand 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
RATE = 0.15
def add_tax(price):
return price + price * RATE
print(add_tax(100))115.0There 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
count = 10
def bump():
count = 99
return count
print(bump())
print(count)99
10The 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:
count = 10
def bump():
count = count + 1
return count
print(bump())UnboundLocalError: cannot access local variable 'count' where it is not associated with a valueThe 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.
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.
def add_item(items):
items.append("pen")
return items
basket = []
add_item(basket)
add_item(basket)
print(basket)['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:
def replace(items):
items = ["new"]
return items
basket = ["old"]
result = replace(basket)
print(basket)
print(result)['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
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("pen"))
print(add_item("bag"))
print(add_item("ink"))['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:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("pen"))
print(add_item("bag"))['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.
Whyis Noneand not== None?isasks "is this the very same object?", and there is only oneNonein a whole program.==tests equality, which some types define in surprising ways. ForNone,isis both conventional and dependable.
A complete example
basket.py:
# 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")pen 51.75
bag 977.50
morning order
2 lines, 1029.25 in total
basket outside the function: 2 linesThree 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.
Step 4 of 6 — Predict
Check your understanding
A value is assigned to count inside the function. What do the two lines print?
count = 10
def bump():
count = 99
return count
print(bump())
print(count)- A99 10
- B99 99
- C10 10
- DAn `UnboundLocalError`
There is no return, yet the outer list is printed. What appears?
def add_item(items):
items.append("pen")
basket = []
add_item(basket)
add_item(basket)
print(basket)- A['pen', 'pen']
- B[]
- C['pen']
- DNone
Two separate calls, with an empty list as the default. What is printed?
def collect(item, box=[]):
box.append(item)
return box
print(collect("pen"))
print(collect("bag"))- A['pen'] ['pen', 'bag']
- B['pen'] ['bag']
- C['pen', 'bag'] ['pen', 'bag']
- D['pen'] ['pen']
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Write a file called cart.py with three functions:
line_total(price, quantity, tax_rate=0.15)— pure, touching nothingadd_to(cart, name, price, quantity)— adds to the cart it is handedreceipt(cart, header=None)— returns a list of lines, inventing a header when none is given
Then build an empty cart, add a few things, and print the receipt.
Finally, three experiments, all worth seeing with your own eyes:
- Change
receipt's default toheader=[], then call the function twice and print both results. What do you see the second time? - Add
cart = cart.copy()as the first line ofadd_to, then print the outer cart. How many things are in it, and why? - Assign
TAX_RATE = 0.2inside a function, then printTAX_RATEoutside it. Which value do you see?
The three answers together are the whole chapter: which names live where, and which changes reach the outside.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz