Step 04 of 13

Classes & dataclasses

Model data and behaviour with classes: __init__, methods, special methods, properties, dataclasses, enums, and when to choose composition.

18 min 9 sections

Dictionaries are great for loose data, but they don't say what keys must exist, they can't validate themselves, and the functions that work on them are scattered around your code. Classes bundle data with the behaviour that belongs to it, under a name that means something in your domain: Order, Invoice, Temperature. This lesson covers writing classes, the special methods that make them feel built in, properties, and dataclasses, which remove most of the boilerplate. It ends with how to choose between a dict, a dataclass and a full class.

A first class

A class is a blueprint; calling it creates an instance:

python
class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0) -> None:
        self.owner = owner
        self.balance = balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount


acct = BankAccount("Ana", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct.owner, acct.balance)     # Ana 120
  • __init__ runs when you create an instance and sets up its attributes.
  • Every method's first parameter is the instance itself, called self by convention. acct.deposit(50) is really BankAccount.deposit(acct, 50).
  • Attributes are created by assigning to self.something. Each instance has its own.

The value of putting withdraw on the class is that the rule ("no overdrafts") lives in one place, next to the data it protects, instead of in every function that touches a balance.

Special methods

Methods with double underscores, called dunder methods, hook your class into Python's built-in behaviour. Without them, printing an account gives <__main__.BankAccount object at 0x10a3f2d50> and two accounts with identical data aren't equal.

python
class Money:
    def __init__(self, cents: int, currency: str = "EUR") -> None:
        self.cents = cents
        self.currency = currency

    def __repr__(self) -> str:                 # for developers: repr(), the REPL, debuggers
        return f"Money({self.cents}, {self.currency!r})"

    def __str__(self) -> str:                  # for users: print(), str(), f-strings
        return f"{self.cents / 100:.2f} {self.currency}"

    def __eq__(self, other: object) -> bool:   # ==
        if not isinstance(other, Money):
            return NotImplemented
        return (self.cents, self.currency) == (other.cents, other.currency)

    def __add__(self, other: "Money") -> "Money":   # +
        if self.currency != other.currency:
            raise ValueError("cannot add different currencies")
        return Money(self.cents + other.cents, self.currency)


price = Money(1999) + Money(500)
print(price)          # 24.99 EUR
print(repr(price))    # Money(2499, 'EUR')
print(price == Money(2499))   # True

Returning NotImplemented from __eq__ for unknown types lets Python try the other operand's comparison, and ultimately answer False, rather than crashing. Other common dunders: __len__ (len(x)), __iter__ (for ... in x), __contains__ (in), __lt__ (<, sorting), __hash__ (use as a dict key or set member).

Properties: computed and protected attributes

A property looks like an attribute but runs a method. Use it for values derived from other attributes, and to validate on assignment:

python
class Temperature:
    def __init__(self, celsius: float) -> None:
        self.celsius = celsius            # goes through the setter below

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32


t = Temperature(21)
print(t.fahrenheit)       # 69.8
t.celsius = -300          # ValueError: below absolute zero

The leading underscore in _celsius is a convention meaning "internal, don't touch from outside". Python doesn't enforce privacy; it trusts the convention.

Because properties look like attributes, you can start with a plain attribute and switch to a property later without changing any code that uses the class. So don't write getter and setter methods "just in case".

Dataclasses: classes without the boilerplate

Many classes are mostly data: a constructor that stores fields, a __repr__, an __eq__. Writing those by hand is repetitive and error-prone. The dataclasses module generates them from type-annotated fields:

python
from dataclasses import dataclass, field
from datetime import date


@dataclass
class LineItem:
    sku: str
    quantity: int
    unit_price_cents: int

    @property
    def total_cents(self) -> int:
        return self.quantity * self.unit_price_cents


@dataclass
class Order:
    id: str
    customer: str
    items: list[LineItem] = field(default_factory=list)
    created: date = field(default_factory=date.today)

    def add(self, item: LineItem) -> None:
        self.items.append(item)

    @property
    def total_cents(self) -> int:
        return sum(item.total_cents for item in self.items)


order = Order("o-1", "ana")
order.add(LineItem("PEN", 3, 120))
print(order)
# Order(id='o-1', customer='ana', items=[LineItem(sku='PEN', quantity=3, unit_price_cents=120)], created=datetime.date(2026, 10, 31))
print(order.total_cents)    # 360

@dataclass wrote __init__, __repr__ and __eq__ from the field list. It's still an ordinary class, so you add methods and properties as usual.

Useful options:

python
@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float
  • frozen=True makes instances immutable (assignment raises an error) and hashable, so they can be dict keys and set members. Use it for value objects: things defined entirely by their values, such as a point, a money amount or a date range.
  • slots=True (Python 3.10+) makes instances smaller and attribute access faster, and catches typos such as p.z = 1.
  • order=True generates <, > and friends, comparing fields in order.

For validation, add a __post_init__ method, which runs after the generated __init__:

python
@dataclass(frozen=True)
class DateRange:
    start: date
    end: date

    def __post_init__(self) -> None:
        if self.end < self.start:
            raise ValueError("end must not be before start")

Enums: a fixed set of choices

