AlgoMaster Logo

Immutability

Medium Priority21 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

An immutable value is one that can't change after it's created. If you want a different value, you build a new one and leave the original alone. This sounds like a small detail, but it's one of the foundations of functional programming: pure functions stay pure when their inputs can't be mutated, shared data stays safe across threads when nobody can modify it, and reasoning about a program gets a lot easier when "the value of x" never depends on when you look at it. This chapter covers why immutability matters, which Python types are immutable out of the box, how to make your own classes immutable, what persistent data structures bring to the table, and what the trade-offs are in real Python code.

Why Immutability Matters

A mutable value can be changed by anyone who holds a reference to it. That sounds harmless, but it's the source of a lot of bug-hunting in production code. Once a list, dict, or custom object is shared, any code path that touches it can change the state for every other code path.

Three concrete benefits come from making values immutable:

Referential transparency. This is the formal name for the property that the value of an expression doesn't depend on when or where you evaluate it. If cart.total is a method on a mutable cart, calling it twice in a row might give different answers, because something between the two calls might have added an item. If cart is immutable, cart.total is the same answer every time. Referentially transparent code is easier to test, easier to cache, and easier to refactor.

No action at a distance. When a function takes a mutable list and modifies it, the caller's list changes too, often unexpectedly. Immutable values can't be modified by called code, so the caller's value stays stable. Bugs caused by a function mutating its inputs can't happen.

Safe sharing across threads. Two threads reading the same immutable value never need to coordinate; nobody's writing. Two threads sharing a mutable value need locks, atomics, or careful queue-based coordination. The relevant fact here is that immutability removes most of the sharing problem.

A small e-commerce example shows the difference. The mutable version:

The original cart was mutated. The caller still has a reference to the same list, and that list now holds discounted prices. Any code elsewhere holding a reference to original_cart and expecting the original prices would get incorrect data.

The immutable version returns a new cart instead:

Same discounted result, but the original cart is untouched. Any caller holding the original reference still has the original prices.

The cost of the immutable version is one extra list of dicts allocated. In return, the function is safe to call from anywhere, in any order, without worrying about what other code is doing with the inputs.

Returning new values instead of mutating doubles the memory pressure for that operation (the old value plus the new one). For small data, this is invisible. For million-element collections, it can matter; the persistent-data-structures section below covers what to do then.

Python's Built-in Immutable Types

Python has a handful of immutable types built in. They're the foundation of any immutability-based design, and the type itself enforces the rule.

TypeMutable?Hashable?Notes
int, float, complexNoYesArithmetic returns new numbers
boolNoYesTrue and False are singletons
strNoYesAll string methods return new strings
bytesNoYesLike str but for raw bytes
tupleNoSometimesImmutable, but contents can be mutable
frozensetNoYesThe immutable cousin of set
rangeNoYesImmutable lazy integer sequence
NoneNoYesA singleton

The "sometimes" on tuple deserves a note. The tuple itself is immutable: you can't add, remove, or reassign elements. But if one of the elements is mutable (a list, say), that element can still be changed in place. The container is frozen; the contents may not be.

The tuple holds a reference to the list. The reference can't be replaced, but the list it points at can still be modified. Immutability in Python is about the type of the container plus the types of its contents, not just the outermost type.

The same rule means a tuple with a mutable element isn't hashable, because hashing requires that the value (transitively) never changes. A tuple of fully immutable values is hashable; a tuple containing a list is not.

The numeric hash is some int; the exact value isn't important. The second hash fails because the list inside the tuple makes the whole tuple unhashable. Hashability is the practical test for "is this value truly immutable, including its contents?"

frozenset is the immutable counterpart of set. It supports all the read-only set operations (membership, union, intersection) but no methods that would change the set.

categories | {"office"} returns a new frozenset; the original is untouched. Calling .add on a frozenset raises AttributeError because the method doesn't exist. The type enforces the rule at the Python level.

Strings work the same way: every operation that looks like it modifies a string actually returns a new one. s.upper() doesn't change s; it returns a new string. The old s is still around (and unchanged) until nothing references it.

Two separate string objects. is returns False because they're different objects in memory. Anything that looks like in-place modification of a string is an illusion; Python rebinds the name to a new object.

String concatenation in a loop allocates a new string per +=, which is O(n) per step and O(n²) overall. Use "".join(parts) or io.StringIO for that pattern.

Mutable Types to Watch

