Step 04 of 13
Classes & dataclasses
Model data and behaviour with classes: __init__, methods, special methods, properties, dataclasses, enums, and when to choose composition.
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:
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
selfby convention.acct.deposit(50)is reallyBankAccount.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.
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)) # TrueReturning 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:
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 zeroThe 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:
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:
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: floatfrozen=Truemakes 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 asp.z = 1.order=Truegenerates<,>and friends, comparing fields in order.
For validation, add a __post_init__ method, which runs after the generated __init__:
@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:
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 StatusStrEnum (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:
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:
@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
| Use | When |
|---|---|
dict | Loose, dynamic data: parsed JSON you'll pass through, ad hoc grouping |
@dataclass | Records with known fields: the default for domain data |
@dataclass(frozen=True) | Values defined by their contents: money, coordinates, date ranges |
| A regular class | Objects with significant behaviour or internal state to protect |
Enum / StrEnum | A fixed set of named options |
Practice
Model a shopping cart:
- A frozen dataclass
Moneyholdingcents: intandcurrency: str, with__add__, multiplication by an integer (__mul__), and a__str__like12.50 EUR. Adding different currencies raisesValueError. - A dataclass
CartItemwithsku,name,unit_price: Moneyandquantity: intthat validatesquantity >= 1in__post_init__, with atotalproperty. - A
Cartclass withadd(item)(adding an existing SKU increases its quantity),remove(sku),__len__(number of units) and atotalproperty. An empty cart's total is0.00 EUR.
Show solution
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 EURMoney 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 asself. - 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 andfrozen=Truefor 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.Why does @dataclass reject items: list[str] = [] as a field default?
2.Which method controls what print(obj) shows?
3.What does frozen=True on a dataclass give you?
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.