Python Dataclasses
Object-Oriented Python Object-Oriented Python

Python Dataclasses

1Python Dataclasses: overview

A dataclass is a normal class where you list the fields once, with type hints, and Python writes init, repr and eq for you, so classes that mostly hold data stay short and readable.

GoalBy the end you can turn a data-holding class into a @dataclass, give fields defaults and default_factory, make instances immutable or sortable, validate and derive fields with post_init, convert objects with asdict(), astuple() and replace(), and decide when a plain class or a dict fits better.

Before this lesson: Python Classes and Objects, Python Dunder (Magic) Methods

2Python Dataclasses: diagram

Python DataclassesBy hand: 8 lines for 2 fieldsclass Point: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Point({self.x}, {self.y})" def __eq__(self, other): return (self.x, self.y) == (other.x, other.y)@dataclass: 4 lines@dataclassclass Point: x: int y: int# init, repr, eq: all generated@dataclass
Both versions give you a class whose objects can be created, printed and compared. With @dataclass the three methods are generated from the field list, so they can never fall out of step with it.

3Python Dataclasses: explained step by step

Many classes exist mostly to carry a few values around: a Product with a name and a price, a Point with x and y, a Config with a handful of settings. Setting each one up by hand means writing the same three things again and again: an __init__ that copies every argument onto self, a __repr__ so printing is readable, and an __eq__ so two objects holding the same values count as equal. It is dull work, and it is easy to add a field and forget to update one of the three.

The boilerplate problem

Here is a plain class. Two points with identical coordinates are not equal, because without an __eq__ Python only asks "is this the very same object?":

Python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p1 = Point(2, 3)
p2 = Point(2, 3)
print(p1 == p2)
print(p1 is p2)

To fix that you would add an __eq__, then a __repr__ so the object prints nicely, and then repeat the whole routine for every data class you write.

The @dataclass decorator

Import dataclass, put it above the class, and list the fields as annotated names in the class body. Each name: type line becomes a field, and the order of the lines is the order of the __init__ parameters.

Python
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p1 = Point(2, 3)
p2 = Point(2, 3)
print(p1)
print(p1 == p2)
print(p1.x + p1.y)

The annotation is required, because it is how Python finds the fields, but Python does not check it. Point("a", "b") works without complaint. Treat type hints as documentation that your editor can also use.

Default values and default_factory

A field with a default value follows the same rule as function parameters: fields with defaults must come after fields without. Writing price: float = 0.0 before name: str raises TypeError: non-default argument 'name' follows default argument 'price'.

A list, dict or set cannot be written as a default directly. Just like a list in a class body, one shared list would be reused by every object, so tags: list = [] raises ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory. Use field(default_factory=list) instead: it calls list() separately for each new object.

Python
from dataclasses import dataclass, field

@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True
    tags: list = field(default_factory=list)

lamp = Product("Lamp", 24.5)
mug = Product("Mug", 8.0, tags=["kitchen"])
lamp.tags.append("home")
print(lamp)
print(mug)

Frozen: objects that cannot change

@dataclass(frozen=True) makes every assignment to a field raise FrozenInstanceError after the object is created. That is perfect for values that should never change, such as coordinates or money. As a bonus, a frozen dataclass can be used as a dictionary key or stored in a set, because its fields never change underneath it.

Python
from dataclasses import dataclass, FrozenInstanceError

@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float

home = Coordinate(51.5, -0.12)
try:
    home.lat = 40.0
except FrozenInstanceError:
    print("Cannot change a frozen dataclass")

visited = {home: "Home"}
print(visited[Coordinate(51.5, -0.12)])

Sorting with order=True

@dataclass(order=True) adds <, <=, > and >=. Objects are compared field by field, in the order the fields are declared, exactly like tuples. That means the first field is the most important one.

Python
from dataclasses import dataclass

@dataclass(order=True)
class Version:
    major: int
    minor: int

releases = [Version(2, 1), Version(1, 9), Version(2, 0)]
print(sorted(releases))
print(Version(1, 9) < Version(2, 0))
print(max(releases))

post_init and field(init=False)

The generated __init__ finishes by calling __post_init__(self) if you have written one. It is the right place to validate values or to compute a field from the others. A field marked field(init=False) is left out of __init__, so callers cannot pass it, and you fill it in yourself.

Python
from dataclasses import dataclass, field

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)

    def __post_init__(self):
        if self.width <= 0 or self.height <= 0:
            raise ValueError("width and height must be positive")
        self.area = self.width * self.height

