◀ Course contents Part 4 · Module 4-02

Dataclasses & Type Hints

Saying the shape once, and having Python write the rest

The class you wrote in the last module was thirty lines, and twenty-five of them were __init__, __repr__ and __eq__ doing exactly what anyone would have guessed. A dataclass writes those for you from a list of fields. Type hints are the other half: a way of saying what a value is supposed to be, checked by tools rather than by Python.

Ready?

1

Fields In, Three Methods Out

A dataclass is an ordinary class with the boring methods generated from the fields you list:

from dataclasses import dataclass

@dataclass
class Order:
    name: str
    quantity: int
    price: int

order = Order("tea", 3, 1250)
order            # Order(name='tea', quantity=3, price=1250)
order == Order("tea", 3, 1250)   # True

That is __init__, __repr__ and __eq__, all three of which you wrote by hand in the last module, and all three exactly as you wrote them.

The annotations are what makes a field a field. A line without one is a plain class attribute and will not appear in the generated __init__, which is a genuinely confusing way to lose a parameter.

Everything else about a class still works. Methods, properties and @property all behave normally — a dataclass only replaces the parts that were predictable.

Not for everything

A dataclass is right when the class is mostly a named shape with some behaviour attached. When there is real logic in __init__ — building things, opening things, deciding things — a normal class says so, and a dataclass with a large __post_init__ is a normal class wearing a decorator.

Quick check

What does @dataclass generate?

2

Defaults, and the One Python Refuses

A field can have a default, and the same ordering rule applies as for function parameters: everything with a default comes last.

@dataclass
class Order:
    name: str
    quantity: int
    price: int = 0
    express: bool = False

A mutable default is a different matter. Python does not let you make the module 3-02 mistake here at all:

@dataclass
class Item:
    tags: list = []     # ValueError, at class definition time

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

This is the only place in the language that catches it for you, and the error even names the fix:

from dataclasses import dataclass, field

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

default_factory takes the function that makes the default — list, not list() — and calls it once per instance. Exactly the defaultdict pattern, and exactly the fix for the shared class attribute from the last module.

Quick check

Why does tags: list = [] raise at definition time?

3

frozen, order, and Validating After Init

frozen=True makes instances immutable. Assigning to a field raises FrozenInstanceError, and — because the object can no longer change — instances become hashable, so they can be dictionary keys and set members.

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

seen = {Point(1, 2)}       # works; a normal dataclass could not

This is the tuple argument from module 2-06, with names on the fields. Use it for anything that represents a value rather than a thing with a life of its own.

order=True generates <, > and friends, comparing fields in declaration order — so the field you want to sort by goes first, and the rest break ties. Instances then sort with no key at all.

And __post_init__ runs immediately after the generated __init__, which is where validation goes:

def __post_init__(self):
    if self.quantity < 0:
        raise ValueError(f"quantity must not be negative: {self.quantity}")

Quick check

Why can a frozen dataclass be a dictionary key when an ordinary one cannot?

4

Hints Are for People and Tools, Not for Python

A type hint says what a value is supposed to be. Python records it and does nothing else with it:

def line_total(quantity: int, price: int) -> int:
    return quantity * price

line_total("3", "4")     # "3333" — no error, no complaint

Nothing is checked at runtime, ever. Hints exist so that a type checker — mypy, pyright, your editor — can find that mistake before the code runs, and so a reader can tell what a function expects without following it three levels down.

The syntax worth knowing:

names: list[str]
counts: dict[str, int]
pairs: tuple[str, int]
maybe: str | None          # either a string or nothing
def f(x: int) -> None:     # returns nothing useful

str | None is the one that earns its keep. It says out loud that a value can be missing, and a checker will then insist you deal with that before using it — which catches the class of bug where a None travels three functions before failing.

Function signatures

where a reader looks first, and where a wrong assumption costs most.

Dataclass fields

required anyway, and they are the shape of your data.

Anything that can be None

the single most useful thing a hint can tell you.

Every local variable

count: int = 0 is noise. The value says it.

A hint is a claim, not a guarantee

