Python Structures and Mutability

Functions and Mutability

Some Python objects can change after creation. Others cannot.

This distinction becomes especially important when objects are passed to functions.

Data type Mutable?
List Yes
Dictionary Yes
Set Yes
Integer No
Float No
Boolean No
String No
Tuple No

A function can modify a mutable object received as an argument. The caller will see that change.

Review: Changing a List

The append() method changes a list in place.

my_list = [1, 2, 3]

print(my_list)
[1, 2, 3]
my_list.append(4)

print(my_list)
[1, 2, 3, 4]

Many methods that mutate a list return None.

my_list = [1, 2, 3]
result = my_list.append(4)

print(result)
None
print(my_list)
[1, 2, 3, 4]

The assignment below is therefore a common mistake:

my_list = [1, 2, 3]
my_list = my_list.append(4)

print(my_list)
None

append() changes the original list and returns None. The assignment then replaces the variable with None.

Warning

Not every method mutates its object. For example, string.upper() returns a new string because strings are immutable. Check what a method returns before assigning its result.

Mutation and New Objects

Compare a mutating list method with list concatenation.

Mutation with append()

original = [1, 2, 3]
new_list = original.append(4)

print(original)
[1, 2, 3, 4]
print(new_list)
None

original changes, while new_list receives None.

A New Object with +

original = [1, 2, 3]
new_list = original + [4]

print(original)
[1, 2, 3]
print(new_list)
[1, 2, 3, 4]

The + operation creates a new list. The original list remains unchanged.

Exercise: Predict the output

Without running the code, determine the value printed by each cell.

playlist = ["Intro", "Night Drive"]
result = playlist.append("Home")
print(playlist)
print(result)

The first cell prints:

['Intro', 'Night Drive', 'Home']

The second cell prints:

None

Functions Can Mutate Their Arguments

When a function receives a mutable object, its parameter refers to the same object used by the caller.

def add_item(items: list, item) -> None:
    items.append(item)
shopping_list = ["bread", "coffee"]

print(shopping_list)
['bread', 'coffee']
returned_value = add_item(shopping_list, "apples")

print(returned_value)
None
print(shopping_list)
['bread', 'coffee', 'apples']

The function returns None, but shopping_list still changes.

Sets

def add_attendee(attendees: set, name: str) -> None:
    attendees.add(name)
event_attendees = {"Maya", "Leo"}
add_attendee(event_attendees, "Nora")

print(event_attendees)
{'Leo', 'Nora', 'Maya'}

Dictionaries

def update_status(record: dict, status: str) -> None:
    record["status"] = status
project = {"name": "Website", "status": "planned"}
update_status(project, "active")

print(project)
{'name': 'Website', 'status': 'active'}
Exercise: What changes?

Predict the values of guest and returned_value after the function call.

def add_preference(profile: dict, preference: str) -> None:
    profile["preference"] = preference


guest = {"name": "Ava"}
returned_value = add_preference(guest, "window seat")
print(guest)
print(returned_value)

guest contains both "name" and "preference". returned_value is None because the function has no return statement.

Rebinding Is Not Mutation

Assigning a new object to a parameter only changes the local parameter. It does not replace the caller’s object.

def replace_list(items: list) -> None:
    items = ["replacement"]
original = ["first", "second"]
replace_list(original)

print(original)
['first', 'second']

The function rebinds its local variable items. It does not mutate original.

Compare that with slice assignment, which changes the existing list:

def replace_contents(items: list) -> None:
    items[:] = ["replacement"]
original = ["first", "second"]
replace_contents(original)

print(original)
['replacement']

Protecting Mutable Objects

If a function should leave its input unchanged, create a copy before modifying it.

def add_item_safely(items: list, item) -> list:
    copied_items = items.copy()
    copied_items.append(item)
    return copied_items
shopping_list = ["bread", "coffee"]
updated_list = add_item_safely(shopping_list, "apples")

print(shopping_list)
['bread', 'coffee']
print(updated_list)
['bread', 'coffee', 'apples']

You can copy dictionaries and sets in the same way.

def change_city(profile: dict, city: str) -> dict:
    copied_profile = profile.copy()
    copied_profile["city"] = city
    return copied_profile
profile = {"name": "Maya", "city": "Madrid"}
updated_profile = change_city(profile, "Lisbon")

print(profile)
{'name': 'Maya', 'city': 'Madrid'}
print(updated_profile)
{'name': 'Maya', 'city': 'Lisbon'}
Exercise: Protect the original set

Complete add_tag_safely() so it returns an updated set without changing the original set.

def add_tag_safely(tags: set, new_tag: str) -> set:
    # Complete the function
