Python 6.2Written lesson8 minLesson 2 of 3 in Module 6
Dataclasses
Define a class that mainly holds data in a few lines, with type hints.
Video in production.
Read the lesson: Dataclasses
01Boilerplate methods in a data holding class
In the previous lesson, you wrote __init__ and __repr__ for the Review class by hand:
class Review:
def __init__(self, product, rating, text):
self.product = product
self.rating = rating
self.text = text
def __repr__(self):
return f"Review({self.product!r}, {self.rating})"
Even after all of that, the class still has no __eq__, so two reviews holding the same data do not compare equal.
The bigger trouble is what happens when the class grows. Suppose you add a field to a review, such as whether it was verified. Then __init__ has to change to accept and store it, __repr__ has to change to show it, and an __eq__ would have to change to compare it. All of those methods have to be updated together, by hand, every time.
Yet every one of those methods follows directly from the list of fields. So can Python generate them for you, if you give it that list?
02`@dataclass`
It can, with something called a dataclassKey termA class whose __init__, __repr__ and __eq__ are written from its list of fields, with @dataclass..
Defining a dataclass
You import dataclass from the dataclasses module. Then you write @dataclass on the line above the class, and inside the class you list the fields, each with a type hint: product: str, then rating: int, and text: str. A fieldKey termOne attribute a dataclass lists, with a type hint: rating: int. is one of the named values each object of the class stores.
from dataclasses import dataclass
@dataclass
class Review:
product: str
rating: int
text: str
And that is it. There is no __init__, no __repr__ and no __eq__ anywhere in it.
What the @ line is
The line with the @ sign is called a decoratorKey termA function applied to the class or function below it, written with @: @dataclass.. dataclass is a function that takes your class, adds those methods to it, and returns the class. The @ sign is how you apply that function to the class that follows it.
The generated __repr__
Now make a review:
kettle = Review("kettle", 5, "Great kettle!")
kettle
Review(product='kettle', rating=5, text='Great kettle!')
It displays as Review, with product='kettle', rating=5 and text='Great kettle!'. So __repr__ was generated for you, and it shows every field by name.
Comparing two reviews
In the previous lesson, two reviews with the same data compared False. What do you think happens with a dataclass? Think it through before reading on.
I have a guess. Show meHide the answer
kettle == Review("kettle", 5, "Great kettle!")
True
The answer is True. The generated __eq__ compares the fields one by one, so two reviews with the same product, rating and text are equal. If you change the rating to 4, the comparison gives False:
kettle == Review("kettle", 4, "Great kettle!")
False
Attributes work as before
The attributes are there just as they were with the hand-written class, so kettle.rating still gives 5:
kettle.rating
5
03Methods and type hints
A dataclass is still an ordinary class, so you can give it methods, just as you did in the previous lesson. Add a method called is_positive, which returns whether self.rating is at least 4:
@dataclass
class Review:
product: str
rating: int
text: str
def is_positive(self) -> bool:
return self.rating >= 4
Review("kettle", 5, "Great kettle!").is_positive()
True
A five-star kettle review is positive, so the call gives True.
Are the type hints checked?
The type hints are how the dataclass finds its fields. But in the lesson on defining functions, you saw that Python does not check type hints when the code runs. So say you create a review with the rating written as the word "five", in quotes. Will Python accept it? Think it through before reading on.
I have a guess. Show meHide the answer
Review("lamp", "five", "Too dim.")
Review(product='lamp', rating='five', text='Too dim.')
Yes, it does. The review displays with rating='five', a string. The hints still are not checked when the code runs.
What mypy reports
mypy, the type checker from the previous module, does read them. Here the same class and call are saved in a file called hint_check.py, and mypy reports the problem:
$ uv run mypy hint_check.py
hint_check.py:11: error: Argument 2 to "Review" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
Argument 2 to Review has the incompatible type str, where int was expected.
What Python does check
What Python does check is the number of arguments. The generated __init__ has one parameter per field, so Review("lamp") on its own raises a TypeError:
Review("lamp")
TypeError: Review.__init__() missing 2 required positional arguments: 'rating' and 'text'
Two required positional arguments are missing: rating and text.
The core of a dataclass
That is the core of a dataclass. @dataclass reads the fields from the type hints, and generates __init__, __repr__ and __eq__ for you. Otherwise, the class is an ordinary class, with methods of its own. And the hints are still not checked when the code runs.
04Field defaults and default factory
A field can have a default value, the same way a parameter can. Writing verified: bool = False makes verified optional:
@dataclass
class Review:
product: str
rating: int
text: str
verified: bool = False
def is_positive(self) -> bool:
return self.rating >= 4
Review("kettle", 5, "Great kettle!")
Review(product='kettle', rating=5, text='Great kettle!', verified=False)
A review created without it has verified equal to False, and the display shows that field too. A review created with verified=True, like this lamp review, stores True:
Review("lamp", 2, "Too dim.", verified=True)
Review(product='lamp', rating=2, text='Too dim.', verified=True)
Defaults come last
The rule from the lesson on arguments applies here as well: a field without a default cannot come after a field with a default. So a rating field listed after verified is a TypeError:
@dataclass
class Broken:
product: str
verified: bool = False
rating: int
TypeError: non-default argument 'rating' follows default argument
A list as a default
Now, what about a list? Say you write a second dataclass, called Tagged, for a review with tags, and you give its tags field a default of an empty list:
@dataclass
class Tagged:
product: str
tags: list[str] = []
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
As soon as the class is defined, dataclass raises a ValueError. This is the mutable default problem from the lesson on arguments again. If every review started with the same list object, adding a tag to one review would add it to all of them.
Using a default factory
So instead, you write the default as field(default_factory=list). A default factory is a function that the dataclass calls to make a field's default value. It is called once for each new object, so each object gets a new list of its own. Here, appending "quiet" to the first review's tags leaves the second review's tags empty:
from dataclasses import field
@dataclass
class Tagged:
product: str
tags: list[str] = field(default_factory=list)
first = Tagged("kettle")
second = Tagged("lamp")
first.tags.append("quiet")
first, second
(Tagged(product='kettle', tags=['quiet']), Tagged(product='lamp', tags=[]))
05Frozen dataclasses
Some values should never change once they are created, like a review that has already been posted. For those, you write @dataclass(frozen=True). A frozenKey termA dataclass that blocks field assignment and deletion, @dataclass(frozen=True). Nested mutable values can still change. With the default equality, its generated hash works only when every hashed field is hashable. dataclass blocks assignment to its fields after the object is created.
@dataclass(frozen=True)
class Review:
product: str
rating: int
text: str
verified: bool = False
def is_positive(self) -> bool:
return self.rating >= 4
So if you try kettle.rating = 1, you get a FrozenInstanceError, and the message says it cannot assign to the field rating:
kettle = Review("kettle", 5, "Great kettle!")
kettle.rating = 1
FrozenInstanceError: cannot assign to field 'rating'
Frozen objects are hashable
Frozen has a second effect, too. Set elements and dictionary keys need hashable values, whose hash never changes. An ordinary dataclass can change, so putting one of the Tagged objects in a set raises a TypeError:
{first}
TypeError: unhashable type: 'Tagged'
The frozen reviews can go in a set, because every one of their fields is hashable.
A set of reviews
So say a set holds two kettle reviews with the same data, and one lamp review. How many items do you think the set has? Think it through before reading on.
I have a guess. Show meHide the answer
len({Review("kettle", 5, "Great kettle!"), Review("kettle", 5, "Great kettle!"),
Review("lamp", 2, "Too dim.")})
2
Two. The two kettle reviews are equal, so the set keeps just one of them, the same way a set kept one of each visitor in the lesson on sets.
A review as a dictionary key
A frozen review also works as a dictionary key. Here is a dictionary of replies, keyed by the kettle review. Looking it up with a new review that has the same data gives the reply:
replies = {kettle: "Thanks for the review!"}
replies[Review("kettle", 5, "Great kettle!")]
'Thanks for the review!'
06Loading CSV rows into dataclass objects
Now use the dataclass on the review data. In the lesson on files, you read reviews.csv with DictReader, and in the lesson on exceptions, you skipped the row whose rating was the word five. Here is the file, data/reviews.csv:
product,rating,review
kettle,5,"Great kettle! Boils fast, and it is quiet."
kettle,4,"The lid is stiff, but it boils fast. Great value!"
toaster,5,"Toasts evenly. Easy to clean, and the crumb tray is great."
headphones,4,"Great sound, easy to pair, and the battery lasts all week!"
headphones,2,"Not great. The left side is quiet, and it is not easy to fix?"
lamp,five,"Too dim."
Each row becomes a Review
This time, each row becomes a Review. For each row, the try block appends Review of the product, int of the rating, and the review text. A ValueError from int adds one to skipped instead:
import csv
reviews = []
skipped = 0
with open("data/reviews.csv", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
try:
reviews.append(Review(row["product"], int(row["rating"]), row["review"]))
except ValueError:
skipped += 1
print(len(reviews), skipped)
reviews[0]
5 1
Review(product='kettle', rating=5, text='Great kettle! Boils fast, and it is quiet.', verified=False)
You get five reviews and one skipped row, and the first review displays with every field: kettle, 5, its text, and verified=False.
Because each review has the is_positive method, a list comprehension that keeps only the positive reviews is short to write:
[review.product for review in reviews if review.is_positive()]
['kettle', 'kettle', 'toaster', 'headphones']
It gives the products kettle, kettle, toaster and headphones.
Turning a review back into a dictionary
To save a review, you can turn it back into a dictionary. asdict, from the dataclasses module, gives a dictionary of the fields:
from dataclasses import asdict
asdict(kettle)
{'product': 'kettle', 'rating': 5, 'text': 'Great kettle!', 'verified': False}
json.dumps of that dictionary gives the same data as JSON text, the way you wrote the counts in the lesson on files:
import json
json.dumps(asdict(kettle))
'{"product": "kettle", "rating": 5, "text": "Great kettle!", "verified": false}'
In JSON, False is written with a lowercase "f", as false.
07Recap and challenge: dataclasses
You started with the methods you wrote by hand in the previous lesson, and had Python generate them.
@dataclassreads a class's fields from their type hints, and generates__init__,__repr__and__eq__for you, so two reviews with the same data are now equal.- The line with the
@sign is a decorator: a function applied to the class below it. - A field can have a default, the same way a parameter can. A list or dictionary default needs
field(default_factory=...), so each object gets its own. frozen=Trueblocks assignment to the fields. When every field is hashable, as inReview, a review can go in a set or be a dictionary key.- The type hints are still not checked when the code runs. They are read by mypy.
Your challenge
Write a frozen dataclass called Car, with an age, which is an int, and a price, which is a float. Load the six cars from the CSV file data/cars.csv into a list of Car objects, and find their average price:
age,price
1,23.2
2,19.6
4,15.2
6,11.2
8,7.6
9,7.2
The average should come to 14.0.
Those six cars come from the machine learning series, whose first lesson predicts a car's price from its age. The prediction models in scikit-learn are objects too, made from classes with a common set of methods. In the next lesson, you will write a model class of your own, with those same methods.
Key terms: Dataclasses
The words this lesson introduces, each in one line. The module’s glossary collects them all, and the AI glossary has every term across the courses.
- dataclass
- A class whose
__init__,__repr__and__eq__are written from its list of fields, with@dataclass. - decorator
- A function applied to the class or function below it, written with
@:@dataclass. - field
- One attribute a dataclass lists, with a type hint:
rating: int. - frozen
- A dataclass that blocks field assignment and deletion,
@dataclass(frozen=True). Nested mutable values can still change. With the default equality, its generated hash works only when every hashed field is hashable.
Quiz: Dataclasses
Your first pick on each question is the one that counts. Getting one wrong here is how the lesson sticks.