Immutable or mutable is a fundamental distinction in Python that determines whether an object’s state (its value or contents) can be changed after creation. Immutable objects (int, float, bool, str, tuple, frozenset, bytes, range, etc.) cannot be modified — any “change” creates a new object and rebinds the name. Mutable objects (list, dict, set, bytearray, custom classes without __hash__ restriction, most user-defined objects) can be modified in-place, affecting all references to the same object. In 2026, understanding mutability is critical — it explains many subtle bugs (mutable defaults, shared state in pipelines, unexpected changes in data frames), impacts hashability (dict keys, set members), performance (copy vs view), thread safety, and functional programming patterns. Mastering this distinction helps write predictable, safe, and efficient Python code.
Here’s a complete, practical guide to immutable vs mutable objects in Python: core types, behavior differences, common pitfalls (mutable defaults, shared references), real-world patterns, and modern best practices with type hints, defensive copying, immutability tools (dataclasses frozen, attrs frozen, Polars), and pandas/Polars integration.
Immutable types — cannot be changed after creation; operations return new objects.
x = 42
y = x
x += 1 # creates new int object
print(x, y) # 43 42 (y still points to old object)
s = "hello"
s += " world" # creates new str
print(s) # hello world
t = (1, 2, 3)
t += (4,) # creates new tuple
print(t) # (1, 2, 3, 4)
Mutable types — can be modified in-place; changes affect all references to the same object.
lst = [1, 2, 3]
ref = lst
lst.append(4) # modifies the same list object
print(lst, ref) # [1, 2, 3, 4] [1, 2, 3, 4]
d = {"a": 1}
ref_d = d
d["b"] = 2 # modifies the same dict
print(d, ref_d) # {'a': 1, 'b': 2} {'a': 1, 'b': 2}
Classic pitfalls — mutable defaults (evaluated once at definition), shared references, accidental mutation in functions.
def bad_append(item, lst=[]): # Dangerous: lst created once
lst.append(item)
return lst
print(bad_append(1)) # [1]
print(bad_append(2)) # [1, 2] ? shared!
# Fix: sentinel pattern
def safe_append(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
print(safe_append(1)) # [1]
print(safe_append(2)) # [2]
Real-world pattern: safe data handling in pandas/Polars — avoid unintended mutation of DataFrames passed to functions.
import pandas as pd
def add_column(df: pd.DataFrame, col_name: str, values) -> pd.DataFrame:
df = df.copy() # defensive copy — protects original
df[col_name] = values
return df
data = pd.DataFrame({'A': [1, 2, 3]})
new_data = add_column(data, 'B', [10, 20, 30])
print(data) # unchanged: only 'A'
print(new_data) # has 'A' and 'B'
Best practices make mutability safe, predictable, and performant. Prefer immutable objects when possible — int, str, tuple, frozenset, frozen dataclasses — changes create new objects, no side effects. Use defensive copying for mutable arguments — lst.copy(), dict.copy(), df.copy(). Modern tip: use Polars for immutable-by-default DataFrames — most operations return new frames, naturally safe and DRY. Add type hints — def func(df: pd.DataFrame) -> pd.DataFrame — signals intent and helps mypy catch misuse. Avoid mutable defaults — use None sentinel pattern. Use frozen dataclasses or attrs with frozen=True — immutable objects with nice syntax. Prefer functional style — avoid mutation when possible (map, filter, reduce, list comprehensions). Test for side effects — assert original objects unchanged after function calls. Use context managers (with) for resource cleanup — DRY setup/teardown. Combine with copy.deepcopy() only when necessary — expensive; prefer shallow copy or immutable alternatives.
Mutable objects change in-place and affect all references; immutable objects get rebound on “change.” In 2026, prefer immutability, defensive copying, type hints, Polars for safe dataframes, and sentinel defaults. Master pass by assignment and mutability, and you’ll write predictable, bug-resistant code that avoids classic Python gotchas.
Next time you pass a list or DataFrame to a function — remember pass by assignment. It’s Python’s cleanest way to say: “Here’s a reference — mutate carefully.”