Skip to content
System nominalUTC --:--:--Articles 041
MODULE 04 Lesson 11 / 18 30 MIN

Objects and errors

Dataclasses and Value Objects

What @dataclass generates, why a mutable default needs default_factory, and how frozen, order and __post_init__ turn a class into a value object.

You write a class for a task, and then you write __init__, __repr__ and __eq__. None of those three contains a decision you had to make. A month later you add a field, forget to add it to __eq__, and lose an afternoon to a test that fails for a purely mechanical reason.

Python's dataclasses module deletes that work. You write the fields with their types, and the decorator writes the methods. What remains is the part that carries actual information: whether the object may change after it is built, what equality means for it, and whether it is a thing or a description of a thing.

That last question decides the shape of a domain model. A Money with the same amount and currency as another Money is the same amount of money, so replacing one copy with the other changes nothing. Two Session records with the same session id are the same session even if one was last seen at nine and the other at noon, so replacing one with the other loses information. The first is a value object, the second is an entity, and a model that confuses them produces duplicate rows and tests that pass for the wrong reason.

The decorator writes the three methods you were going to write

A dataclass is a normal class with annotations at the top. The decorator reads those annotations and generates an __init__, a __repr__ and an __eq__ from them:

from dataclasses import asdict, dataclass, field


@dataclass
class Task:
    title: str
    estimate: int = 30
    tags: list[str] = field(default_factory=list)


first = Task("write the report", tags=["writing"])
second = Task("write the report")
print(first)
print(first == Task("write the report", tags=["writing"]))
print(first.tags is second.tags)
print(Task.__hash__)
print(asdict(first))
Task(title='write the report', estimate=30, tags=['writing'])
True
False
None
{'title': 'write the report', 'estimate': 30, 'tags': ['writing']}

Three generated methods, one generated attribute. The __eq__ compares every field that is marked for comparison, and asdict walks the fields and returns plain data, which is what you want when serialising. The None on the fourth line is the one piece of the output worth pausing on: when a dataclass has eq=True and is not frozen, the decorator sets __hash__ to None, so the object cannot go in a set or act as a dictionary key. Lesson 10 covers why that is the correct default.

Here is the same class written by hand, which is what you are choosing not to write:

class Task:
    def __init__(self, title, estimate=30, tags=None):
        self.title = title
        self.estimate = estimate
        self.tags = list(tags) if tags else []

    def __repr__(self):
        return f"Task({self.title!r}, {self.estimate}, {self.tags})"

    def __eq__(self, other):
        if not isinstance(other, Task):
            return NotImplemented
        return (self.title, self.estimate, self.tags) == (
            other.title,
            other.estimate,
            other.tags,
        )


print(Task("write the report", tags=["writing"]))
print(Task("write the report", tags=["writing"]) == Task("write the report", tags=["writing"]))
Task('write the report', 30, ['writing'])
True

The behaviour matches, and the maintenance cost does not. Every added field means three edits, and the __eq__ that forgets one of them fails silently by reporting two different objects as equal. The dataclass version cannot drift, because there is no second place listing the fields.

A mutable default is a class-level bug, so the decorator refuses it

The obvious way to give every task an empty tag list is to write one. The decorator stops you, and the error names the fix:

from dataclasses import dataclass


@dataclass
class Bad:
    title: str
    tags: list[str] = []
Traceback (most recent call last):
  File "bad_task.py", line 4, in <module>
    @dataclass
     ^^^^^^^^^
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory

The list would have been one object shared by every instance, which is the trap from lesson 10 with better error reporting. Frames through dataclasses.py are left out of that traceback. What matters is the message, and the fix is field:

tags: list[str] = field(default_factory=list)

default_factory takes a callable, and the decorator calls it once per instance instead of evaluating it once at class definition. Any zero-argument callable works, so field(default_factory=dict), field(default_factory=set) and field(default_factory=lambda: datetime.now(UTC)) all do what you expect. The confirmation is the False earlier: two tasks built with no arguments hold two different lists.

Use default_factory only for genuinely mutable or computed defaults. A string, a number, a tuple or None is fine as a plain default, and field(default="x") is a longer way to write something the decorator already handles.

__post_init__ is where validation goes

Type annotations are not enforced at runtime, so Money annotated with int will happily accept a string. The generated __init__ calls __post_init__ at the end if you have defined it, which makes it the one place that runs after every field is set:

from dataclasses import dataclass


@dataclass(frozen=True, order=True)
class Money:
    amount_cents: int
    currency: str = "EUR"

    def __post_init__(self):
        if not isinstance(self.amount_cents, int):
            raise TypeError("amount_cents must be an int")
        if self.currency not in {"EUR", "USD", "GBP"}:
            raise ValueError(f"unsupported currency: {self.currency!r}")

    @property
    def display(self):
        return f"{self.amount_cents / 100:.2f} {self.currency}"


