Blog

Python Dataclasses

A dataclass writes the boring methods for you. You declare the fields with type annotations and the decorator generates __init__, __repr__ and __eq__.

It has been in the standard library since Python 3.7, so there is nothing to install.

The boilerplate it removes

The old class and the dataclass below it do the same job.

# the boilerplate a dataclass removes
class PointOld:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f'PointOld(x={self.x}, y={self.y})'
    def __eq__(self, other):
        return isinstance(other, PointOld) and (self.x, self.y) == (other.x, other.y)

from dataclasses import dataclass, field, asdict, replace

@dataclass
class Point:
    x: int
    y: int

p = Point(1, 2)
print(p)
print(p == Point(1, 2))

It prints:

Point(x=1, y=2)
True

Equality compares the fields, not the identity, which is almost always what you want for a value object.

Defaults and the list rule

Simple defaults are written normally. A mutable default needs default_factory, which is called once per instance.

# defaults, and why lists need default_factory
@dataclass
class Basket:
    owner: str
    items: list = field(default_factory=list)
    currency: str = 'INR'

b1, b2 = Basket('ada'), Basket('grace')
b1.items.append('apple')
print(b1)
print(b2)

try:
    @dataclass
    class Broken:
        items: list = []
except ValueError as err:
    print(type(err).__name__ + ':', err)

It prints:

Basket(owner='ada', items=['apple'], currency='INR')
Basket(owner='grace', items=[], currency='INR')
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

The last lines are the useful part. Python refuses the mutable default rather than letting every instance share one list, which is what happens with a plain function argument.

Frozen instances

frozen=True makes the fields read only and the instance hashable, so it can be a dict key or go in a set.

# frozen instances are hashable and read only
@dataclass(frozen=True)
class Config:
    host: str
    port: int = 8080

c = Config('localhost')
print(c, hash(c) == hash(Config('localhost')))
try:
    c.port = 9090
except Exception as err:
    print(type(err).__name__ + ':', err)

It prints:

Config(host='localhost', port=8080) True
FrozenInstanceError: cannot assign to field 'port'

Ordering

order=True generates the comparison methods, which compare the fields in the order they are declared, as a tuple would.

# order gives you comparisons and sorting
@dataclass(order=True)
class Version:
    major: int
    minor: int

versions = [Version(1, 4), Version(1, 2), Version(0, 9)]
print(sorted(versions))
print(Version(1, 4) > Version(1, 2))

It prints:

[Version(major=0, minor=9), Version(major=1, minor=2), Version(major=1, minor=4)]
True

Sorting works with no key function.

Derived values with __post_init__

field(init=False) keeps a field out of the constructor, and __post_init__ runs straight after it.

# __post_init__ for derived values
@dataclass
class Rect:
    width: float
    height: float
    area: float = field(init=False)
    def __post_init__(self):
        self.area = self.width * self.height

print(Rect(3, 4))

It prints:

Rect(width=3, height=4, area=12)

asdict and replace

asdict converts to plain dicts, recursively. replace builds a new instance with some fields changed, which is how you update a frozen one.

# asdict and replace
print(asdict(Basket('ada', ['apple'])))
print(replace(Config('localhost'), port=9090))

It prints:

{'owner': 'ada', 'items': ['apple'], 'currency': 'INR'}
Config(host='localhost', port=9090)

Keeping a field out of repr or equality

Useful for secrets, caches and anything noisy.

# fields you do not want in repr or comparison
@dataclass
class User:
    name: str
    token: str = field(repr=False, compare=False)

print(User('ada', 'secret-token'))
print(User('ada', 'secret-token') == User('ada', 'different-token'))

It prints:

User(name='ada')
True

The token is not printed and does not affect equality.

What to remember

  • The decorator generates __init__, __repr__ and __eq__ from annotated fields.
  • Mutable defaults need field(default_factory=list).
  • frozen=True gives read only, hashable instances.
  • asdict and replace cover serialising and updating.

If you also want validation and parsing at the edges of your program, that is where Pydantic earns its place. For plain internal data holders, a dataclass is enough and costs nothing.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

Leave a Reply