SchoolWhool
Quizzes and saved progress, freeSign in freeSign in

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

Predict first

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?

Predict first

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

Predict first

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.

  • @dataclass reads 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=True blocks assignment to the fields. When every field is hashable, as in Review, 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.