Python objects split into two groups: mutable objects (list, dict, set, bytearray) can be changed in place after creation while keeping the same identity, and immutable objects (int, float, bool, str, bytes, tuple, frozenset) cannot be changed at all — any operation that looks like a modification actually creates a brand-new object at a new memory address. The practical test is id(): if an operation leaves the object's id() unchanged, it was mutated in place; if the id() changes, a new object was created. This single distinction drives dictionary-key rules, function-argument surprises, thread safety, and the O(n²) string-concatenation trap.
That's the summary an AI Overview will give you. Here's what it can't show you: the exact side-by-side of every type, a decision matrix for which to reach for, an animated view of what id() is actually doing under the hood, and the tuple-of-a-list edge case that breaks the tidy "tuples are immutable" rule. Let's dig in.
Mutable vs immutable at a glance
| Property | Immutable objects | Mutable objects |
|---|---|---|
| Built-in types | int, float, complex, bool, str, bytes, tuple, frozenset | list, dict, set, bytearray |
| Can change in place? | No — every "change" makes a new object | Yes — contents change, identity stays |
id() after modification | Changes (new object) | Stays the same |
| Hashable / usable as dict key | Yes (unless it nests a mutable object) | No — raises TypeError: unhashable type |
| Thread safety | Safe to share; no mid-flight mutation | Needs locking under concurrency |
| Memory behavior | New allocation per change | Reused buffer; in-place growth |
| Aliasing risk | Low — sharing is harmless | High — two names, one object, silent side effects |
| When to reach for it | Keys, constants, config, thread-shared values, anything you want frozen | Accumulators, buffers, caches, anything you mutate in a loop |
Which Objects in Python are Immutable?
Immutable objects cannot be modified after creation. Any operation that appears to change an immutable object actually creates a new object in memory. The following Python data types are immutable:
-
bool – Boolean values (True, False)
-
integer – Whole numbers
-
float – Decimal numbers
-
tuple – Ordered collections that cannot be changed
-
string – Text sequences
-
frozenset – Immutable version of sets
💡 Key Insight: Immutable objects provide memory safety and can be used as dictionary keys or set elements because their hash values never change.
Which Objects in Python are Mutable?
Mutable objects can be modified in place without creating new objects in memory. This makes them more memory-efficient for operations that involve frequent changes. The primary mutable data types in Python include:
-
list – Ordered collections that can be modified
-
set – Unordered collections of unique elements
-
dictionary – Key-value pairs that can be updated
These objects maintain their identity in memory even when their contents change, making them ideal for scenarios where you need to frequently add, remove, or modify elements.
The tuple-of-a-list gotcha
"Tuples are immutable" is true but incomplete. A tuple freezes its slots — you cannot add, remove, or reassign elements — but if a slot holds a mutable object, that object can still change:
t = (1, [2, 3])
t[1].append(4) # allowed — the list inside is still mutable
print(t) # (1, [2, 3, 4])
# t[1] = [9] # TypeError — you cannot reassign the slot
hash(t) # TypeError: unhashable type: 'list'
Because one of its members is mutable, this tuple is not hashable and cannot be used as a dictionary key or set element — even though the tuple wrapper itself is immutable. The rule that actually matters for keys is not "is it a tuple" but "is it hashable all the way down."
How to Test Object Mutability
Python provides the id() function to determine whether an object is mutable or immutable. This function returns the unique memory address of an object, allowing you to track whether operations create new objects or modify existing ones.
The diagram below shows the core difference. On the left, reassigning a string leaves the old object behind and points the name at a fresh object (new id()). On the right, append() grows the same list object in place — the name never moves, and the id() never changes.
Testing Immutable Objects (Strings)
Let's examine how string operations demonstrate immutability:
string1 = "hello"
print(f"Initial ID: {id(string1)}")
print(f"Type: {type(string1)}")
string1 = "how are you?"
print(f"New ID: {id(string1)}")
print(f"Type: {type(string1)}")
When you run this code, you'll notice that the id() values are different. This demonstrates that the variable string1 is actually a pointer to an object. When we assign a new string value, Python creates a new object and redirects the pointer, rather than modifying the original string object.
Testing Mutable Objects (Lists)
Now let's observe how list operations demonstrate mutability:
list1 = ['orange', 'apple', 'pear']
print(f"Initial ID: {id(list1)}")
print(f"Type: {type(list1)}")
# Modify the list in place
list1.append('grape')
print(f"After append ID: {id(list1)}")
print(f"List contents: {list1}")
# Reassigning creates a new object
list1 = ['orange', 'apple', 'pear', 'strawberry']
print(f"After reassignment ID: {id(list1)}")
The key observation here is that the id() remains the same after using append(), proving that the list object itself was modified rather than replaced. However, reassigning the entire list creates a new object with a different ID.
💡 One caveat when reading id(): CPython caches small integers (-5 to 256) and interns some short strings, so two separate literals can share an id() even when you did not intend it. That is an implementation optimization, not a language guarantee — use id() and is to reason about identity and mutation, never to compare values. For equality, always use ==.
Performance Impact and Memory Efficiency
Understanding mutability is crucial for writing efficient Python applications. The choice between mutable and immutable objects can significantly impact memory usage and execution speed, especially in applications that perform many data modifications.
String Concatenation: A Performance Anti-Pattern
Consider this common but inefficient approach to string building:
string1 = "hello"
print(f"Initial ID: {id(string1)}")
string1 = string1 + " how are you?"
print(f"After concatenation ID: {id(string1)}")
print(f"Final string: {string1}")
Each concatenation operation creates a new string object, copying all existing content plus the new content. In applications performing thousands of such operations, this becomes a significant performance bottleneck.
⚠️ Performance Warning: Repeated string concatenation in loops can cause O(n²) time complexity due to constant memory reallocation and copying.
Efficient String Building with Lists
A more efficient approach uses mutable lists for collecting string components:
fruit = []
print(f"Initial list ID: {id(fruit)}")
fruit.append('apple')
print(f"After first append: {id(fruit)}")
fruit.append('pear')
print(f"After second append: {id(fruit)}")
fruit.append('orange')
print(f"After third append: {id(fruit)}")
# Convert to string only once
result_string = " ".join(fruit)
print(f"Final string: {result_string}")
This approach maintains the same list object throughout all append operations, only creating the final string when needed. This reduces memory allocations from O(n²) to O(n), resulting in significant performance improvements for large-scale operations.
Python's Dynamic Typing vs Static Languages
Python's approach to mutability differs significantly from statically typed languages like C++. Understanding these differences helps developers leverage Python's flexibility while avoiding common pitfalls.
Static Typing Constraints
In C++, variables are strongly typed and immutable by design:
// C++ example - strongly typed
int myinteger = 5;
// This would cause a compilation error:
// string myinteger = "Hello!"; // Cannot redeclare with different type
Python's Dynamic Flexibility
Python allows variable reassignment with different types:
myint = 5
print(f"Type: {type(myint)}") # <class 'int'>
myint = "Hello!"
print(f"Type: {type(myint)}") # <class 'str'>
💡 Best Practice: While Python allows type changes, maintaining consistent variable types throughout your code improves readability and reduces debugging complexity.
Decision matrix: which should I reach for?
Use this to pick the right kind of object for the job instead of defaulting to whatever you typed first.
| Your goal | Reach for | Why | Avoid |
|---|---|---|---|
| Dictionary key or set member | tuple, str, int, frozenset | Must be hashable and stable | list, dict, set (unhashable) |
| Accumulate items in a loop | list then "".join() | In-place growth is O(n); string += is O(n²) | Repeated str concatenation |
| A value shared across threads read-only | tuple, frozenset, str, int | No mid-flight mutation, no lock needed | Shared list/dict without a lock |
| A constant or config that must never change | tuple, frozenset | Immutability documents and enforces intent | list/dict (silently mutable) |
| A default argument | None sentinel, build inside the function | Mutable defaults persist between calls | def f(x=[]) / def f(x={}) |
| A fixed set of allowed values | frozenset | Hashable, fast membership, cannot drift | set if it must stay constant |
| A growable, ordered collection | list | Cheap append/pop, mutate in place | tuple (rebuilds on every change) |
| A mutable byte buffer (I/O, encoding) | bytearray | Edit bytes in place | bytes (immutable, copies) |
⚠️ The mutable-default trap: def add(item, bucket=[]): shares one list across every call that omits bucket, so results leak between invocations. Use bucket=None and bucket = bucket or [] inside the body instead. This bug is a direct consequence of the mutable/immutable split.
Why Understanding Mutability Matters
Mastering the concepts of mutable and immutable objects enables you to make informed decisions about data structure selection, memory optimization, and algorithm design. This knowledge becomes particularly valuable when developing applications that handle large datasets or require high-performance processing.
Performance Optimization: Choose mutable objects for frequent modifications to minimize memory allocation overhead and improve execution speed.
Memory Safety: Leverage immutable objects when you need guaranteed data integrity and thread-safe operations in concurrent applications.
By understanding which Python objects fit into each category and how they behave during operations, you can design applications that are both efficient and maintainable. This knowledge helps you avoid common performance pitfalls while taking advantage of Python's flexibility and power.