Mutable vs immutable objects
Overview
An object is mutable if its contents can change after it is created, and immutable if they cannot. Think of a mutable object as a whiteboard you keep editing, and an immutable object as a printed page — to "change" it you must print a new page. Here are the built-in mutable types: - `list`: Calling `list.append(x)` appends an element to the list in place and returns `None`. - `dict`: Calling `dict.update(other)` modifies the dictionary in place and returns `None`. It raises a `TypeError` if the argument is not an iterable of key-value pairs. - `set`: Calling `set.add(x)` modifies the set in place and returns `None`. It raises a `TypeError` if the added element is unhashable. Here are the built-in immutable types: - `int`, `float`, and `bool`: These primitive values are strictly immutable. - `str`: Calling string methods like `s.upper()` returns a new `str` object, leaving the original untouched. - `tuple`: A tuple's structure cannot be changed after creation. Because mutable objects are edited in place, they cannot be hashed. This means a `list` cannot be used as a `dict` key, but an immutable type like a `str` or `tuple` can. t = (1, [2, 3]) t[1].append(4) print(t) # (1, [2, 3, 4]) This is a sharp edge called shallow immutability. While the `tuple` itself cannot be resized or have its elements reassigned, a mutable object inside the `tuple` can still be modified in place. This trap is covered fully in shallow-vs-deep-copy. def add_item(item, cart=[]): cart.append(item) return cart print(add_item("apple")) # ['apple'] print(add_item("banana")) # ['apple', 'banana'] This is another sharp edge called the mutable default argument trap. The default `list` is created only once when the function is defined, causing it to silently accumulate state across calls. This trap is covered fully in mutable-default-argument.
Mutability decides whether an operation edits an object in place or returns a new one. Getting this wrong causes two classic bugs. First, assuming a `str` method changed the string (it didn't). Second, two variable names sharing one mutable object. When this happens, an in-place edit through one variable is unexpectedly seen through the other.
Where used: Pydantic models, config dictionaries, API request bodies
Why learn this
- Avoiding shared-state bugs when the same `list` or `dict` is referenced from two places in an API handler
- Knowing why a `tuple` or `str` can be a `dict` key but a `list` cannot
Code walkthrough
nums = [1, 2]
nums.append(3)
print(nums)
text = 'hi'
text.upper()
print(text)
Focus: The `list` prints `[1, 2, 3]` (changed in place); the `str` still prints `'hi'` because `.upper()` built a new string we ignored.
Aha moment
cart = ['laptop']
backup = cart
backup.append('mouse')
print(cart)
Prediction: `backup` looks like a safe copy of `cart`. After appending `'mouse'` to `backup`, what does `print(cart)` show?
Common guess: ['laptop']
`backup = cart` binds a second label to the same `list` object — it does not copy the list. So `backup.append('mouse')` mutates the one `list` both names point to, and `cart` sees it too. For an independent copy, use `cart.copy()`.
Common mistakes
- Expecting a string to change in place: Calling `s.upper()` returns a new `str` object and leaves `s` unchanged. Calling it without reassigning the result does nothing. Fix: reassign the variable, for example `s = s.upper()`.
- Sharing one mutable object by accident: After `b = a` where `a` is a `list` or `dict`, `a` and `b` reference the exact same object. This means `a.append(x)` is visible through `b`. Fix: copy the object using `a.copy()` when you need an independent version.
Glossary
- in place
- Modifying the original object directly, rather than creating a brand-new copy of it. Example: `nums.append(4)`.
- shared-state
- When two or more parts of a program access and modify the exact same data in memory, often leading to unexpected bugs. Example: modifying a shared `list` via `b.append(1)`.
Recall questions
- What distinguishes a mutable object from an immutable one?
- When you 'change' a string, what actually happens?
- Why can a string or tuple be used as a dict key but a list cannot?
- What happens if you modify a mutable object inside an immutable tuple?
- What happens if you use a mutable object like a list as a default argument in a function?
Understanding checks
Why does `print(y)` output `[1, 2, 3, 4]` instead of `[1, 2, 3]`?
Because `y = x` binds the variable `y` to the same underlying `list` object as `x`. The `+=` operator on a `list` mutates the object in place, so `y` reflects the change.
Lists are mutable objects. When multiple variables reference the same mutable object, any in-place mutation to that object through one variable will be visible through all other variables referencing it.
What is the output of this code?
`hello`
Strings are immutable, so `a += ' world'` does not modify the original string `'hello'`. Instead, it creates a new `str` object `'hello world'` and reassigns the variable `a` to point to it. The variable `b` still references the original `'hello'` string.
Practice tasks
Reassign string to see uppercase
The function below attempts to return an uppercase version of the input string, but it returns the original lowercase text instead. Modify the code to correctly return the uppercase version. Remember that `str` methods return new strings rather than modifying them in place.
Challenge
Merge config without mutating
Write `merge_config(base, override)` that returns a NEW `dict` combining both (`override` wins) WITHOUT mutating `base`. Then print the result and `base` to prove `base` is unchanged.
Continue learning
Previous: Primitive types: int, float, bool, str
Next: Shallow vs Deep Copy