Python Immutability: What It Really Means and Why It Matters

When you assign a new value to a string variable in Python, the original string does not change. Python quietly creates an entirely new object in memory and points your variable at it. This behavior, called immutability, is one of the language's fundamental design decisions, and misunderstanding it is the source of countless bugs, performance surprises, and confused debugging sessions.

Immutability is not just an academic concept. It determines whether your objects can be used as dictionary keys, whether your functions produce unexpected side effects, and whether your code is safe to run across multiple threads. This article walks through what immutability actually means in Python, where it breaks down, and how to use it deliberately to write code that is more predictable and easier to maintain.

Mutable vs. Immutable: The Core Distinction

Every object in Python has three properties: an identity (its address in memory, returned by id()), a type, and a value. An object's identity and type never change after creation. The question of mutability comes down to whether the object's value can change.

Immutable types in Python include int, float, bool, str, bytes, tuple, and frozenset. Once created, their value cannot be modified. Mutable types include list, dict, set, and bytearray. Their contents can be changed in place without creating a new object.

The difference becomes visible when you check object identity before and after an operation:

# Immutable: integer
x = 10
print(id(x))    # e.g., 140234866789168

x += 1
print(id(x))    # e.g., 140234866789200  (different object!)

# Mutable: list
items = [1, 2, 3]
print(id(items))  # e.g., 140234861339456

items.append(4)
print(id(items))  # e.g., 140234861339456  (same object!)

When you perform x += 1 on an integer, Python does not modify the integer object 10 in place. It creates an entirely new integer object 11 and rebinds the variable x to point at it. The old object 10 still exists in memory (at least until garbage collection reclaims it). With the list, append() modifies the existing object directly. The identity stays the same.

Note

Variables in Python are not containers. They are names that reference objects. When you reassign a variable, you are changing which object the name points to, not changing the object itself. This is the single most important mental model for understanding immutability.

How Immutability Works Under the Hood

Strings are the classic example of immutability causing confusion. When you concatenate strings, Python does not append characters to the existing string. It allocates memory for a brand new string, copies the contents of both original strings into it, and returns the new object.

name = "Python"
print(id(name))       # 140234866102384

name = name + "3.14"
print(id(name))       # 140234866098736  (new object)

# The original "Python" string still exists until
# garbage collection removes it

This is why repeatedly concatenating strings inside a loop is inefficient. Each iteration creates a new string object. For building strings incrementally, str.join() or io.StringIO are significantly faster because they batch the operation:

# Slow: creates a new string on every iteration
result = ""
for word in ["Python", "is", "powerful"]:
    result += word + " "

# Fast: single allocation
words = ["Python", "is", "powerful"]
result = " ".join(words)

Python also uses an optimization called interning for small integers and certain strings. The integers from -5 to 256 are pre-allocated when the interpreter starts. Every variable that holds the value 42 points to the exact same object in memory. This works precisely because integers are immutable—there is no risk that changing the value through one variable would affect another.

a = 256
b = 256
print(a is b)  # True (same cached object)

a = 257
b = 257
print(a is b)  # False (different objects, outside cache range)
Pro Tip

Never use is to compare values. Use == instead. The is operator checks identity (whether two variables point to the same object), not equality. Interning makes is appear to work for small numbers, but it will fail unpredictably for larger values. The only common use for is is checking against None.

The Tuple Trap: Shallow vs. Deep Immutability

Tuples are immutable, but that guarantee is shallower than many people expect. A tuple cannot change which objects it contains, but if any of those objects are themselves mutable, their contents can still change. This is sometimes referred to as non-transitive immutability.

record = ([1, 2, 3], "metadata")

# This fails: you cannot replace the list with something else
# record[0] = [4, 5, 6]  # TypeError

# But this works: the list itself is mutable
record[0].append(4)
print(record)  # ([1, 2, 3, 4], 'metadata')

The tuple record holds a reference to a list and a string. The tuple's immutability prevents you from swapping those references. But the list object that the tuple points to is still a regular mutable list, and nothing about the tuple prevents you from calling .append() on it.

This has practical consequences. Tuples containing only immutable elements are hashable and can be used as dictionary keys or set members. Tuples containing mutable elements are not:

