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.
Before this lesson: Python Classes and Objects, Python Dunder (Magic) Methods
2Python Dataclasses: diagram
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?":
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.
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.
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.
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.
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.
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).
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.
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.
- Line 1
dataclassandfieldcome from the standarddataclassesmodule. - Line 3-7
@dataclassreads the three annotated lines as fields and generates__init__,__repr__and__eq__.scoresusesfield(default_factory=list), so everyStudentgets its own new empty list. - Line 9-10A dataclass is still a normal class, so it can have methods.
average()returns0.0for a student without scores instead of dividing by zero. - Line 12-13Arguments are matched to fields in the order they were declared. Ben gets the default empty list for
scores. - Line 14Only Ben's list changes, because the factory created a separate list for him.
- Line 16-17
print()shows the generated__repr__: the class name followed by every field asname=value. - Line 18The generated
__eq__compares all the fields, so a brand-newStudentwith the same values equalsasha, even though it is a different object. - 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 = []✓ 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✓ 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)✓ 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✓ 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)✓ 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 ✓ CompletedMake 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).
from dataclasses import dataclass
class Book:
# add the decorator above the class and the three fields here
pass
title: str. A default value goes after the annotation: pages: int = 100.from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int = 100
print(Book("Dune", "Frank Herbert", 412))
2. Predict the playlists
Predict the output Medium ✓ CompletedType exactly what the program prints. Think about which objects share a list and what replace() copies.
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)
default_factory gives each new Playlist its own list. But replace() does not copy the list: the new object is built from the same list object that focus already holds.Playlist(name='Focus', songs=['Rain'], plays=2)
Playlist(name='Focus', songs=[], plays=0)
False
Gym 2 ['Rain', 'Run']
['Rain', 'Run']
2 more exercises → 6 more examples
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