print(Money(1250))
print(Money(500) < Money(1250))
Money(amount_cents=1250, currency='EUR')
True

Pass an unsupported currency and the traceback shows the exact place in the chain, including the generated constructor in <string>:

print(Money(1250, "BTC"))
Traceback (most recent call last):
  File "money.py", line 16, in <module>
    print(Money(1250, "BTC"))
          ~~~~~^^^^^^^^^^^^^
  File "<string>", line 5, in __init__
  File "money.py", line 13, in __post_init__
    raise ValueError(f"unsupported currency: {self.currency!r}")
ValueError: unsupported currency: 'BTC'

Raising here rather than in the caller means an invalid Money cannot exist, so every function that receives one can skip the checking. Customise with __post_init__ rather than by writing your own __init__, because a hand-written __init__ replaces the generated one and you lose the field handling you were relying on unless you remember to call super().__init__().

frozen=True is what makes sharing a value object safe

A value object that someone can modify in place is a value object in name only, because the copy you hold can change without you touching it:

from dataclasses import dataclass


@dataclass(frozen=True)
class Money:
    amount_cents: int
    currency: str = "EUR"


fee = Money(1250)
print(fee == Money(1250), len({fee, Money(1250)}))
try:
    fee.amount_cents = 900
except Exception as exc:
    print(type(exc).__name__, exc)
True 1
FrozenInstanceError cannot assign to field 'amount_cents'

Frozen also supplies the __hash__ that the mutable version lacked, which is why the set literal on the first line works and holds one element. To change a frozen value you build a new one, and dataclasses.replace saves you from restating the fields that did not change. Continuing from the block above:

from dataclasses import replace

updated = replace(fee, amount_cents=900)
print(fee, updated)
Money(amount_cents=1250, currency='EUR') Money(amount_cents=900, currency='EUR')

Two caveats. FrozenInstanceError is a subclass of AttributeError, so a broad except AttributeError somewhere above will swallow it and leave you debugging a value that looks unchanged. And frozen is not enforcement: it overrides attribute assignment, so object.__setattr__(fee, "amount_cents", 0) still writes. Frozen means "this object is not designed to change", not "the interpreter will stop you".

Adding slots=True to the decorator is a separate, smaller decision: the instance keeps its fields in fixed slots instead of a dictionary, so there is no __dict__ and assigning an undeclared attribute raises AttributeError instead of quietly succeeding. It costs nothing here and catches typos, at the price of losing dynamic attributes on purpose.

order=True compares field by field, in the order you declared them

order=True generates __lt__, __le__, __gt__ and __ge__ that compare tuples of fields. For Money that gives Money(500) < Money(1250), and it also gives you a comparison that means nothing: Money(500, "EUR") < Money(500, "USD") is True because the strings are compared, not the amounts. Ordering across currencies is not a fact about money, and the honest options are to compare only within one currency or to leave order off and sort by an explicit key.

The generated comparison also fails at the first field that cannot be ordered, which can be several fields deep:

from dataclasses import dataclass, field


@dataclass(frozen=True, order=True)
class Line:
    sku: str
    quantity: int
    meta: dict = field(default_factory=dict)


print(sorted([Line("A-1", 1, {"x": 1}), Line("A-1", 1, {"y": 2})]))
Traceback (most recent call last):
  File "line.py", line 11, in <module>
    print(sorted([Line("A-1", 1, {"x": 1}), Line("A-1", 1, {"y": 2})]))
          ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 17, in __lt__
TypeError: '<' not supported between instances of 'dict' and 'dict'

Two lines with different SKUs sort without complaint, because the comparison stops at the first field that differs and never reaches the dictionary. Excluding the field from comparison is the fix, and it fixes equality at the same time:

@dataclass(frozen=True, order=True)
class Line:
    sku: str
    quantity: int
    meta: dict = field(default_factory=dict, compare=False)


print(sorted([Line("A-1", 2), Line("A-2", 1)]))
print(Line("A-1", 2) == Line("A-1", 2, {"note": "x"}))
[Line(sku='A-1', quantity=2, meta={}), Line(sku='A-2', quantity=1, meta={})]
True

compare=False means the field is still stored, still printed by the generated __repr__, and ignored by equality and ordering. That is the right treatment for metadata that does not define the value.

A value object is equal by value, and an entity is equal by identity

Everything above assumes the object is described entirely by its fields. An entity is not: it has an identity that persists while its attributes change, and equality has to follow the identity rather than the current field values:

from dataclasses import dataclass


@dataclass(eq=False)
class Session:
    session_id: str
    user_id: str
    seen_at: str

    def __eq__(self, other):
        return isinstance(other, Session) and self.session_id == other.session_id

    def __hash__(self):
        return hash(self.session_id)


morning = Session("s-1", "ana", "09:00")
evening = Session("s-1", "ana", "21:40")
other = Session("s-2", "ana", "09:00")
print(morning == evening, morning == other)
print(len({morning, evening, other}))
True False
2

