Skip to content
aviral gupta

// I2.5 · ~35 min · Intermediate

Data classes

After this lesson you can replace hand-written __init__ and __repr__ with @dataclass, give fields safe defaults, check values in __post_init__, and make instances frozen and sortable.

Lesson 5 of 6 in I2 Classes

You will be able to

  • Define a data class with fields and defaults, and use its generated __init__, __repr__ and __eq__
  • Use field(default_factory=...) for list defaults and __post_init__ for checks and derived fields
  • Use frozen=True and order=True, and predict comparing, sorting and hashing
  1. Warm-up · Activity 1 of 7

    Warm-up from the last lessons: a plain class with an __init__. What does this print?

    class Point:
        def __init__(self, x, y):
            self.x = x
            self.y = y
    
    
    print(Point(1, 2) == Point(1, 2))
  2. Predict · Activity 2 of 7

    Predict before you read on: the same class, written as a data class. What does this print?

    from dataclasses import dataclass
    
    
    @dataclass
    class Point:
        x: int
        y: int
    
    
    p = Point(1, 2)
    print(p, p == Point(1, 2))
  3. Practice · Activity 3 of 7

    Fill in the function that gives every Basket its own empty list.

    from dataclasses import dataclass, field
    
    
    @dataclass
    class Basket:
        items: list[str] = ____(default_factory=list)
    = (default_factory=list)
  4. Practice · Activity 4 of 7

    area is computed in __post_init__. What does this print?

    from dataclasses import dataclass, field
    
    
    @dataclass
    class Rect:
        w: int
        h: int
        area: int = field(init=False)
    
        def __post_init__(self):
            self.area = self.w * self.h
    
    
    print(Rect(2, 3))
  5. Practice · Activity 5 of 7

    Match each piece of a data class to what it does.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. Team is frozen. What does this print?

    from dataclasses import dataclass
    
    
    @dataclass(frozen=True)
    class Team:
        name: str
        members: list
    
    
    t = Team("A", [])
    t.members.append("ada")
    print(t)
  7. Apply · Activity 7 of 7

    Mini-task. Write a data class Item with the fields name: str, price: float and quantity: int = 1. In __post_init__, raise ValueError if price is negative. Add a method total() that returns price * quantity. Item("pen", 1.5, 4) prints as Item(name='pen', price=1.5, quantity=4) with total 6.0, and Item("pen", -1) is refused.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

An order with line items and money

Three data classes work together. Money is frozen and ordered, so amounts can be compared, used in sets and never changed by accident. LineItem checks its quantity in __post_init__. Order starts with its own empty list thanks to default_factory. None of them has a hand-written __init__ or __repr__.

main.py

from dataclasses import dataclass, field


@dataclass(frozen=True, order=True)
class Money:
    cents: int
    currency: str = "EUR"

    def text(self) -> str:
        return f"{self.cents / 100:.2f} {self.currency}"


@dataclass
class LineItem:
    name: str
    price: Money
    quantity: int = 1

    def __post_init__(self) -> None:
        if self.quantity < 1:
            raise ValueError(f"quantity must be at least 1, got {self.quantity}")

    def total(self) -> Money:
        return Money(self.price.cents * self.quantity, self.price.currency)


@dataclass
class Order:
    customer: str
    items: list[LineItem] = field(default_factory=list)

    def add(self, name: str, cents: int, quantity: int = 1) -> None:
        self.items.append(LineItem(name, Money(cents), quantity))

    def total(self) -> Money:
        return Money(sum(item.total().cents for item in self.items))


order = Order("ada")
order.add("coffee", 850, 2)
order.add("mug", 1200)
print(order.items[1])
print(order.total().text(), Order("bob").items)
print(max(item.total() for item in order.items).text())
print(Money(500) == Money(500), len({Money(500), Money(500), Money(200)}))
try:
    order.add("tea", 420, 0)
except ValueError as err:
    print("refused:", err)

Run it with

python main.py

Output