-> int does not stop a function returning a string. It only means somebody said it would not, and that a checker can now disagree with them. Without a checker in the build, hints are documentation that cannot go out of date silently — which is still worth something, and much less than it sounds.

Validate at the boundary with real code. Use hints to say what you meant.

Quick check

What happens at runtime when a function hinted -> int returns a string?

0 of 9 completed

Real Python runs right here in your browser — nothing to install, nothing sent to a server. The interpreter downloads once the first time you press Run, then stays cached.

01

To do

@dataclass generates __init__, __repr__ and __eq__ from a list of annotated fields — the three you wrote by hand in the last module, exactly as you wrote them.

from dataclasses import dataclass

@dataclass
class Order:
    name: str
    quantity: int

The annotation is what makes a field a field. A line without one is a plain class attribute and will not appear in the generated __init__ at all.

Your task: replace the hand-written class with a dataclass holding the same three fields. The output must not change:

Order(name='tea', quantity=3, price=1250)
True
False
your_code.py
Python
Hint

Import dataclass, put @dataclass above the class, and replace the whole body with three annotated lines: name: str, quantity: int, price: int.

Output

      
    02

    To do

    A field can have a default, and the same ordering rule applies as for function parameters: everything with a default comes after everything without one.

    @dataclass
    class Order:
        name: str
        quantity: int = 1
        express: bool = False

    Your task: the fields below are in an order Python rejects. Reorder them so the class defines, keeping every default, then print three orders:

    Order(name='tea', quantity=3, price=1250, express=True)
    Order(name='cup', quantity=1, price=0, express=False)
    Order(name='mat', quantity=5, price=300, express=False)
    your_code.py
    Python
    Hint

    name has no default, so it has to come first. The other three keep their defaults and their relative order: quantity, price, express.

    Output
    
          
      03

      To do

      This is the only place in the language that stops you making the mutable default mistake. Run it and read the error — it names the fix.

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

      field(default_factory=list) takes the function that makes the default — list, with no brackets — and calls it once per instance. The same shape as defaultdict(list), and the fix for the shared class attribute from the last module.

      Your task: fix the field so each item gets its own list:

      ['hot']
      []
      Item(name='tea', tags=['hot'])
      your_code.py
      Python
      Hint

      Import field alongside dataclass, then write tags: list[str] = field(default_factory=list). Pass the list type itself, not a list.

      Output
      
            
        04

        To do

        frozen=True makes instances immutable: assigning to a field raises FrozenInstanceError. And because the object can no longer change, it becomes hashable — so it can be a dictionary key or a set member, which an ordinary dataclass cannot.

        This is the tuple argument from module 2-06 with names on the fields. Use it for anything that represents a value rather than a thing with a life of its own.

        Your task: make Point frozen, so the assignment is refused and the set works:

        Point(x=1, y=2)
        cannot assign to field 'x'
        2
        True
        your_code.py
        Python
        Hint

        One change: @dataclass(frozen=True). Everything else already works once the class is frozen.

        Output
        
              
          05

          To do

          order=True generates the comparison methods, comparing fields in declaration order. So the field you want to sort by goes first, and the rest break ties.

          Instances then sort with no key argument at all, and min and max work on them too.

          Your task: make Result ordered and put the score first, so the results sort lowest score first:

          [Result(score=54, name='grace'), Result(score=71, name='ada'), Result(score=88, name='kenji')]
          Result(score=88, name='kenji')
          Result(score=54, name='grace')
          your_code.py
          Python
          Hint

          Two changes: @dataclass(order=True), and swap the two fields so score is declared first. The three calls at the bottom then need their arguments in the new order.

          Output
          
                
            06

            To do

            __post_init__ runs immediately after the generated __init__, with every field already assigned. That is where validation goes in a dataclass.

            def __post_init__(self):
                if self.quantity < 0:
                    raise ValueError(f"quantity must not be negative: {self.quantity}")

            Your task: add validation refusing a negative quantity and a price of zero or less, with the offending value in each message:

            Order(name='tea', quantity=3, price=1250)
            quantity must not be negative: -2
            price must be positive: 0
            your_code.py
            Python
            Hint

            def __post_init__(self): with the two checks inside, each raising ValueError with an f-string naming the value. It takes only self — the fields are already assigned by then.

            Output
            
                  
              07

              To do

              Python records annotations and does nothing else with them. Nothing is checked at runtime, ever.

              def line_total(quantity: int, price: int) -> int:
                  return quantity * price

              line_total("3", 4) # "3333" — no error at all

              Hints exist so a type checker — mypy, pyright, your editor — can find that before the code runs, and so a reader can tell what a function expects. When you need the guarantee at runtime, that is validation, and it is ordinary code.

              Your task: add hints to line_total, then show both halves: the wrong-typed call that Python allows, and a checked_total that validates and refuses it.

              3750
              3333
              quantity must be a whole number, got '3'
              your_code.py
              Python
              Hint

              Annotate line_total as (quantity: int, price: int) -> int and watch it still accept a string where an int was promised — "3" times 4 is "3333". In checked_total, use isinstance(quantity, int) and raise TypeError(f"quantity must be a whole number, got {quantity!r}") — the !r is what puts the quotes round the '3'.

              Output
              
                    
                08

                To do

                A bare list says almost nothing. list[str] says what is in it, which is the part a reader actually needs.

                names: list[str]
                counts: dict[str, int]
                pair: tuple[str, int]
                maybe: str | None

                str | None is the one that earns its keep: it says out loud that a value can be missing, and a checker will then insist you deal with that before using it.

                Your task: annotate summarise, which takes a list of names and returns a dictionary of name to length, and first_long, which returns the first name over four characters or None when there is none:

                {'tea': 3, 'whisk': 5, 'cloth': 5}
                whisk
                None
                your_code.py
                Python
                Hint

                summarise takes names: list[str] and returns dict[str, int]. first_long takes the same and returns str | None, because it genuinely might not find one.

                Output
                
                      
                  09

                  To do

                  A dataclass replaces only the predictable methods. Everything else about a class works exactly as before — methods, properties, the lot.

                  Your task: take the dataclass below and add a total property and an add_note method, with the notes list built per instance:

                  3750
                  12500
                  ['checked']
                  []

                  The last two lines are one order's notes after adding one, and a second order's, which must still be empty.

                  your_code.py
                  Python
                  Hint

                  notes: list[str] = field(default_factory=list) as the fourth field. Then @property above def total(self), and def add_note(self, text) appending to self.notes with no return.

                  Output
                  
                        

                    The typed inventory

                    To do

                    Two dataclasses and a function, fully annotated: one frozen value type, one record with validation and behaviour, and a report that sorts without a key.

                    Write Sku: a frozen dataclass with code: str and region: str. Frozen because it identifies a thing rather than being one, and because the report uses it as a dictionary key.

                    Write Item: an ordered dataclass whose fields are, in this order:

                    • value: int — quantity times price, filled in by __post_init__. Declared first so items sort by it.
                    • name: str
                    • quantity: int
                    • price: int
                    • tags: list[str] — its own list per item, defaulting to empty

                    __post_init__ sets value, and raises ValueError naming the offending number when the quantity is negative or the price is zero or less. Callers pass 0 for value and let the class work it out.

                    Write index(items) -> dict[Sku, Item]: fully annotated, keyed by a Sku built from the item name and the region it is given.

                    Then print exactly five lines:

                    Item(value=2250, name='cloth', quantity=5, price=450, tags=[])
                    [2250, 3750, 11880]
                    Sku(code='tea', region='eu')
                    True
                    price must be positive: 0

                    In order: the cheapest item, every item's value sorted ascending, one key from the index, whether that key is in the index, and the message from a rejected item.

                    your_code.py
                    Python
                    Hint

                    Sku is @dataclass(frozen=True) with two str fields. Item is @dataclass(order=True) with value declared first, tags using field(default_factory=list), and a __post_init__ that validates then sets self.value = self.quantity * self.price. index is annotated (items: list[Item], region: str) -> dict[Sku, Item] and builds Sku(item.name, region) as each key. min(items) works because the class is ordered, and the sorted values are a comprehension over sorted(items).

                    Output
                    
                          

                      Notification