LambdaLabTM
Computer Science · Class 12 · Functions
FunctionsDefaults⏱️ 14 min read

Mutable Defaults

The last lesson said a default value fills a parameter the caller left out. True — but which value? A default is worked out once, when the def line runs, and the same one is handed to every call after that. With a number or a string nobody notices. With a list, everybody does.

1The program that surprises everyone

Here is a function that puts an item in a basket. If no basket is given, it starts an empty one:

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

print(add_item('pen'))
print(add_item('book'))
print(add_item('bag'))
Output
['pen']
['pen', 'book']
['pen', 'book', 'bag']

Three separate calls, and the basket never emptied. Most people expect three lines each holding one item — ['pen'], ['book'], ['bag'] — because surely a fresh call makes a fresh basket?

Watch Out
Half of that is right, and the half that is wrong is the interesting half. A fresh call really does make a fresh basket name. It does not make a fresh list.

2The list is made once, not once per call

Everything on the def line is worked out at the moment Python reads that line — and it reads it once. The [] is part of that line, so one empty list is created, once, and kept with the function for as long as the program runs.

You can watch it happen. Print the basket at the start of each call, before anything is added:

watch_the_default.py
def add_item(item, basket=[]):
    print('basket at the start of this call:', basket)
    basket.append(item)
    return basket

add_item('pen')
add_item('book')
add_item('bag')
Output
basket at the start of this call: []
basket at the start of this call: ['pen']
basket at the start of this call: ['pen', 'book']

It is empty on the first call only. By the second call the “empty basket” already has a pen in it, because it is the same basket the first call put the pen into. Nobody made a new one, because nothing asked for a new one to be made.

Key Takeaway
def add_item(item, basket=[]) does not mean “start with an empty list every time”. It means “start with this particular list, the one I made when I read the def line”. On the first call that list happens to be empty. After that, it is whatever the previous calls left in it.

3The name is local. The list is not.

This is the sentence worth learning, so here it is slowly. A local variable is two things — a name and the object that name points at — and only the first of them belongs to the call.

Three calls, three names, one list
Made fresh for every call
add_item('pen')
local name basket →
add_item('book')
local name basket →
add_item('bag')
local name basket →
Made once, when def ran
[ ]

One list, stored with the function itself. It is not inside any call, so no call can end and take it away.

Each call builds its own little workspace and puts the name basket in it. That name is thrown away when the call ends — but the name was only ever a label pointing at the list. The list itself lives with the function, not in the workspace, so it survives every call and carries the previous items with it.

And append does not touch the name at all. It reaches through the name and changes the list on the other end — the one every call is pointing at.

4Rebinding a name, and changing a thing

Python has two very different operations that both look like “the variable changed”, and telling them apart explains this whole lesson:

Rebinding — the = sign
basket = basket + [item]

Builds a new list and points the name at that instead. The old list is left exactly as it was. The name has moved; nothing was edited.

Mutating — a method like append
basket.append(item)

Leaves the name where it is and edits the list it points at. Everyone else pointing at that list sees the change.

Swap append for an assignment and the surprise disappears completely — same default, same three calls:

rebinding.py
def add_item(item, basket=[]):
    basket = basket + [item]      # a NEW list — the name now points at that
    return basket

print(add_item('pen'))
print(add_item('book'))
print(add_item('bag'))
Output
['pen']
['book']
['bag']

Three separate answers, which is what everybody expected the first time. Print the basket at both ends of the call and you can see why:

rebinding_watched.py
def add_item(item, basket=[]):
    print('start of call:', basket)
    basket = basket + [item]      # a NEW list — the name now points at that
    print('end of call  :', basket)
    return basket

add_item('pen')
add_item('book')
Output
start of call: []
end of call  : ['pen']
start of call: []
end of call  : ['book']
print('start of call:', basket)

Empty on BOTH calls now — the shared list is still empty, because the first call never changed it.

basket = basket + [item]

The + builds a brand new list. The = points the local name at that new list, and lets go of the shared one.

print('end of call :', basket)

The name is on the new list, so it shows one item. The shared list, sitting with the function, is untouched.

Key Takeaway
The shared list is only a problem when you change it in place. = redirects the name to something new and leaves the original alone. append, insert, remove, sort and basket[0] = … all reach through the name and edit the original, which is why the items pile up.