# Hashable: all elements are immutable
coordinates = {(0, 0): "origin", (10, 20): "point_a"}

# Not hashable: contains a mutable list
try:
    bad_key = {([1, 2], "data"): "value"}
except TypeError as e:
    print(e)  # unhashable type: 'list'
Common Pitfall

When you use a mutable default argument in a function, such as def add_item(item, items=[]), the default list is created once at function definition time and shared across all calls. Since the list is mutable, modifications persist between calls. Always use None as the default and create a new list inside the function body.

# Wrong: mutable default argument
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['a', 'b']  -- unexpected!

# Correct: use None as sentinel
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item("a"))  # ['a']
print(add_item("b"))  # ['b']  -- as expected

Building Your Own Immutable Objects

Python provides several tools for creating custom immutable data structures beyond the built-in types.

Named Tuples

The collections.namedtuple creates lightweight immutable classes with named fields. They behave exactly like tuples but allow attribute access by name:

from collections import namedtuple

Point = namedtuple("Point", ["x", "y"])
p = Point(3, 7)

print(p.x)      # 3
print(p[0])     # 3 (tuple indexing still works)

# p.x = 10     # AttributeError: can't set attribute

# Create a modified copy with _replace()
p2 = p._replace(x=10)
print(p2)       # Point(x=10, y=7)
print(p)        # Point(x=3, y=7)  -- original unchanged

Frozen Dataclasses

The @dataclass(frozen=True) decorator creates classes that raise a FrozenInstanceError if you attempt to modify any attribute after creation. This is one of the cleanest ways to build immutable value objects in Python:

from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    host: str
    port: int
    debug: bool = False

config = Config(host="localhost", port=8080)
print(config)  # Config(host='localhost', port=8080, debug=False)

# config.port = 9090  # FrozenInstanceError

# Frozen dataclasses are hashable, so they work as dict keys
cache = {config: "primary_server"}

For frozen dataclasses to be truly immutable, their fields need to use immutable types as well. If you put a list inside a frozen dataclass, the dataclass prevents you from replacing the field, but the list itself can still be modified:

from dataclasses import dataclass
from typing import Tuple

@dataclass(frozen=True)
class Team:
    name: str
    members: Tuple[str, ...]  # Use tuple, not list

team = Team(name="Backend", members=("Alice", "Bob", "Carol"))
# team.members = ("Dave",)  # FrozenInstanceError
# Tuple contents also can't be modified -- truly immutable

Frozensets

The frozenset is the immutable counterpart to set. It supports all the same read operations (union, intersection, difference) but cannot be modified after creation:

allowed = frozenset({"read", "write", "execute"})

# All set operations return new frozensets
restricted = allowed - {"write", "execute"}
print(restricted)  # frozenset({'read'})

# allowed.add("admin")  # AttributeError

# Frozensets are hashable
permission_map = {allowed: "full_access", restricted: "read_only"}

Type Hints with Final

Python's typing.Final annotation tells type checkers like mypy or pyright that a value should not be reassigned. It does not enforce immutability at runtime, but it provides a clear signal to developers and tooling:

from typing import Final

MAX_RETRIES: Final = 3
API_BASE_URL: Final = "https://api.example.com/v2"

# A type checker will flag this as an error:
# MAX_RETRIES = 5  # Cannot assign to final name "MAX_RETRIES"
Pro Tip

Combine Final annotations with frozen dataclasses and immutable collection types to create a layered defense. The type checker catches reassignment at development time, and the frozen dataclass raises errors at runtime. Together, they eliminate two different classes of accidental mutation.

Immutability and Concurrency

One of the strongest arguments for immutability is thread safety. When an object cannot be modified, there are no race conditions to worry about. Multiple threads can read the same immutable data simultaneously without locks, synchronization primitives, or defensive copying.

This matters more than ever in Python. With the release of Python 3.13's free-threaded experimental build and Python 3.14 making it officially supported (under PEP 703 and PEP 779), the language is moving toward true parallelism. In the free-threaded build, the Global Interpreter Lock (GIL) can be disabled, meaning multiple threads can genuinely execute Python bytecode simultaneously on different CPU cores.

In this world, mutable shared state becomes genuinely dangerous. Two threads modifying the same list at the same time can corrupt it. Immutable objects sidestep this problem entirely:

