Value Objects in Python — Core Concepts
What value objects are
A value object is an object defined entirely by its attributes rather than by a unique identity. Two value objects with the same attributes are considered equal. They are immutable — once created, they do not change.
This contrasts with entities, which have a unique identity. Two customers named “Alice” at the same address might be different people (different IDs). But two Money(100, "USD") objects are always the same thing.
The three properties
1. Equality by value
address_a = Address("123 Main St", "Springfield", "IL")
address_b = Address("123 Main St", "Springfield", "IL")
assert address_a == address_b # True — same values
This is different from the default Python behavior where two objects are equal only if they are the exact same instance in memory.
2. Immutability
Value objects cannot be modified after creation. If you need a different value, you create a new object.
price = Money(Decimal("19.99"), "USD")
# price.amount = Decimal("29.99") ← This should fail
new_price = price.with_amount(Decimal("29.99")) # Create a new object instead
Immutability prevents accidental side effects. If you pass a Money object to a function, you know the function cannot change your copy.
3. Self-validation
Value objects validate their data at creation time. An invalid value object should never exist.
Money(Decimal("-5"), "USD") # Should raise ValueError
EmailAddress("not-an-email") # Should raise ValueError
Implementing value objects in Python
Frozen dataclasses (recommended)
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self):
if self.amount < 0:
raise ValueError("Amount cannot be negative")
if len(self.currency) != 3:
raise ValueError("Currency must be 3-letter ISO code")
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)
The frozen=True parameter makes the dataclass immutable and automatically generates __eq__ and __hash__ based on all fields.
NamedTuple
from typing import NamedTuple
class Coordinates(NamedTuple):
latitude: float
longitude: float
Named tuples are inherently immutable and support equality comparison. They are simpler than dataclasses but lack custom validation in __post_init__.
Value objects vs plain data
You might wonder: why not just use a plain dictionary or tuple? Value objects add:
- Type safety —
MoneyandWeightare different types even if both hold a number - Validation — Invalid states are impossible
- Behavior — Methods like
add()andmultiply()that know the domain rules - Documentation — The class name and fields communicate intent
Common value objects in Python projects
| Value Object | Fields | Why not a primitive? |
|---|---|---|
| Money | amount, currency | Prevents mixing currencies |
| EmailAddress | address | Validates format at creation |
| DateRange | start, end | Ensures end > start |
| Percentage | value | Ensures 0-100 range |
| PhoneNumber | country_code, number | Validates format |
| GeoCoordinate | lat, lon | Validates ranges |
Common misconception
“Value objects are just DTOs (Data Transfer Objects).” DTOs carry data between layers without behavior. Value objects carry data with behavior and invariants. A Money value object knows how to add amounts and prevent currency mixing. A DTO just shuttles fields.
When to use value objects
Use them whenever you find yourself:
- Passing around related fields together (amount + currency, latitude + longitude)
- Validating the same rules in multiple places
- Using primitive types that could be confused (is this
floata price, a weight, or a distance?)
The overhead is minimal in Python. A frozen dataclass is one decorator and a few lines.
The one thing to remember: Value objects replace scattered primitives with self-validating, immutable types that make your domain concepts explicit and your code resistant to a whole class of bugs.
See Also
- Python Aggregate Pattern Why grouping related objects under a single gatekeeper prevents data chaos in your Python application.
- Python Bounded Contexts Why the same word means different things in different parts of your code — and why that is perfectly fine.
- Python Bulkhead Pattern Why smart Python apps put walls between their parts — like a ship that stays afloat even with a hole in the hull.
- Python Circuit Breaker Pattern How a circuit breaker saves your app from crashing — explained with a home electrical fuse analogy.
- Python Clean Architecture Why your Python app should look like an onion — and how that saves you from painful rewrites.