Python's three main containers are mutable: list, dict, and set. So are most custom classes. Anything that can be changed in place is something a function can change without telling the caller, so these types take extra care when writing in an immutable style.

add_item mutates the list and also returns it. Both names point at the same modified list. The caller had no way to know add_item would mutate the input without reading the source.

The functional alternative is to return a new list and leave the input alone:

cart + [item] builds a new list with one extra element. cart_a is untouched; cart_b is the new one. The function doesn't modify its inputs; it returns a result.

The same pattern works for dicts. The {**old, "key": value} syntax produces a new dict with one entry added or replaced:

Two separate dicts. The function never mutated its input. Any caller holding the original reference still sees status: placed.

Sets work the same way. set_a | {x} returns a new set with x added. set_a - {x} returns a new set without x. The originals are untouched.

The cost of these patterns is allocation: every modification copies the container. For small containers, the cost is invisible. For containers with millions of elements, copying every time is real overhead, which the persistent data structure section addresses.

Making Your Own Classes Immutable

Custom classes are mutable by default. You can write obj.attr = something to any object, which means anyone who holds a reference can change it. Three patterns build immutable classes in Python, each with its own trade-offs.

@dataclass(frozen=True)

The simplest path is dataclass with frozen=True. The decorator generates __init__, __repr__, __eq__, and a __setattr__ that raises an error on any attempt to modify an attribute after construction.

The class produces well-named, comparable, frozen objects without any boilerplate. The FrozenInstanceError is raised by the generated __setattr__; any attempt to modify an attribute hits it.

To modify a frozen dataclass, build a new one with the changed fields. dataclasses.replace does this in one line:

replace copies the original and applies the overrides. The original is untouched; the new instance has the new price. This is the functional update pattern in dataclass form.

Frozen dataclasses are also hashable by default (assuming their fields are hashable), which means they work as dict keys or set members.

Two Product instances with the same fields compare equal and hash the same, so looking up the second one finds the first one's entry. Without frozen=True, dataclasses are mutable and not hashable by default.

NamedTuple

Before dataclasses, typing.NamedTuple was the standard immutable record type. It's still useful, especially when tuple-like behavior (indexing, iteration, unpacking) is wanted on top of named fields.

Each instance acts like both a tuple and an object with named fields. The class is immutable, hashable, and slightly faster to construct than a frozen dataclass because it sits directly on top of the built-in tuple.

To modify, use the _replace method, which works like dataclasses.replace:

When to pick NamedTuple over a frozen dataclass: when positional access is wanted, when the object will be unpacked, or when the surrounding code already uses tuples heavily. When to pick a frozen dataclass: when named-only access is wanted, when methods or class variables might be needed, or when the more flexible dataclass machinery (post-init, default factories, custom equality) is useful.

__slots__ Plus No Setters

For finer control or higher performance, you can write a class by hand with __slots__ and no setter methods. __slots__ prevents adding new attributes; not exposing setters prevents changing the existing ones. The class isn't enforced as frozen the way a frozen dataclass is, but the only path to set the attributes is through __init__, which makes it effectively immutable.

__slots__ blocks adding new attributes; the class only knows about name, price, and in_stock. To fully freeze the existing ones, override __setattr__ to raise after __init__ completes. That's more work than @dataclass(frozen=True), which handles it for you, but the manual approach gives more control over edge cases.

For most code, prefer @dataclass(frozen=True). It's shorter, well-tested, and well-known. Hand-rolled immutability with __slots__ is for situations where the extra performance matters (slotted classes use less memory than dict-backed ones) or where dataclasses don't quite fit.

The diagram is the rule of thumb. Start with @dataclass(frozen=True). For positional access or tuple unpacking, use NamedTuple. Drop to __slots__ only for a specific reason.

Persistent Data Structures

Returning a new list or dict on every change works fine for small collections, but the copy cost grows linearly. For data structures with thousands or millions of elements, copying the whole thing on every update is too expensive.

Persistent data structures solve this. They're immutable, but they share structure between versions, so an update is fast (logarithmic instead of linear) even on large collections. When a new version is created, only the changed parts are stored fresh; the rest is shared with the old version.

Python doesn't have these in the standard library. Two notable third-party libraries provide them:

  • `pyrsistent` is the most mature: persistent vectors (PVector), maps (PMap), and sets (PSet), plus typed record classes (PRecord) similar to dataclasses.
  • `immutables` is smaller and focused on a single persistent map (Map). It's the library used internally by contextvars in CPython.