eq=False keeps the decorator out of the decision, and the hand-written pair compares and hashes on session_id alone. The set holds two sessions: the first and second are the same session, the third is a different one. A default dataclass would have compared all three fields, reported the morning and evening sessions as different, and put three entries in the set.

Getting this backwards is expensive in both directions. Treating an entity as a value object means every attribute update looks like a different record, so a cache keyed on the object misses and a de-duplicating set never de-duplicates. Treating a value object as an entity means two Money(1250) instances are compared by identity, so an equality test that should pass fails and a dictionary keyed by amount holds duplicates. Decide which one you are building before you write the fields.

A NamedTuple is a tuple first, and that is the whole difference

typing.NamedTuple gives you a similar-looking class with different foundations. It is a subclass of tuple, and every tuple behaviour comes with it:

from typing import NamedTuple


class Point(NamedTuple):
    x: float
    y: float
    label: str = "origin"


origin = Point(0, 0)
print(origin)
print(origin == (0.0, 0.0, "origin"))
x, y, label = origin
print(x, y, label)
print(origin._replace(label="centre"))
print(hasattr(origin, "__dict__"))
try:
    origin.x = 1
except Exception as exc:
    print(type(exc).__name__, exc)
Point(x=0, y=0, label='origin')
True
0 0 origin
Point(x=0, y=0, label='centre')
False
AttributeError can't set attribute

The second line is the one to notice. A Point compares equal to a plain tuple with the same contents, because it is a plain tuple with extra methods. That is convenient when the value really is a record in a sequence, and a hazard when it is not, because any function that takes a tuple accepts your Point and can pass a bare tuple back.

Choose a NamedTuple when the fields are positional by nature, there are two or three of them, and the tuple protocol is part of the interface. Choose a dataclass when you want a keyword constructor with defaults, per-field control over comparison, validation, computed properties, or the option of mutation. NamedTuple gives you indexing, assignment to several names at once, ordering by position and a free _replace; a dataclass gives you field, frozen, order, compare, slots and __post_init__. Neither is a superset of the other, and the tuple heritage is what tips the decision.

Lab 30 minutes

Model a shopping cart with two types. LineItem holds a sku, a quantity and a unit_cents price. Cart holds a list of line items.

  • LineItem rejects a quantity below 1 and a negative price, and two line items with the same fields compare equal.
  • LineItem can be sorted by the natural field order, and total_cents is a computed property.
  • Cart starts empty without sharing a list with any other cart, and its total_cents sums the line totals.

Then answer: why is Cart not frozen, and what would you pass to field() if LineItem also carried a tags set that should not affect equality or ordering?

Show the solution and the reasoning

The two types are on opposite sides of the value object and entity line, and the decorator arguments follow from that. A line item is fully described by its three fields, so it is a value object: frozen, ordered, and validated once at construction. A cart accumulates items during its life, so it is an entity with mutable state and a list that has to belong to one instance.

from dataclasses import dataclass, field


@dataclass(frozen=True, order=True)
class LineItem:
    sku: str
    quantity: int
    unit_cents: int

    def __post_init__(self):
        if self.quantity < 1:
            raise ValueError("quantity must be at least 1")
        if self.unit_cents < 0:
            raise ValueError("unit_cents must not be negative")

    @property
    def total_cents(self):
        return self.quantity * self.unit_cents


@dataclass
class Cart:
    items: list[LineItem] = field(default_factory=list)

    def add(self, item):
        self.items.append(item)
        return self

    @property
    def total_cents(self):
        return sum(item.total_cents for item in self.items)
python3 lab11.py
Cart(items=[LineItem(sku='B-2', quantity=1, unit_cents=500), LineItem(sku='A-1', quantity=3, unit_cents=250)])
total: 1250
[LineItem(sku='A-1', quantity=3, unit_cents=250), LineItem(sku='B-2', quantity=1, unit_cents=500)]
value equality: True
separate lists: True
ValueError quantity must be at least 1

The sort on the third line is not alphabetical by accident. order=True compares sku first, so "A-1" comes before "B-2", and by the time quantity or price could matter there is nothing left to compare. Sorting by total price instead would need sorted(cart.items, key=lambda item: item.total_cents), because the generated comparison has no way to know which field you meant.

Cart cannot be frozen, and the failure would be immediate: self.items.append(item) would raise FrozenInstanceError. Frozen suits values that are replaced rather than updated. A cart is updated, which is why it gets an add method that returns self, and why the default_factory on items matters. Without it, two carts would append to the same list.

For a tags set that should not affect equality or ordering, pass both flags to field:

tags: set[str] = field(default_factory=set, compare=False)

default_factory=set gives each line item its own set, compare=False keeps it out of __eq__ and out of the generated ordering methods so a set is never compared with <, and the field still appears in the __repr__ when you print the object during debugging.

Progress

Mark this lesson done and it stays marked in this browser.