Mutable vs immutable objects

RoadmapsPython

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

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

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

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

Next: Mutable default argument pitfall

Return to Python Roadmap