box = Rectangle(3, 4)
print(box)
try:
    Rectangle(-1, 5)
except ValueError as err:
    print("Rejected:", err)

Converting and copying: asdict, astuple, replace

The dataclasses module also has helpers. asdict(obj) gives a plain dictionary (nested dataclasses become nested dictionaries, ready for json.dumps), astuple(obj) gives a tuple, and replace(obj, field=value) builds a new object with one or more fields changed. This is how you "edit" a frozen object.

Be careful with replace(): it copies the field values as they are, so a list inside the object is shared between the original and the copy (a shallow copy).

Python
from dataclasses import dataclass, field, asdict, astuple, replace

@dataclass
class Task:
    title: str
    done: bool = False
    tags: list = field(default_factory=list)

task = Task("Write report", tags=["work"])
print(asdict(task))
print(astuple(task))

finished = replace(task, done=True)
print(finished)
print(task.done, finished.tags is task.tags)

When a plain class or a dict is better

A dataclass is not always the right tool. It fits when the class is mostly data and you want repr and == for free. It can still have ordinary methods, as you will see below.

Prefer a plain class when the object is mostly behaviour with hidden internal state, when __init__ takes arguments that are not stored as they are, or when you need full control over how the object is created. Prefer a plain dictionary for quick, throwaway data, or when the keys are not known in advance (for example JSON you do not control). If you need a fixed record that is also a tuple, typing.NamedTuple is another option.

4Python Dataclasses: syntax

from dataclasses import dataclass, field, asdict, astuple, replace

@dataclass                                  # or @dataclass(frozen=True, order=True)
class Item:
    name: str                               # required field
    price: float = 0.0                      # default: must come after required fields
    tags: list = field(default_factory=list)   # mutable default
    total: float = field(init=False)        # computed, not an __init__ argument

    def __post_init__(self):                # runs right after the generated __init__
        self.total = self.price

item = Item("pen", 1.5)                     # Item(name='pen', price=1.5, tags=[], total=1.5)
asdict(item)                                # {'name': 'pen', ...}
astuple(item)                               # ('pen', 1.5, [], 1.5)
replace(item, price=2.0)                    # a copy with price changed

5Python Dataclasses: run the example

Press Run (or Ctrl + Enter). The output (step 6) appears directly below the code. Change anything and run it again.

Interactive example
from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    grade: int
    scores: list = field(default_factory=list)

    def average(self):
        return sum(self.scores) / len(self.scores) if self.scores else 0.0

asha = Student("Asha", 10, [90, 80])
ben = Student("Ben", 10)
ben.scores.append(70)

print(asha)
print(ben)
print(asha == Student("Asha", 10, [90, 80]))
print(asha.average(), ben.average())
Expected outputStudent(name='Asha', grade=10, scores=[90, 80])
Student(name='Ben', grade=10, scores=[70])
True
85.0 70.0

7Python Dataclasses: how the example works

Hover or tap a step to highlight its lines in the example above.

  1. Line 1dataclass and field come from the standard dataclasses module.
  2. Line 3-7@dataclass reads the three annotated lines as fields and generates __init__, __repr__ and __eq__. scores uses field(default_factory=list), so every Student gets its own new empty list.
  3. Line 9-10A dataclass is still a normal class, so it can have methods. average() returns 0.0 for a student without scores instead of dividing by zero.
  4. Line 12-13Arguments are matched to fields in the order they were declared. Ben gets the default empty list for scores.
  5. Line 14Only Ben's list changes, because the factory created a separate list for him.
  6. Line 16-17print() shows the generated __repr__: the class name followed by every field as name=value.
  7. Line 18The generated __eq__ compares all the fields, so a brand-new Student with the same values equals asha, even though it is a different object.
  8. Line 19Asha's average is (90 + 80) / 2 = 85.0 and Ben's is 70.0.

8Python Dataclasses: common mistakes

Using a list as a default value

✗ Wrong

from dataclasses import dataclass

@dataclass
class Playlist:
    name: str
    songs: list = []
ValueError: mutable default <class 'list'> for field songs is not allowed: use default_factory

✓ Right

from dataclasses import dataclass, field

@dataclass
class Playlist:
    name: str
    songs: list = field(default_factory=list)

focus = Playlist("Focus")
gym = Playlist("Gym")
focus.songs.append("Rain")
print(focus)
print(gym)