When a value can only be one of a few options, a string invites typos ("shiped"). An enum makes the options explicit:

python
from enum import StrEnum


class Status(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"


s = Status("paid")             # from a string, e.g. loaded from JSON
print(s is Status.PAID)        # True
print(s == "paid")             # True: StrEnum members are also strings
Status("shiped")               # ValueError: 'shiped' is not a valid Status

StrEnum (Python 3.11+) members compare equal to their string values and serialise cleanly to JSON. Use plain Enum when the values shouldn't behave like strings.

Inheritance and composition

A class can inherit from another, reusing its methods and overriding some:

python
class Notifier:
    def send(self, to: str, message: str) -> None:
        raise NotImplementedError


class EmailNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"email to {to}: {message}")


class SmsNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"sms to {to}: {message}")

Code that takes a Notifier works with either. That's the good use of inheritance: several interchangeable implementations of one role.

The bad use is inheriting to reuse code from a class that isn't really the same kind of thing. Deep hierarchies (Animal → Mammal → Dog → ServiceDog) become fragile: a change high up ripples down unpredictably. Prefer composition, where an object has other objects and delegates to them:

python
@dataclass
class OrderService:
    notifier: Notifier            # has-a, passed in

    def confirm(self, order: Order) -> None:
        self.notifier.send(order.customer, f"Order {order.id} confirmed")


OrderService(EmailNotifier()).confirm(order)

OrderService doesn't care which notifier it gets, and a test can pass in a fake that records messages. Python also supports protocols (structural interfaces) for this, covered in the type hints lesson.

Choosing a representation

UseWhen
dictLoose, dynamic data: parsed JSON you'll pass through, ad hoc grouping
@dataclassRecords with known fields: the default for domain data
@dataclass(frozen=True)Values defined by their contents: money, coordinates, date ranges
A regular classObjects with significant behaviour or internal state to protect
Enum / StrEnumA fixed set of named options

Practice

Model a shopping cart:

  1. A frozen dataclass Money holding cents: int and currency: str, with __add__, multiplication by an integer (__mul__), and a __str__ like 12.50 EUR. Adding different currencies raises ValueError.
  2. A dataclass CartItem with sku, name, unit_price: Money and quantity: int that validates quantity >= 1 in __post_init__, with a total property.
  3. A Cart class with add(item) (adding an existing SKU increases its quantity), remove(sku), __len__ (number of units) and a total property. An empty cart's total is 0.00 EUR.
Show solution
python
from dataclasses import dataclass


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

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"cannot add {self.currency} and {other.currency}")
        return Money(self.cents + other.cents, self.currency)

    def __mul__(self, factor: int) -> "Money":
        return Money(self.cents * factor, self.currency)

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


@dataclass
class CartItem:
    sku: str
    name: str
    unit_price: Money
    quantity: int = 1

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

    @property
    def total(self) -> Money:
        return self.unit_price * self.quantity


class Cart:
    def __init__(self, currency: str = "EUR") -> None:
        self.currency = currency
        self._items: dict[str, CartItem] = {}

    def add(self, item: CartItem) -> None:
        if item.unit_price.currency != self.currency:
            raise ValueError("item currency does not match cart")
        if item.sku in self._items:
            self._items[item.sku].quantity += item.quantity
        else:
            self._items[item.sku] = item

    def remove(self, sku: str) -> None:
        self._items.pop(sku, None)

    def __len__(self) -> int:
        return sum(item.quantity for item in self._items.values())

    @property
    def total(self) -> Money:
        total = Money(0, self.currency)
        for item in self._items.values():
            total = total + item.total
        return total


cart = Cart()
cart.add(CartItem("PEN", "Pen", Money(120), 3))
cart.add(CartItem("BAG", "Backpack", Money(3500)))
cart.add(CartItem("PEN", "Pen", Money(120), 2))
print(len(cart), cart.total)    # 6 41.00 EUR
cart.remove("BAG")
print(len(cart), cart.total)    # 5 6.00 EUR

Money is frozen because it's a value: two Money(120) are interchangeable, and nothing should change one in place. CartItem isn't frozen because the cart updates its quantity. Cart is a regular class with a private dict keyed by SKU, because it has behaviour (merging duplicates, checking currency) and internal structure that callers shouldn't depend on. Starting the total at Money(0, self.currency) is what makes an empty cart's total 0.00 EUR.

Key takeaways

  • Classes bundle data with the behaviour that protects it; __init__ sets attributes and methods receive the instance as self.
  • Special methods such as __repr__, __str__, __eq__ and __add__ make your types work with built-in syntax.
  • Properties give computed or validated attributes without getter and setter methods.
  • Dataclasses generate the boilerplate for data-centred classes; use field(default_factory=...) for mutable defaults and frozen=True for value objects.
  • Use enums for fixed choices, inheritance for interchangeable implementations of a role, and composition for most other reuse.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why does @dataclass reject items: list[str] = [] as a field default?

  2. 2.Which method controls what print(obj) shows?

  3. 3.What does frozen=True on a dataclass give you?

  4. 4.OrderService needs to send notifications by email in production and record them in tests. Which design fits best?

Finished reading?

Mark it complete to fill in your roadmap.