def add_tag_safely(tags: set, new_tag: str) -> set:
    copied_tags = tags.copy()
    copied_tags.add(new_tag)
    return copied_tags

Shallow Copies

The .copy() method creates a shallow copy. The outer object is new, but nested mutable objects are still shared.

original = [["Monday"], ["Tuesday"]]
copied = original.copy()
copied[0].append("Python")

print(copied)
[['Monday', 'Python'], ['Tuesday']]
print(original)
[['Monday', 'Python'], ['Tuesday']]

Both outputs include "Python" because the inner lists are shared.

Use deepcopy() when nested mutable objects must also be copied.

from copy import deepcopy

original = [["Monday"], ["Tuesday"]]
copied = deepcopy(original)
copied[0].append("Python")

print(copied)
[['Monday', 'Python'], ['Tuesday']]
print(original)
[['Monday'], ['Tuesday']]
Exercise: Shallow or independent?

Will original contain "dessert" after this code runs? Explain your answer.

original = {
    "starter": ["soup"],
    "main": ["pasta"],
}

copied = original.copy()
copied["main"].append("dessert")

Yes. The dictionaries are different objects, but both refer to the same nested list stored under "main". Appending to that list affects both dictionaries.

Mutable Default Parameters: The Problem

Default parameter values are created once, when Python defines the function. A mutable default is therefore shared by later calls.

def add_to_list(value, stored_values: list = []) -> list:
    stored_values.append(value)
    return stored_values
first_result = add_to_list(1)

print(first_result)
[1]
second_result = add_to_list(2)

print(second_result)
[1, 2]
separate_result = add_to_list(3, stored_values=[])

print(separate_result)
[3]
fourth_result = add_to_list(4)

print(fourth_result)
[1, 2, 4]

The first, second, and fourth calls use the same default list. The third call receives a separate empty list.

The same problem affects dictionary and set defaults.

def remember_tag(tag: str, tags: set = set()) -> set:
    tags.add(tag)
    return tags
print(remember_tag("python"))
{'python'}
print(remember_tag("data"))
{'python', 'data'}
Warning

Avoid mutable default values such as [], {}, and set().

Exercise: Shared dictionary

How many keys does animal_sounds contain at the end? Explain why.

def add_entry(key, value, records: dict = {}) -> dict:
    records[key] = value
    return records


clothes = add_entry("shirt", "blue")
clothes = add_entry("pants", "green", clothes)

animal_sounds = add_entry("dog", "woof")
animal_sounds = add_entry("cat", "meow", animal_sounds)
print(animal_sounds)

animal_sounds contains four keys: "shirt", "pants", "dog", and "cat".

The first and third calls use the same default dictionary. The second and fourth calls receive that same dictionary explicitly.

Mutable Default Parameters: The Solution

Use None as the default. Create a new mutable object inside the function when needed.

def add_to_list(value, stored_values: list = None) -> list:
    if stored_values is None:
        stored_values = []

    stored_values.append(value)
    return stored_values

Each call without a list now creates a separate list.

print(add_to_list(1))
[1]
print(add_to_list(2))
[2]
print(add_to_list(3))
[3]

You can still provide an existing list when you want the function to update it.

saved_values = [10]
result = add_to_list(20, saved_values)

print(result)
[10, 20]
print(saved_values)
[10, 20]
Exercise: Fix the function

Rewrite add_entry() so calls without a dictionary do not share data.

def add_entry(key, value, records: dict = {}) -> dict:
    records[key] = value
    return records
def add_entry(key, value, records: dict = None) -> dict:
    if records is None:
        records = {}

    records[key] = value
    return records

Challenge: Notification Preferences

Write a function named enable_notification() with these requirements:

  1. It receives a notification name.
  2. It accepts an optional set of existing preferences.
  3. If no set is supplied, it creates a new one.
  4. It returns a set containing the new notification.
  5. Separate calls without a set must not share data.
def enable_notification(name: str, preferences: set = None) -> set:
    if preferences is None:
        preferences = set()

    preferences.add(name)
    return preferences

Summary

  • Lists, dictionaries, and sets are mutable.
  • Integers, floats, booleans, strings, and tuples are immutable.
  • A function can mutate a mutable object received from its caller.
  • Rebinding a parameter does not replace the caller’s object.
  • Copy an object when a function should leave its input unchanged.
  • .copy() is shallow, so nested mutable objects remain shared.
  • Mutable default parameters persist across calls.
  • Use None as the default and create the mutable object inside the function.

Homework and Review

When you feel ready, finish the Module 2 homework exercises available here. To earn participation credit, complete the exercises highlighted in red.

Homework