Python

Mutable vs Immutable Objects in Python

Master Python mutability: learn which data types are mutable vs immutable, how to test with id(), and optimize performance with efficient string building.

By InventiveHQ Team

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

PropertyImmutable objectsMutable objects
Built-in typesint, float, complex, bool, str, bytes, tuple, frozensetlist, dict, set, bytearray
Can change in place?No — every "change" makes a new objectYes — contents change, identity stays
id() after modificationChanges (new object)Stays the same
Hashable / usable as dict keyYes (unless it nests a mutable object)No — raises TypeError: unhashable type
Thread safetySafe to share; no mid-flight mutationNeeds locking under concurrency
Memory behaviorNew allocation per changeReused buffer; in-place growth
Aliasing riskLow — sharing is harmlessHigh — two names, one object, silent side effects
When to reach for itKeys, constants, config, thread-shared values, anything you want frozenAccumulators, 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.

How id() reveals mutability: reassignment creates a new object, in-place mutation does not Left panel: a string variable is reassigned and its pointer moves to a new object with a new id. Right panel: a list is appended to and the pointer stays on the same object with the same id.

Immutable: str Mutable: list

name s "hello" id: 4471 "how are you?" id: 9820 new object → new id() name lst ['a', 'b'] + 'c' + 'd' id: 5510 same object → same id()
Advertisement

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 goalReach forWhyAvoid
Dictionary key or set membertuple, str, int, frozensetMust be hashable and stablelist, dict, set (unhashable)
Accumulate items in a looplist then "".join()In-place growth is O(n); string += is O(n²)Repeated str concatenation
A value shared across threads read-onlytuple, frozenset, str, intNo mid-flight mutation, no lock neededShared list/dict without a lock
A constant or config that must never changetuple, frozensetImmutability documents and enforces intentlist/dict (silently mutable)
A default argumentNone sentinel, build inside the functionMutable defaults persist between callsdef f(x=[]) / def f(x={})
A fixed set of allowed valuesfrozensetHashable, fast membership, cannot driftset if it must stay constant
A growable, ordered collectionlistCheap append/pop, mutate in placetuple (rebuilds on every change)
A mutable byte buffer (I/O, encoding)bytearrayEdit bytes in placebytes (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.

Frequently Asked Questions

What is the difference between mutable and immutable objects in Python?

A mutable object can be changed in place after it is created without changing its identity — its id() stays the same. An immutable object cannot be changed; any operation that appears to modify it actually builds a brand-new object with a new id(). Lists, dicts, sets, and bytearrays are mutable. Ints, floats, bools, strings, bytes, tuples, and frozensets are immutable.

Is a tuple always immutable in Python?

The tuple itself is immutable — you cannot add, remove, or replace its elements. But a tuple can hold references to mutable objects, and those objects can still change. So (1, [2, 3]) is a "frozen" container whose inner list can be mutated. This is also why a tuple containing a list is unhashable and cannot be used as a dictionary key.

Why can immutable objects be used as dictionary keys but mutable ones cannot?

Dictionary keys and set members must be hashable, and Python requires that an object's hash never changes during its lifetime. Immutable objects satisfy this because their value — and therefore their hash — is fixed. Lists, dicts, and sets are mutable, so their hash could change out from under the dictionary, which would corrupt lookups. Python forbids this by making them unhashable.

How do I check if a Python object is mutable or immutable?

Use id() to read an object's identity before and after an operation. If an in-place operation such as list.append() leaves id() unchanged, the object is mutable. If every "change" produces a new id(), it is immutable. A quicker test is to try hash(obj) — most immutable built-ins are hashable and most mutable ones raise TypeError.

Why is repeated string concatenation slow in Python?

Strings are immutable, so s = s + "x" cannot extend the existing string. Python allocates a new string and copies all prior characters into it every time. In a loop of n concatenations that copying adds up to roughly O(n squared) work. Collecting parts in a list and calling "".join(parts) once is O(n) and dramatically faster for large inputs.

Does id() being equal mean two variables are the same object?

Yes — if two names return the same id(), they point to the same object in memory, so mutating through one name is visible through the other. This is the classic aliasing bug with lists. Note that CPython caches small integers (-5 to 256) and interns some short strings, so a is b can be True for those even when you did not intend to share; do not rely on that behavior for logic.

Are Python function arguments passed by value or by reference?

Python passes references by value ("pass by object reference"). If you mutate a mutable argument inside a function — appending to a list, updating a dict — the caller sees the change. If you rebind the parameter or pass an immutable object, the caller's variable is unaffected. This is why a mutable default argument like def f(x=[]) is a well-known trap.

What is the difference between a set and a frozenset?

A set is mutable — you can add and remove elements — so it is unhashable and cannot be a dictionary key or a member of another set. A frozenset is the immutable version: its contents are fixed after creation, it is hashable, and it can be used as a dictionary key or nested inside another set.

PythonData TypesMemory ManagementListsStringsPerformance
Advertisement