Immutable Objects — Unchanging by Design
Immutability Trade-offs
Read aloud is unavailable in this browser.
When Advantages Become Costs
Every design decision involves trade-offs. Immutability is no exception. While its benefits are substantial—thread safety, simplicity, and reliability—these benefits come with costs that must be weighed against the specific requirements of your system.
A mature engineer doesn't dogmatically apply patterns; they understand when patterns are beneficial and when they impose unnecessary burden. This page explores the genuine costs of immutability and provides guidance on when mutable designs might be the pragmatic choice.
By the end of this page, you will understand the performance implications of immutability, recognize scenarios where mutability is appropriate, learn techniques for mitigating immutability costs, and develop judgment for choosing between mutable and immutable designs.
Memory and Allocation Overhead
The most commonly cited cost of immutability is object creation overhead. Every "modification" creates a new object, which means more memory allocations, more garbage collection pressure, and potentially more memory usage.
The Naive View of the Problem:
Consider updating a single field in a large object:
123456789101112131415161718192021222324252627282930313233
from dataclasses import dataclass, replacefrom typing import Tupleimport sys # Large immutable object@dataclass(frozen=True)class LargeDocument: title: str content: str # Could be megabytes metadata: Tuple[str, ...] # Many items revision: int # Naive concern: Does updating revision copy everything?doc = LargeDocument( title="My Document", content="A" * 1_000_000, # 1 million characters metadata=tuple(f"tag_{i}" for i in range(1000)), revision=1) print(f"Original doc size: ~{sys.getsizeof(doc)} bytes (shallow)") # Update revision — does this copy 1MB of content?doc_v2 = replace(doc, revision=2) # The answer: NO! Python (and most languages) share immutable references.# The content string is NOT copied — doc_v2.content points to the same string. print(f"doc.content is doc_v2.content: {doc.content is doc_v2.content}") # True!print(f"doc.metadata is doc_v2.metadata: {doc.metadata is doc_v2.metadata}") # True! # Only a new LargeDocument object is created (small), holding the same references.# This is called "structural sharing" — unchanged parts are shared.Modern languages and runtimes share immutable data automatically. When you create a new immutable object, only the changed fields are new—unchanged fields share references with the original. This dramatically reduces actual memory overhead compared to naive expectations.
When Overhead Does Matter:
While structural sharing helps, there are scenarios where immutability overhead is genuinely significant:
- •Tight loops with many updates — Creating thousands of objects per second in an inner loop creates GC pressure; mutable updates are more efficient
- •Very high-frequency trading — Microsecond-level latency requirements can't tolerate allocation unpredictability
- •Embedded systems — Limited memory and no garbage collection may make immutability impractical
- •Large collection modifications — Adding to an immutable list of 1 million items is O(n) naively; specialized persistent data structures are needed
- •Game loops and real-time systems — Consistent frame timing requires predictable allocation patterns
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
import time # CASE: Tight loop with many updates — immutability hurts # Mutable approach (efficient)def mutable_accumulation(n: int) -> list: result = [] for i in range(n): result.append(i * 2) # O(1) amortized — mutates in place return result # Immutable approach (inefficient for this case!)def immutable_accumulation(n: int) -> tuple: result = () for i in range(n): result = result + (i * 2,) # O(n) — creates new tuple each time! return result # Timing comparisonn = 10000 start = time.time()mutable_accumulation(n)mutable_time = time.time() - start start = time.time()immutable_accumulation(n)immutable_time = time.time() - start print(f"Mutable: {mutable_time:.4f}s")print(f"Immutable: {immutable_time:.4f}s")print(f"Immutable is ~{immutable_time/mutable_time:.1f}x slower") # The immutable version is O(n²) overall, mutable is O(n).# For 10,000 items, immutable might be 100x slower. # ───────────────────────────────────────────────────── # SOLUTION: Build mutably, then freeze def efficient_immutable_accumulation(n: int) -> tuple: """Build with mutable list, convert to immutable tuple at end""" result = [] # Mutable during construction for i in range(n): result.append(i * 2) # O(1) amortized return tuple(result) # O(n) conversion, done once # This is the standard pattern:# - Use mutable structures during construction/computation# - Return immutable results# - Immutability at API boundaries, mutability inside algorithmsAPI Complexity and Boilerplate
Immutable classes require more API surface. Instead of simple setters, you need with* methods, builders, or copy functions. This increases code volume and can make simple operations feel verbose.
The Boilerplate Problem:
- •
user.name = "Alice" - •
user.age += 1 - •
user.address.city = "Boston" - •Direct and intuitive
- •
user = user.withName("Alice") - •
user = user.withAge(user.age + 1) - •
user = user.withCity("Boston") - •Returns value must be captured
Mitigation Strategies:
-
Use language features — Python's
dataclasses.replace(), Kotlin'scopy(), C# records, Java records reduce boilerplate significantly. -
Libraries and code generation — Lombok's
@Builder, Immutables.org, and similar tools generate the verbose code for you. -
Method chaining — Fluent APIs make multiple updates readable:
user = (user
.with_name("Alice")
.with_age(31)
.with_city("Boston"))
- Accept the trade-off consciously — The verbose syntax makes mutation explicit, which is actually a feature for code reviewers.
The "verbosity" of immutable updates makes state changes visible. user.name = 'Alice' hides that mutation is happening. user = user.withName('Alice') makes it obvious that we're creating a new state. In complex systems, this explicitness is valuable.
When Mutability Is Appropriate
Immutability is a powerful default, but it's not always the right choice. Several scenarios genuinely benefit from mutable designs:
1. Entities with Identity
Objects that represent real-world entities with identity—a specific user in a database, a particular order in an e-commerce system—often make sense as mutable. The entity is the same entity even as its attributes change.
2. Performance-Critical Inner Loops
When profiling shows that object allocation is a bottleneck in a hot path, mutable objects with in-place updates may be necessary. This is common in:
- Numerical computing
- Game engines
- High-frequency trading
- Real-time audio/video processing
3. Inherently Stateful Abstractions
Some concepts are fundamentally about state change:
- Caches (add/remove entries)
- Connections (open/close state)
- Cursors and iterators (current position)
- Builders during construction
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
# APPROPRIATE MUTABILITY: Entity with Identity class User: """ User is an ENTITY — it represents a specific user in the system. The user "John Smith" is the same user even after changing email. Identity persists through state changes. """ def __init__(self, user_id: str, name: str, email: str): self.user_id = user_id # Immutable identity self.name = name # Mutable attribute self.email = email # Mutable attribute def update_email(self, new_email: str): """In-place update is semantically correct — same user, new email""" self.email = new_email # Compare with VALUE OBJECT (should be immutable)from dataclasses import dataclass @dataclass(frozen=True)class EmailAddress: """ EmailAddress is a VALUE OBJECT — no identity. Two EmailAddress("a@b.c") are interchangeable. If you change the value, it's conceptually a different email. """ value: str @property def domain(self): return self.value.split("@")[1] # ───────────────────────────────────────────────────── # APPROPRIATE MUTABILITY: Performance-Critical Code import numpy as np def mutable_matrix_operations(): """ Numerical computing — mutability is standard and necessary. Creating new arrays for every operation would be impractical. """ # 1 million element array data = np.zeros(1_000_000) # In-place operations — efficient data += 1.0 # Add 1 to every element data *= 2.0 # Multiply every element by 2 np.sqrt(data, out=data) # In-place sqrt # Creating new arrays each time would use 3x more memory # and be significantly slower return data # ───────────────────────────────────────────────────── # APPROPRIATE MUTABILITY: Inherently Stateful Abstraction class ConnectionPool: """ Connection pool is inherently about managing changing state. Connections are acquired, used, and returned. Modeling this immutably would be awkward and inefficient. """ def __init__(self, max_connections: int): self._available = [] # Mutable pool of available connections self._in_use = set() # Mutable set of checked-out connections self._max = max_connections def acquire(self) -> 'Connection': """Get a connection from the pool — mutates internal state""" if self._available: conn = self._available.pop() else: conn = self._create_connection() self._in_use.add(conn) return conn def release(self, conn: 'Connection'): """Return a connection to the pool — mutates internal state""" self._in_use.discard(conn) self._available.append(conn) def _create_connection(self): if len(self._in_use) >= self._max: raise RuntimeError("Pool exhausted") return Connection() class Connection: passDefault to immutability, especially for Value Objects, DTOs, configuration, and data passed across boundaries. Switch to mutability only when you have a clear reason: performance requirements, entity semantics, or inherently stateful abstractions.
Hybrid Strategies
Real-world systems often benefit from hybrid approaches that combine immutability and mutability strategically. Here are proven patterns:
1. Immutable Public Interface, Mutable Internals
Expose immutable objects to external code while using mutable structures internally for efficiency:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
from dataclasses import dataclassfrom typing import List, Tuple # PATTERN 1: Immutable interface, mutable internals @dataclass(frozen=True)class ImmutableSearchResult: """Immutable result exposed to callers""" query: str results: Tuple[str, ...] total_count: int class SearchEngine: """ Internal implementation uses mutable structures for efficiency. Public interface deals only in immutable objects. """ def __init__(self): self._index = {} # Mutable internal state self._cache = {} # Mutable cache def search(self, query: str) -> ImmutableSearchResult: """Returns immutable result — internal mutability hidden""" # Mutable list for building results results: List[str] = [] # Complex search logic, mutating 'results'... for word in query.split(): if word in self._index: results.extend(self._index[word]) results = list(set(results))[:10] # Dedupe and limit # Convert to immutable before returning return ImmutableSearchResult( query=query, results=tuple(results), total_count=len(results) ) # ───────────────────────────────────────────────────── # PATTERN 2: Copy-on-write (COW) class CopyOnWriteList: """ Appears immutable to external holders. Mutates in place when only one reference exists. Copies when modification would affect other holders. """ def __init__(self, items=None): self._items = list(items) if items else [] self._ref_count = 1 def _copy_if_shared(self): """Make a private copy if others hold references""" if self._ref_count > 1: self._items = list(self._items) self._ref_count = 1 def append(self, item): self._copy_if_shared() self._items.append(item) def share(self) -> 'CopyOnWriteList': """Create a shared reference (COW semantics)""" self._ref_count += 1 return self def __iter__(self): return iter(self._items) # ───────────────────────────────────────────────────── # PATTERN 3: Event Sourcing — immutable events, mutable projections from datetime import datetimefrom typing import Tuple # Events are immutable — the history never changes@dataclass(frozen=True)class AccountCreated: account_id: str owner: str timestamp: datetime @dataclass(frozen=True)class MoneyDeposited: account_id: str amount: float timestamp: datetime @dataclass(frozen=True)class MoneyWithdrawn: account_id: str amount: float timestamp: datetime # Projection is mutable — rebuilt from immutable eventsclass AccountProjection: """ Mutable view derived from immutable event history. Can be rebuilt at any time by replaying events. """ def __init__(self): self.balance = 0.0 self.owner = "" def apply(self, event): if isinstance(event, AccountCreated): self.owner = event.owner elif isinstance(event, MoneyDeposited): self.balance += event.amount elif isinstance(event, MoneyWithdrawn): self.balance -= event.amount # Events (the source of truth) are immutableevents = ( AccountCreated("ACC-1", "Alice", datetime.now()), MoneyDeposited("ACC-1", 1000, datetime.now()), MoneyWithdrawn("ACC-1", 50, datetime.now()),) # Projection (the current view) is mutable but derivableprojection = AccountProjection()for event in events: projection.apply(event) print(f"Owner: {projection.owner}, Balance: {projection.balance}")| Pattern | Use When | Trade-off |
|---|---|---|
| Immutable interface, mutable internals | Performance-critical algorithms with stable API | Internal complexity hidden; testing internals harder |
| Copy-on-write (COW) | Many reads, few writes; sharing common cases | Complexity in managing reference counts |
| Event sourcing | Audit trail needed; temporal queries important | Storage overhead; rebuild time for projections |
| Immutable DTOs, mutable entities | Clean boundaries between layers | Conversion overhead at boundaries |
Decision Framework: Mutable vs. Immutable
When deciding between mutable and immutable designs, consider these questions systematically:
- •Is it a Value Object or an Entity? → Value Objects (no identity) should be immutable → Entities (with identity) may be mutable
- •Will it be shared across threads? → Yes → Strongly prefer immutability → No → Either is fine, choose based on semantics
- •Will it be used as a map key or in a set? → Yes → Must be immutable (or at least have stable hash)
- •Is it in a performance-critical hot path? → Yes → Profile first, then consider mutability if needed → No → Default to immutability
- •Does it cross API boundaries (public method params/returns)? → Yes → Prefer immutability for safety → No (internal implementation) → Can use mutability
- •Is the concept inherently about state change? → Yes (connections, pools, caches) → Mutability is natural → No → Prefer immutability
The Default Position:
Start with immutability. It's easier to make something mutable later if needed than to retrofit immutability into a mutable design. Teams that default to immutability report fewer bugs, easier testing, and more maintainable code.
In most codebases, 80%+ of classes should be immutable. The remaining 20%—entities, caches, pools, and hot-path optimizations—can use mutability judiciously. If you find yourself reaching for mutability frequently, reconsider your design.
Summary: Immutability Trade-offs
We've examined the genuine costs and appropriate exceptions to immutability. Let's consolidate the key insights:
- •Allocation overhead is often overestimated — Structural sharing means only changed parts are copied; most "overhead" is theoretical.
- •Real overhead exists in specific scenarios — Tight loops, embedded systems, high-frequency trading, and large collection updates may require mutability.
- •API verbosity is a feature, not a bug — Explicit functional updates make state changes visible in code review.
- •Entities with identity may be mutable — Objects representing specific real-world things can change while maintaining identity.
- •Hybrid strategies are powerful — Immutable interfaces with mutable internals, COW, and event sourcing combine the best of both.
- •Default to immutability — Most classes should be immutable; mutability should be used consciously for specific reasons.
Module Complete:
You've now mastered immutability—from its definition and benefits, through practical design techniques, to understanding when alternative approaches are appropriate. This knowledge equips you to make principled design decisions and build more robust, maintainable systems.
You've completed the Immutable Objects module. You understand what immutability means, why it matters, how to implement it, and when to consider alternatives. Apply this knowledge to make your code more predictable, thread-safe, and maintainable.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.