LineItem(name='mug', price=Money(cents=1200, currency='EUR'), quantity=1)
29.00 EUR []
17.00 EUR
True 2
refused: quantity must be at least 1, got 0
  • The repr of a LineItem includes the repr of the Money inside it.
  • Order("bob").items is empty: every order gets its own list from default_factory.
  • max() works on Money because of order=True; it compares cents first, then currency.
  • The set holds two amounts: frozen Money instances are hashable, and equal ones count once.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 2

A contact as a data class

Rewrite Contact as a data class with the fields name, email and tags, where tags defaults to a new empty list for every contact. In __post_init__, strip and lowercase the email, and raise ValueError if it has no @. tag(label) adds a label only if it is not there yet. The starter shares one list between all contacts: the tests catch that.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Import dataclass and field, put @dataclass above the class, and replace __init__ with three annotated fields.

  2. Hint 2

    tags: list[str] = field(default_factory=list) gives every contact a new list.

  3. Hint 3

    In __post_init__(self) -> None, assign self.email = self.email.strip().lower() first, then check for "@".

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from dataclasses import dataclass, field


@dataclass
class Contact:
    name: str
    email: str
    tags: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        self.email = self.email.strip().lower()
        if "@" not in self.email:
            raise ValueError(f"not an email address: {self.email!r}")

    def tag(self, label: str) -> None:
        if label not in self.tags:
            self.tags.append(label)
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

class Contact:
    def __init__(self, name: str, email: str, tags: list[str] = []) -> None:
        self.name = name
        self.email = email
        self.tags = tags

    def tag(self, label: str) -> None:
        self.tags.append(label)

test_main.py

from main import Contact


def test_repr():
    """The data class shows its fields"""
    got = repr(Contact("Ada", "ada@example.com"))
    assert got == "Contact(name='Ada', email='ada@example.com', tags=[])", f"repr is {got}"


def test_equal():
    """Two contacts with the same data are equal"""
    assert Contact("Ada", "ada@example.com") == Contact("Ada", "ada@example.com"), "equal contacts compare as different"


def test_own_tags():
    """Every contact has its own tag list"""
    a = Contact("Ada", "ada@example.com")
    b = Contact("Bob", "bob@example.com")
    a.tag("vip")
    assert b.tags == [], f"tagging a also changed b.tags to {b.tags!r}"


def test_tag_once():
    """A tag is stored only once"""
    c = Contact("Ada", "ada@example.com")
    c.tag("vip")
    c.tag("vip")
    assert c.tags == ["vip"], f"after tagging vip twice, tags is {c.tags!r}"


def test_normalises_email():
    """The email is stripped and lowercased"""
    got = Contact("Ada", "  Ada@Example.COM ").email
    assert got == "ada@example.com", f"email is {got!r}"


def test_rejects_email():
    """An email without @ raises ValueError"""
    try:
        Contact("Ada", "ada.example.com")
    except ValueError:
        pass
    else:
        assert False, 'Contact("Ada", "ada.example.com") was accepted'

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

Version numbers that sort correctly

As text, "1.9.3" sorts after "1.10.0". Make Version a frozen, ordered data class with major, minor and patch (both default 0), and give it a __str__ that returns "1.10.0". parse_version(text) turns "1.10.0" into Version(1, 10, 0). newest(texts) returns the newest version as a string, comparing numbers, not text.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Change the decorator to @dataclass(frozen=True, order=True). Comparing then works field by field: major first, then minor, then patch.

  2. Hint 2

    In parse_version, split the text on "." and convert each part with int().

  3. Hint 3

    newest can parse every text, take max() of the Version objects, and return str() of the result.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

from dataclasses import dataclass


@dataclass(frozen=True, order=True)
class Version:
    major: int
    minor: int = 0
    patch: int = 0

    def __str__(self) -> str:
        return f"{self.major}.{self.minor}.{self.patch}"


def parse_version(text: str) -> Version:
    major, minor, patch = (int(part) for part in text.split("."))
    return Version(major, minor, patch)


def newest(texts: list[str]) -> str:
    return str(max(parse_version(text) for text in texts))
