Immutable Objects — Unchanging by Design
What Is Immutability
Read aloud is unavailable in this browser.
The Unchanging Promise
Imagine you hand someone a document. With a mutable document, they could alter it—crossing out words, adding new paragraphs, changing numbers. Every time you reference that document afterward, you can never be certain it still says what you originally wrote. Now imagine handing them a photocopy—sealed, unchangeable, permanent. No matter what they do with their copy, your original remains pristine. This is the essence of immutability.
In object-oriented programming, an immutable object is one whose state cannot be modified after creation. Once an immutable object is constructed, its internal data remains fixed for its entire lifetime. This seemingly simple constraint has profound implications for how we design, debug, and reason about software systems.
By the end of this page, you will understand the precise definition of immutability, distinguish between true immutability and shallow immutability, recognize immutable objects in familiar code, and appreciate why immutability has become a cornerstone principle in modern software architecture.
Defining Immutability
At its core, immutability is a guarantee: once an object is created, its observable state never changes. This definition, while simple, requires careful unpacking to understand fully.
Observable state refers to the values that can be accessed through the object's public interface. An immutable object provides no methods, properties, or mechanisms that could alter what callers see when they query the object.
The formal definition:
An object is immutable if, after construction, no operation—whether internal or external—can change the values returned by its methods or properties.
This is a strict contract. It's not enough for an object to appear unchanged most of the time. A truly immutable object provides a mathematical guarantee: given the same object reference, all queries will return identical results, forever.
Think of an immutable object as a snapshot—a frozen moment in time. Just as a photograph captures a scene exactly as it was, an immutable object captures data exactly as it was at construction. The photograph doesn't change if the actual scene changes later; similarly, the immutable object doesn't change even if the data it was based on changes elsewhere.
Mutable vs. Immutable — A Comparison:
Let's contrast these two approaches with a concrete example. Consider a simple Point class representing a coordinate in 2D space.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
# MUTABLE Point - State can change after creationclass MutablePoint: def __init__(self, x: float, y: float): self.x = x # Public, modifiable attribute self.y = y # Public, modifiable attribute def move(self, dx: float, dy: float): """Modifies the point in place""" self.x += dx self.y += dy # Usage — The same object changes over timep = MutablePoint(0, 0)print(f"Initial: ({p.x}, {p.y})") # (0, 0)p.move(5, 3)print(f"After move: ({p.x}, {p.y})") # (5, 3) — Same object, different state! # Problem: If you passed 'p' to another function earlier,# that function's reference now points to different data. # ───────────────────────────────────────────────────── # IMMUTABLE Point - State is fixed at creationclass ImmutablePoint: def __init__(self, x: float, y: float): self._x = x # Private, set once self._y = y # Private, set once @property def x(self) -> float: return self._x # Read-only access @property def y(self) -> float: return self._y # Read-only access def move(self, dx: float, dy: float) -> 'ImmutablePoint': """Returns a NEW point; original unchanged""" return ImmutablePoint(self._x + dx, self._y + dy) def __repr__(self): return f"ImmutablePoint({self._x}, {self._y})" # Usage — Each operation creates a new objectp1 = ImmutablePoint(0, 0)p2 = p1.move(5, 3) # p1 unchanged, p2 is new print(f"p1: ({p1.x}, {p1.y})") # (0, 0) — Still original values!print(f"p2: ({p2.x}, {p2.y})") # (5, 3) — New object with new values # Any reference to p1 is guaranteed to always see (0, 0)The key insight: with ImmutablePoint, the move method doesn't modify the existing point—it creates and returns a new point representing the moved position. The original point remains frozen at its initial coordinates forever. This pattern is sometimes called functional update or copy-on-write semantics.
Deep vs. Shallow Immutability
One of the most critical—and commonly misunderstood—aspects of immutability is the distinction between shallow immutability and deep immutability. Getting this wrong can introduce subtle, devastating bugs.
Shallow Immutability:
An object is shallowly immutable if its direct fields cannot be changed, but those fields may reference mutable objects that can be changed. The outer object appears immutable, but mutation can leak through its references.
Shallow immutability is a common trap. Developers believe they've created an immutable object, but external code can still mutate the internal state through shared references. This breaks the immutability contract and eliminates its benefits.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
# SHALLOW IMMUTABILITY — The dangerous illusionclass ShallowlyImmutablePerson: """ BROKEN: This class LOOKS immutable but isn't! The 'address' field references a mutable object. """ def __init__(self, name: str, address: list): self._name = name self._address = address # Danger: stores reference, not copy! @property def name(self) -> str: return self._name @property def address(self) -> list: return self._address # Danger: exposes mutable reference! # The illusion of immutability...address_list = ["123 Main St", "Apt 4"]person = ShallowlyImmutablePerson("Alice", address_list) print(f"Original: {person.address}") # ['123 Main St', 'Apt 4'] # External mutation through original reference — BREAKS "immutability"address_list.append("New York")print(f"After external mutation: {person.address}") # ['123 Main St', 'Apt 4', 'New York'] # Mutation through returned reference — ALSO breaks "immutability"person.address.clear()print(f"After direct mutation: {person.address}") # [] — Completely emptied! # The "immutable" object's state has been changed TWICE! # ───────────────────────────────────────────────────── # DEEP IMMUTABILITY — True protectionfrom typing import Tuple class DeeplyImmutablePerson: """ CORRECT: True immutability with defensive copying and immutable internal structures. """ def __init__(self, name: str, address: tuple): self._name = name # Store as tuple (immutable) — no external mutation possible self._address = tuple(address) if not isinstance(address, tuple) else address @property def name(self) -> str: return self._name @property def address(self) -> Tuple[str, ...]: return self._address # Tuples are immutable — safe to share def with_address(self, new_address: tuple) -> 'DeeplyImmutablePerson': """Functional update: returns new person with new address""" return DeeplyImmutablePerson(self._name, new_address) # True immutability in actionaddress_tuple = ("123 Main St", "Apt 4")person = DeeplyImmutablePerson("Alice", address_tuple) print(f"Original: {person.address}") # ('123 Main St', 'Apt 4') # Can't mutate tuple — this would raise an error:# person.address.append("New York") # AttributeError: 'tuple' has no attribute 'append' # Original reference mutation has no effect# (tuple is already copied/converted during construction)address_tuple = ("Different",) # Rebinding doesn't affect personprint(f"After rebinding: {person.address}") # ('123 Main St', 'Apt 4') — Unchanged! # To change address, must create new personperson2 = person.with_address(("456 Oak Ave",))print(f"Person 1: {person.address}") # ('123 Main St', 'Apt 4') — Original intactprint(f"Person 2: {person2.address}") # ('456 Oak Ave',) — New personThe Immutability Contract:
For true (deep) immutability, every component of the object's state must also be immutable, recursively. This leads to a fundamental principle:
Deep Immutability Principle: An object is deeply immutable if and only if:
- All its fields are immutable (primitive values or references to immutable objects)
- It never exposes direct references to internal mutable state
- All collections it contains are immutable or defensively copied
| Aspect | Shallow Immutability | Deep Immutability |
|---|---|---|
| Direct fields | Cannot be reassigned | Cannot be reassigned |
| Referenced objects | Can be mutated externally | Also immutable (recursively) |
| Thread safety | Not guaranteed | Fully guaranteed |
| Equality stability | Can change over time | Permanent and reliable |
| Defensive copying | Not done (or incomplete) | On both input and output |
| Bug surface area | Large (hidden mutation) | Minimal (true snapshot) |
Recognizing Immutability in Practice
Immutability isn't an exotic concept—it's all around us in everyday programming. Many of the most commonly used types in modern languages are immutable by design. Understanding these examples builds intuition for when and how to apply immutability in your own designs.
Built-in Immutable Types:
| Language | Immutable Types | Notes |
|---|---|---|
| Java | String, Integer, Long, Double, BigDecimal, LocalDate, LocalDateTime | Java 8+ date/time API is fully immutable |
| Python | str, int, float, tuple, frozenset, bytes | Contrast with mutable list, dict, set |
| JavaScript/TS | string, number, boolean, Symbol, BigInt | All primitives; objects require Object.freeze() |
| C# | string, DateTime, TimeSpan, value types (structs) | Records with init properties in C# 9+ |
| Scala | String, Int, case classes (default) | Immutability is the default paradigm |
| Kotlin | String, Int, data classes with val | val = immutable, var = mutable |
The String Example — Immutability in Action:
Strings are arguably the most familiar immutable type. When you "modify" a string, you're actually creating a new string:
1234567891011121314151617181920212223242526
# String immutability in Pythonoriginal = "Hello"modified = original + " World" # Key insight: 'original' is UNCHANGEDprint(f"original: '{original}'") # 'Hello'print(f"modified: '{modified}'") # 'Hello World' # They are different objects in memoryprint(f"Same object? {original is modified}") # Falseprint(f"original id: {id(original)}")print(f"modified id: {id(modified)}") # Different! # You CANNOT mutate a string character# original[0] = "J" # TypeError: 'str' object does not support item assignment # Even methods that "change" the string return new stringsgreeting = " hello world "trimmed = greeting.strip()upper = greeting.upper() print(f"original: '{greeting}'") # ' hello world 'print(f"trimmed: '{trimmed}'") # 'hello world'print(f"upper: '{upper}'") # ' HELLO WORLD ' # The original 'greeting' string is untouched by any operationStrings were made immutable in most languages for critical reasons: security (strings often hold passwords, URLs, file paths that shouldn't change unexpectedly), thread safety (strings can be safely shared across threads), string interning (identical strings can share memory), and hash stability (strings are commonly used as map keys, which requires stable hash codes).
The Value Object Pattern
Immutability is intimately connected to a fundamental design pattern called Value Objects. Understanding this connection illuminates when immutability is most appropriate.
What is a Value Object?
A Value Object is an object that represents a descriptive aspect of the domain with no conceptual identity. Two Value Objects are considered equal if all their attributes are equal—it doesn't matter which instance you have, only what values it holds.
Contrast with Entity Objects:
-
Entity: Has identity. Two
Userobjects with the same name are different if they have different IDs. Changing a user's name doesn't create a new user—it updates the existing one. -
Value Object: Has no identity. Two
Money(100, USD)objects are interchangeable. There's no concept of "the same hundred dollars"—any hundred dollars is as good as another.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
from dataclasses import dataclassfrom decimal import Decimalfrom typing import Self # VALUE OBJECT: Money# - No identity (any $100 is as good as another)# - Defined entirely by its attributes# - Should be IMMUTABLE @dataclass(frozen=True) # frozen=True makes it immutableclass Money: amount: Decimal currency: str def add(self, other: 'Money') -> 'Money': if self.currency != other.currency: raise ValueError(f"Cannot add {self.currency} to {other.currency}") return Money(self.amount + other.amount, self.currency) def multiply(self, factor: Decimal) -> 'Money': return Money(self.amount * factor, self.currency) def __str__(self) -> str: return f"{self.currency} {self.amount:.2f}" # Usage — Value semanticsprice1 = Money(Decimal("100.00"), "USD")price2 = Money(Decimal("100.00"), "USD") # Value equality: same attributes = equalprint(f"price1 == price2: {price1 == price2}") # True # Immutable operations return new objectstotal = price1.add(Money(Decimal("50.00"), "USD"))print(f"price1: {price1}") # USD 100.00 (unchanged)print(f"total: {total}") # USD 150.00 (new object) # ───────────────────────────────────────────────────── # Other common Value Objects: @dataclass(frozen=True)class DateRange: """Immutable representation of a date range""" start: str # ISO date string for simplicity end: str def contains(self, date: str) -> bool: return self.start <= date <= self.end def extend_by_days(self, days: int) -> 'DateRange': # Returns NEW DateRange, original unchanged from datetime import datetime, timedelta end_date = datetime.fromisoformat(self.end) new_end = (end_date + timedelta(days=days)).isoformat()[:10] return DateRange(self.start, new_end) @dataclass(frozen=True)class EmailAddress: """Immutable, validated email address""" value: str def __post_init__(self): if "@" not in self.value or "." not in self.value: raise ValueError(f"Invalid email: {self.value}") @property def domain(self) -> str: return self.value.split("@")[1] @dataclass(frozen=True)class Coordinate: """Immutable geographic coordinate""" latitude: float longitude: float def distance_to(self, other: 'Coordinate') -> float: # Simplified distance calculation import math return math.sqrt( (self.latitude - other.latitude) ** 2 + (self.longitude - other.longitude) ** 2 )- •Money — Amount and currency; arithmetic operations return new Money
- •Date/Time ranges — Start and end; extensions return new ranges
- •Addresses — Street, city, postal code; validation at construction
- •Email addresses — Validated string wrapper with domain operations
- •Measurements — Value and unit (10 kg, 5 meters); unit-aware math
- •Colors — RGB/HSL values; color operations return new colors
- •Geographic coordinates — Lat/long; distance calculations
- •Version numbers — Major.minor.patch; comparison operations
Value Objects should ALWAYS be immutable. Since they have no identity, "modifying" a Value Object conceptually creates a different value. Making them mutable is not just unnecessary—it's actively harmful, leading to bugs where shared values unexpectedly change.
Immutability and Temporal Reasoning
One of the most powerful benefits of immutability is how it simplifies temporal reasoning—understanding how your program's state evolves over time.
The Mutable State Problem:
With mutable objects, understanding a program requires tracking not just what an object is, but when you're looking at it. The same variable can have different values at different points in execution, and determining when a mutation occurred becomes a debugging nightmare.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# THE MUTABLE STATE NIGHTMARE# This is a simplified but realistic debugging scenario class MutableOrder: def __init__(self, items: list, total: float): self.items = items self.total = total self.status = "pending" def apply_discount(order: MutableOrder, discount: float): """Mutates order in place""" order.total = order.total * (1 - discount) def add_item(order: MutableOrder, item: str, price: float): """Mutates order in place""" order.items.append(item) order.total += price def process_order(order: MutableOrder): """Complex processing that may mutate order""" if order.total > 100: apply_discount(order, 0.1) # 10% discount order.status = "processed" # Debugging session — The nightmare beginsorder = MutableOrder(["Widget"], 50.0)print(f"A: {order.total}") # 50.0 # Some code path calls this...add_item(order, "Gadget", 75.0)print(f"B: {order.total}") # 125.0 # Then this gets called...process_order(order)print(f"C: {order.total}") # 112.5 (10% discount applied!) # Bug report: "Customer was charged 112.5 but expected 125.0"# Questions you must now answer:# 1. When exactly was the discount applied?# 2. What was the order state when the discount decision was made?# 3. Which code path led to this? (there could be dozens)# 4. Is there a race condition if this is multithreaded? # The same 'order' object had THREE different total values# at different points in time. Debugging requires reconstructing# the entire execution history.The Immutable Alternative:
With immutable objects, temporal reasoning becomes trivial. Each "version" of the data is a separate object, and you can inspect any version at any time without worrying about it changing.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
# THE IMMUTABLE CLARITY# Same scenario, but with immutable design from dataclasses import dataclassfrom typing import Tuple @dataclass(frozen=True)class ImmutableOrder: items: Tuple[str, ...] # Immutable tuple total: float status: str = "pending" def with_discount(self, discount: float) -> 'ImmutableOrder': return ImmutableOrder( items=self.items, total=self.total * (1 - discount), status=self.status ) def with_item(self, item: str, price: float) -> 'ImmutableOrder': return ImmutableOrder( items=self.items + (item,), total=self.total + price, status=self.status ) def processed(self) -> 'ImmutableOrder': return ImmutableOrder( items=self.items, total=self.total, status="processed" ) def apply_discount_if_eligible(order: ImmutableOrder) -> ImmutableOrder: """Returns new order with discount if eligible""" if order.total > 100: return order.with_discount(0.1) return order # Clear execution with full history preservedorder_v1 = ImmutableOrder(items=("Widget",), total=50.0)print(f"V1: {order_v1.total}") # 50.0 order_v2 = order_v1.with_item("Gadget", 75.0)print(f"V2: {order_v2.total}") # 125.0 order_v3 = apply_discount_if_eligible(order_v2)print(f"V3: {order_v3.total}") # 112.5 order_v4 = order_v3.processed()print(f"V4 status: {order_v4.status}") # "processed" # Debugging is TRIVIAL:# - order_v1, v2, v3, v4 are ALL still available# - Each represents the exact state at that point# - You can compare any two versions directly# - No need to reconstruct execution history print(f"Full audit trail:")print(f" v1 → v2: added Gadget (+75.0)")print(f" v2 → v3: discount applied (-12.5)")print(f" v3 → v4: status changed to processed") # You can even keep the entire history:history = [order_v1, order_v2, order_v3, order_v4]for i, version in enumerate(history): print(f" Version {i+1}: total={version.total}, status={version.status}")With immutable objects: Variables don't change; they get replaced. This means looking at any variable at any point gives you the complete, permanent truth about that value. There's no hidden history to reconstruct, no race conditions to worry about, and debugging becomes a matter of comparing snapshots rather than reconstructing timelines.
Summary: What Is Immutability
We've established the foundational understanding of immutability. Let's consolidate the key concepts:
- •Immutability means state cannot change — Once created, an immutable object's observable state remains fixed forever.
- •Deep vs. shallow matters critically — True immutability requires that all nested references are also immutable; shallow immutability is a dangerous illusion.
- •Familiar types are often immutable — Strings, numbers, and date/time types in most languages are immutable by design, demonstrating the pattern's practicality.
- •Value Objects should always be immutable — Objects defined by their attributes (not identity) naturally map to immutable designs.
- •Immutability simplifies temporal reasoning — Each variable represents a permanent snapshot, eliminating the mental overhead of tracking state changes over time.
- •Modification creates new objects — The functional update pattern (returning new instances) replaces in-place mutation while preserving the original.
What's Next:
Now that you understand what immutability is, the next page explores why it matters so deeply. We'll examine the concrete benefits of immutability—thread safety, simplicity, predictability—and see how these advantages compound in real systems to dramatically reduce bugs and complexity.
You now understand the definition and mechanics of immutability. This foundation prepares you to appreciate the profound benefits that immutability provides—benefits that have made it a cornerstone of modern software architecture.
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.