import threading

# Dangerous with free-threaded Python: shared mutable state
shared_list = []

def append_items():
    for i in range(1000):
        shared_list.append(i)  # Race condition!

# Safe: immutable data shared across threads
from dataclasses import dataclass

@dataclass(frozen=True)
class AppConfig:
    db_url: str
    pool_size: int
    read_only: bool

# Created once, safely shared everywhere
config = AppConfig(
    db_url="postgresql://localhost/mydb",
    pool_size=10,
    read_only=True
)

def worker(cfg: AppConfig):
    # No risk: cfg cannot be modified
    print(f"Connecting to {cfg.db_url}")

The Future: PEP 683, PEP 795, and Beyond

Python's relationship with immutability is evolving at the interpreter level, not just in application code.

PEP 683: Immortal Objects (Python 3.12+)

Every Python object carries internal runtime state, including a reference count that tracks how many variables point to it. Even objects that are "immutable" from a programmer's perspective (like None, True, and small integers) have their reference counts modified constantly as they are used throughout a program. This internal mutation has real costs: it invalidates CPU caches, causes unnecessary memory writes, and triggers copy-on-write pages in forked processes.

PEP 683, authored by Eric Snow and Eddie Elizondo and implemented in Python 3.12, introduced "immortal objects." These are objects whose reference count is fixed at a special sentinel value that the runtime recognizes. When the interpreter encounters an immortal object, it skips the reference count increment and decrement entirely. The object lives for the entire duration of the runtime and is never garbage collected.

Meta (formerly Facebook) developed this feature for Instagram's Python-based backend, where they use a pre-fork server architecture. Before immortal objects, forked worker processes would trigger copy-on-write on shared memory pages simply because reference counts were being updated. With immortal objects, shared memory actually stays shared, significantly reducing private memory usage across worker processes.

PEP 795: Deep Immutability (Proposed)

PEP 795, introduced at the Python Language Summit in May 2025 and revamped in December 2025, proposes a much more ambitious feature: the ability to "freeze" any Python object graph, making it and everything it references deeply immutable. The proposal includes a freeze() function that recursively renders an object and all reachable objects immutable, and an isfrozen() function for runtime checks.

The primary motivation is efficient data sharing between subinterpreters. Currently, data sent between subinterpreters must be copied. If an object could be frozen and guaranteed immutable, it could be shared by reference instead, avoiding the copy entirely. This would also unlock optimization opportunities around caching, JIT compilation, and garbage collection.

PEP 795 has not been submitted to the Python Steering Council as of early 2026 and faces significant technical challenges around determining exactly what gets frozen (including an object's class, methods, and potentially entire modules). The community has shown strong interest in the concept, though the current proposal is still being refined.

Immutability is not something you can bolt on later. It is one of the core design decisions of a language. — Community discussion on PEP 795

Key Takeaways

  1. Immutable objects cannot be changed after creation. Operations that appear to modify them (like string concatenation or integer arithmetic) actually create entirely new objects in memory. Python's built-in immutable types include int, float, bool, str, bytes, tuple, and frozenset.
  2. Tuple immutability is shallow, not deep. A tuple prevents you from swapping the objects it contains, but it does not prevent those objects from being modified internally. For true immutability, all nested elements must also be immutable types.
  3. Frozen dataclasses are the cleanest way to build custom immutable objects. Combine @dataclass(frozen=True) with immutable field types like tuple and frozenset for objects that are both hashable and safe to share across threads.
  4. Immutability matters for concurrency. With Python's free-threaded builds becoming officially supported, immutable data structures eliminate race conditions and the need for locking when sharing data between threads.
  5. Python's internals are getting more immutable too. PEP 683's immortal objects (shipped in Python 3.12) and the proposed PEP 795 deep immutability feature reflect the growing importance of immutability for performance, memory efficiency, and safe concurrency in the Python ecosystem.

Immutability is not about restriction. It is about making guarantees. When you know an object will never change, you can cache it, hash it, share it across threads, and reason about it with confidence. Start by making your value objects and configuration data immutable. As you get comfortable with the pattern, you will find that it simplifies debugging, eliminates entire categories of bugs, and prepares your code for a future where Python runs on all your cores.

back to articles