Run it on your computer

Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.py

from dataclasses import dataclass


@dataclass
class Version:
    major: int
    minor: int = 0
    patch: int = 0


def parse_version(text: str) -> Version:
    return Version(0)


def newest(texts: list[str]) -> str:
    return max(texts)

test_main.py

import dataclasses

from main import Version, newest, parse_version


def test_parse():
    """parse_version reads three numbers"""
    got = parse_version("1.10.3")
    assert got == Version(1, 10, 3), f'parse_version("1.10.3") is {got!r}'


def test_str():
    """str() gives the dotted form"""
    got = str(Version(2, 0, 1))
    assert got == "2.0.1", f"str(Version(2, 0, 1)) is {got!r}"


def test_order():
    """Versions compare number by number"""
    assert Version(1, 10, 0) > Version(1, 9, 3), "Version(1, 10, 0) is not greater than Version(1, 9, 3)"


def test_newest():
    """newest compares numbers, not text"""
    got = newest(["1.9.3", "1.10.0", "1.2.0"])
    assert got == "1.10.0", f"newest gave {got!r}"


def test_hashable():
    """Equal versions count once in a set"""
    got = len({parse_version("1.2.0"), parse_version("1.2.0"), parse_version("1.2.1")})
    assert got == 2, f"the set holds {got} versions, expected 2"


def test_frozen():
    """A version cannot be changed"""
    v = Version(1, 2, 3)
    try:
        v.major = 9  # type: ignore[misc]
    except dataclasses.FrozenInstanceError:
        pass
    else:
        assert False, "v.major = 9 was accepted; use frozen=True"

On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.

Run the program:

python main.py

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

A list as a default value

from dataclasses import dataclass


@dataclass
class Cart:
    owner: str
    items: list = []

What Python prints

ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Why, and the fix

A default is evaluated once, so every Cart would share the same list, and items added to one cart would show up in all of them. @dataclass catches this for lists, dicts and sets, and fails when the class is defined. Write items: list = field(default_factory=list), and import field from dataclasses.

A field without a default after one with a default

from dataclasses import dataclass


@dataclass
class Order:
    express: bool = False
    customer: str


print(Order("ada"))

What Python prints

TypeError: non-default argument 'customer' follows default argument 'express'

Why, and the fix

The generated __init__ takes the fields in the order they are written, and a parameter without a default cannot follow one with a default, just as in a normal def. Move the fields with defaults to the end: customer: str first, then express: bool = False.

Putting a normal data class in a set

from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


visited = {Point(0, 0), Point(1, 2)}
print(len(visited))

What Python prints

TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')

Why, and the fix

A data class with the generated __eq__ has no hash, because its fields could change after it is stored in a set. If the points never change, write @dataclass(frozen=True): frozen instances get a hash built from their fields, so they work in sets and as dict keys.

Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Annotated class variables become fields

@dataclass reads the class body and turns every class variable with a type annotation into a field. From the fields it writes __init__, __repr__ and __eq__ for you, in the order the fields appear. A field with a value, such as quantity: int = 1, gets a default, and fields with defaults must come after fields without. A class variable without an annotation is not a field. The annotations are not checked when the program runs: Point("a", "b") is accepted.

Safe defaults and __post_init__

A list, dict or set as a default would be shared by every instance, so @dataclass refuses items: list = [] with ValueError. Write items: list[str] = field(default_factory=list) instead: the factory is called for each new instance. After the generated __init__ has set the fields, it calls __post_init__ if the class defines one. That is the place to check values, raise ValueError, clean them up, or compute a field marked field(init=False), which is not a parameter of __init__.

frozen and order

The generated __eq__ compares the fields in order, like tuples, and only instances of the same class. With order=True you also get <, <=, > and >=, compared the same way, so sorted() and max() work. frozen=True makes assigning a field raise FrozenInstanceError. A frozen class with eq also gets __hash__, so instances can go in sets and be dict keys; a normal data class is unhashable. Frozen is shallow: a list inside a frozen instance can still change.

Sources

Last reviewed September 29, 2026