A small example with pyrsistent:

cart.append("HDMI Cable") doesn't mutate cart; it returns a new PVector. Internally, the two vectors share most of their underlying tree, so the operation is O(log n) instead of the O(n) of a list copy. Same idea for PMap: set returns a new map with one key updated, sharing the rest.

When to use a persistent library: when a long-lived data structure is updated frequently and immutability is wanted without paying the copy cost on every update. Examples include game state, redo/undo histories, configurations that get layered, and snapshot-based pipelines. For most application code, regular Python collections plus careful copying are simpler and the cost is invisible.

The trade-off is dependencies and unfamiliarity. Persistent data structures aren't part of the standard library, so adding them is a real choice. The API is slightly different from the built-ins (methods are called to modify), which means code that uses them looks different from typical Python. For libraries written in the FP style, this is fine; for code that mixes styles, it can be jarring.

pyrsistent operations are O(log n) on the structure size. For small collections this can be slower than the built-ins (constants matter); for large collections this is much faster than copying. Benchmark on actual data before deciding.

The Cost of Immutability

Immutability isn't free. The costs are real, and pretending otherwise leads to either premature optimization or premature pessimization. Three costs matter:

Allocation. Every update allocates. For built-in collections, that's a full copy. For persistent structures, it's a small allocation per change but still nonzero. Code that updates immutable values in a tight loop allocates a lot.

Garbage-collection pressure. Allocating a lot means freeing a lot. Python's reference counting is fast, but cycles still get visited by the garbage collector, and a workload that produces thousands of short-lived objects per second puts pressure on the GC.

Convenience. Some operations are awkward without mutation. Building up a result with a loop is more natural with list.append than with new_list = old_list + [item] (which is also O(n) per step). The functional alternative is to use comprehensions, which are clean and fast but require the data to be expressible that way.

When the cost matters and when it doesn't:

Doesn't matter: small to medium data, function inputs and outputs (returning a new dict from a function), domain objects (a Product, an Order), config objects, anything updated occasionally.

Matters: hot loops over millions of items, real-time pipelines, code where the same collection is modified in place thousands of times per second.

For the cases where it doesn't matter, default to immutability. The clarity and safety wins outweigh the allocation cost. For the cases where it does matter, use mutable structures locally (inside one function, where all the changes are visible), then return an immutable snapshot at the boundary.

The function uses a mutable list internally because that's the natural shape for building up a result. At the boundary, it freezes the result as a tuple before returning. The caller has an immutable value to hold and pass around, but the internals didn't pay the cost of building a new list per append.

This local-mutable, global-immutable pattern is the practical sweet spot in Python. Pure immutability everywhere is too slow for many real cases; full mutability everywhere is too dangerous; mutable inside functions plus immutable at the API boundary gives both speed and safety.

Best Practices

A short list of habits that pay off in real code:

Prefer immutable function arguments. Take a tuple instead of a list when the function only needs to read. Take a frozenset instead of a set. The signature documents the intent: "I won't change this." If the function later starts mutating, the type signature has to change too, which is a useful checkpoint.

Return new values instead of mutating. When a function's purpose is to produce a transformed version of an input, return the new version and leave the input alone. The caller can choose whether to bind the result to the same name or to a new one.

Use frozen dataclasses for domain objects. Carts, orders, customers, products, addresses: these benefit from immutability because their identity is what they are, not where they live in memory. Frozen dataclasses are short to write and provide hashability without extra code.

Freeze at the boundary. Inside a function, mutate freely when it's natural. Before returning, convert to an immutable type (tuple, frozen dataclass, namedtuple). The internals stay fast; the API stays safe.

Use persistent libraries when copy costs hurt. When a profile shows that copying large containers is a measurable cost, pyrsistent or immutables fits. Don't add the dependency for theoretical wins; add it for measured ones.

Don't fight the language. Python is not Haskell. Some things (object identity, caches, accumulators) are easier with controlled mutation than with pure-functional reconstruction. Use immutability where it fits, and don't force it where it doesn't.

Nested immutable structures with functional updates. replace handles each layer; the original Customer and its Address are untouched. Two slightly different customers exist now, each independent. This is the pattern that scales to real domain models without introducing mutation bugs.

Quiz

Immutability Quiz

10 quizzes