This is the same mutable / immutable idea from Class 11, and the same one behind n = n + 1 changing nothing outside a function while marks.append(m) changes everything. One moves a label; the other edits a thing.

5Why msg='Hello' never caused trouble

Every default is shared — the string in msg='Hello' is created once too. It causes no trouble because a string cannot be edited in place. There is no msg.append(…) to write. The only thing you can do to a string is build a new one and rebind:

immutable_default.py
def greet(name, msg='Hello'):
    msg = msg + '!'               # rebinding — the only option a string allows
    print(msg, name)

greet('Riya')
greet('Amit')
Output
Hello! Riya
Hello! Amit

Both calls start from 'Hello', because nothing can reach into a string and change it. Numbers, strings, Booleans, None and tuples are all immutable, so all of them are safe as defaults. Lists, dictionaries and sets are not.

shared_dict.py
# a dictionary default has exactly the same problem

def tally(mark, marks={}):
    marks[mark] = marks.get(mark, 0) + 1
    return marks

print(tally('A'))
print(tally('B'))
print(tally('A'))
Output
{'A': 1}
{'A': 1, 'B': 1}
{'A': 2, 'B': 1}

6The fix: an immutable default, and a fresh list inside

If you want a genuinely new list on every call, the list has to be created inside the body — because the body is the part that runs every time. The default becomes None, which is immutable and therefore safe, and the first line checks for it:

basket_fixed.py
def add_item(item, basket=None):
    if basket is None:
        basket = []               # runs on every call that needs it
    basket.append(item)
    return basket

print(add_item('pen'))
print(add_item('book'))
print(add_item('bag'))
Output
['pen']
['book']
['bag']
def add_item(item, basket=None):

None is the default now. It is immutable, so there is nothing for the calls to share and spoil.

if basket is None:

True only when the caller left the basket out. If they passed their own list, this is skipped.

basket = []

Inside the body, so it runs afresh on every such call. THIS is the line that makes a new empty list each time.

Note
is None rather than == None. Both work here, and is is what Python programmers write for this check. Read it as “is this the same nothing?”

7Passing your own list is never affected

The sharing only happens when the argument is left out. Supply a basket and the default is not consulted at all:

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

print(add_item('pen'))                # uses the shared default
print(add_item('cap', ['own']))       # uses the caller's own list
print(add_item('book'))               # back to the shared default
Output
['pen']
['own', 'cap']
['pen', 'book']

The middle call is completely separate. The last call carries on from ['pen'], because the shared list never saw the 'own' basket at all.

try_the_default.py

8Recap

A default is made once

The def line runs once, so the [] on it creates one list, kept with the function for the whole program.

The name is fresh; the object is not

Every call makes a new local name basket. Every one of those names points at that same one list.

= moves the name, methods edit the thing

basket = basket + [x] builds a new list and leaves the old one alone. basket.append(x) edits the shared one.

Immutable defaults are safe

Numbers, strings, Booleans, None and tuples cannot be edited in place, so nothing can pile up in them.

Use None and build inside

def f(x, lst=None): then if lst is None: lst = []. The body runs every call, so the list really is new each time.

Passing your own is unaffected

The default is only used when the argument is left out. Supply a list and it is yours alone.

✍️ Now write these yourself
  1. 1

    Run the basket program and predict the fourth call before you add it.

    Hint · It carries on from wherever the third call left off — four items, not one.

  2. 2

    Print basket at the start of the body and watch it arrive non-empty on the second call.

    Hint · That single line is the whole explanation. The “empty” default is not empty any more.

  3. 3

    Change basket.append(item) to basket = basket + [item] and run it again.

    Hint · Three separate answers. The shared list is still there, just never touched.

  4. 4

    Fix it with basket=None and check all three calls give one item each.

    Hint · The basket = [] has to be inside the body, because the body is the part that runs every time.

  5. 5

    Write the same trap with a dictionary default and a .get() tally.

    Hint · A dictionary is mutable too, so the counts keep adding up across calls.

Quick Check

Why does add_item('book') print ['pen', 'book'] and not ['book']?

Quick Check

Which change removes the surprise, keeping basket=[] as the default?

Quick Check

Why is msg='Hello' a perfectly safe default?

Quick Check

In the None fix, why must basket = [] be inside the body?