Python from zero to data engineering Lesson 14 / 110

Mutability and references: why did both lists change?

Understand shared objects, shallow copies, mutable defaults, and the memory cost of copying more data than you need.

You copy a variable, change the new one, and discover that the original changed too. The cinema is haunted, but this particular incident is ordinary Python.

You need: lists, dictionaries, functions, and defaults. By the end: you can distinguish two names from two objects and decide how much copying is actually necessary. Allow 30–40 minutes.

Assignment does not clone a list

original = ["Mina", "Luca"]
working = original
working.append("Ana")

print(original)
print(working is original)

Output:

['Mina', 'Luca', 'Ana']
True

Both names refer to the same list. .append() mutates that object, so either name reveals the change. is checks whether two references identify the same object. == instead compares values. Use == for ordinary value comparisons; reserve is for identity questions such as checking None.

Integers and strings are immutable. Lists and dictionaries are mutable. That distinction concerns objects; a variable name can be rebound to another object in either case.

Reassignment and mutation behave differently

def add_guest(guests):
    guests.append("Ana")


def replace_guests(guests):
    guests = ["Noah"]
    return guests


customers = ["Mina"]
add_guest(customers)
replacement = replace_guests(customers)
print(customers)
print(replacement)

Output:

['Mina', 'Ana']
['Noah']

The first function changes the shared list. The second changes only its local name and returns the new list. The caller’s customers still refers to the original object.

A mutating function can be perfectly appropriate. Make its contract clear: does it update the supplied collection, or return a new result? Surprises at this boundary cause more trouble than either approach by itself.

A shallow copy creates only a new outer container

original = [{"customer": "Mina", "tickets": 2}]
working = original.copy()

working.append({"customer": "Luca", "tickets": 1})
working[0]["tickets"] = 3

print(len(original))
print(original[0]["tickets"])
print(working is original)
print(working[0] is original[0])

Output:

1
3
False
True

The outer lists differ, so appending to working does not append to original. The first dictionary is still shared, so changing its ticket field is visible through both lists. This is a shallow copy.

For records containing only immutable field values, copying each record can provide the isolation we need:

original = [{"customer": "Mina", "tickets": 2}]
working = []
for booking in original:
    working.append(booking.copy())

working[0]["tickets"] = 3
print(original[0]["tickets"])
print(working[0]["tickets"])

This prints 2, then 3. If a record contained another mutable list or dictionary, that nested value would still be shared. Python also offers copy.deepcopy() for recursively copying many object structures; we will use modules formally in lesson 20. It is not a default prescription for every transformation.

The default list that remembers previous calls

This is an intentionally flawed function. Predict its second result:

def remember_guest(name, guests=[]):
    guests.append(name)
    return guests


print(remember_guest("Mina"))
print(remember_guest("Luca"))

Output:

['Mina']
['Mina', 'Luca']

The default list was created once when the function was defined. Calls that omit guests reuse it. To create a fresh list for each omitted argument, use None as a sentinel—a value meaning “not supplied”—and allocate inside the function:

def remember_guest(name, guests=None):
    if guests is None:
        guests = []
    guests.append(name)
    return guests


print(remember_guest("Mina"))
print(remember_guest("Luca"))

Now each output has just one name. Passing an existing list explicitly still allows the function to mutate it. if guests is None preserves that behaviour even for an explicitly supplied empty list; an emptiness check would express a different contract.

Performance and memory: copy with a reason

A shallow list copy takes work proportional to its length and allocates another container of references. Copying each record adds more objects. A deep copy can traverse much more data and use substantially more memory.

Do not claim isolation from a shallow copy of nested data. Conversely, do not deep-copy an entire dataset to change one field in one record. Decide who owns the data and which part must be independent. Sometimes mutation is appropriate; sometimes returning a newly constructed result is clearer.

Avoiding unnecessary copies can lower memory pressure and runtime. Actual financial savings depend on whether that improvement changes a job’s duration, resource size, or billed usage. Keep correctness as the first requirement.

Your turn: update without changing the input

Write with_status(booking, status) that returns a new dictionary with an updated status field. The input contains only string and integer values. Verify that its original status remains unchanged.

Show one solution
def with_status(booking, status):
    updated = booking.copy()
    updated["status"] = status
    return updated


original = {"id": "B001", "status": "pending"}
confirmed = with_status(original, "confirmed")
print(original["status"])
print(confirmed["status"])
print(original is confirmed)

Expected output: pending, confirmed, False. This isolates the dictionary’s fields for the stated input. It does not promise independence for mutable objects nested inside those fields.

Checkpoint: draw two names pointing to one list, then two lists pointing to one dictionary. If you can explain both diagrams, the common “my copy changed too” bug becomes predictable.

Search