A plain [] would be created once and shared by every Playlist. Dataclasses refuse it on purpose. Use field(default_factory=list) so each object gets its own list.

A required field after a field with a default

✗ Wrong

from dataclasses import dataclass

@dataclass
class Item:
    name: str
    price: float = 1.0
    quantity: int
TypeError: non-default argument 'quantity' follows default argument 'price'

✓ Right

from dataclasses import dataclass

@dataclass
class Item:
    name: str
    quantity: int
    price: float = 1.0

print(Item("pen", 3))

The generated __init__ follows the rule for function parameters: required parameters cannot come after optional ones. Move quantity above price, or give it a default too.

Forgetting the type annotation

✗ Wrong

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price = 9.99

item = Product("Lamp", 12.5)
TypeError: Product.__init__() takes 2 positional arguments but 3 were given

✓ Right

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float = 9.99

item = Product("Lamp", 12.5)
print(item)

Only names with an annotation become fields. price = 9.99 is an ordinary class attribute, so __init__ has no price parameter. Write price: float = 9.99.

Assigning to a field of a frozen dataclass

✗ Wrong

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

p = Point(1, 2)
p.x = 10
dataclasses.FrozenInstanceError: cannot assign to field 'x'

✓ Right

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Point:
    x: int
    y: int

p = Point(1, 2)
moved = replace(p, x=10)
print(p, moved)

That is exactly what frozen=True is for. To get a changed version, build a new object with replace() and keep using that.

Expecting type hints to be checked

✗ Wrong

from dataclasses import dataclass

@dataclass
class Price:
    amount: float

cost = Price("ten")
print(cost)
No error: Python prints Price(amount='ten') even though a number was expected

✓ Right

from dataclasses import dataclass

@dataclass
class Price:
    amount: float

    def __post_init__(self):
        if not isinstance(self.amount, (int, float)):
            raise TypeError("amount must be a number")

cost = Price(10)
print(cost)

Annotations are documentation only. If bad types would cause trouble later, check them yourself in __post_init__ and raise an error there.

9Python Dataclasses: exercises

Write your answer, then press Check. The checks run in your browser.

1. Turn Book into a dataclass

Write code Easy

Make Book a dataclass with the fields title (str), author (str) and pages (int, default 100). Do not write __init__ or __repr__ yourself.

For example Book("Dune", "Frank Herbert", 412) should print as Book(title='Dune', author='Frank Herbert', pages=412).

Your code
from dataclasses import dataclass

class Book:
    # add the decorator above the class and the three fields here
    pass

2. Predict the playlists

Predict the output Medium

Type exactly what the program prints. Think about which objects share a list and what replace() copies.

Read this code
from dataclasses import dataclass, field, replace

@dataclass
class Playlist:
    name: str
    songs: list = field(default_factory=list)
    plays: int = 0

focus = Playlist("Focus")
other = Playlist("Focus")
focus.songs.append("Rain")
focus.plays += 2
print(focus)
print(other)
print(focus == other)

gym = replace(focus, name="Gym")
gym.songs.append("Run")
print(gym.name, gym.plays, gym.songs)
print(focus.songs)

2 more exercises → 6 more examples

Finished this lesson?
Progress is saved in this browser — create a free account to keep it across devices.

Questions learners ask

Do dataclasses check the types I write?

No. The annotations tell readers and editors what you intend, but Python does not enforce them: a field typed int will happily hold a string. If you need real checks, validate in post_init, or use a validation library such as pydantic.

What is the difference between a dataclass and a namedtuple?

A namedtuple is an immutable tuple with named positions, so you can index and unpack it. A dataclass is a regular class: it is mutable by default, supports default_factory, methods, post-init logic and inheritance, and becomes immutable only if you ask for frozen=True.

Why can't I use an empty list as a default value in a dataclass?

A default is evaluated once, so every object would share the same list and appending to one would change all of them. Dataclasses raise a ValueError to stop this. Write field(default_factory=list) and a fresh list is created for each object.

Further reading

10Next lesson

Up nextWorking with JSON in PythonJSON is a plain-text format for structured data, and Python's built-in json module converts JSON text into dictionaries…

Related:
Python DictionariesPython Classes and ObjectsPython Dunder (Magic) MethodsWorking with JSON in Python

← Python Abstract Classes (ABC) · All Object-Oriented Python lessons · Practise in the IDE