Functions and dictionaries are enough for many programs. A class becomes useful when a concept has related state and behavior that should travel together. A study session has a topic and duration; a study plan can collect sessions and report progress. Using modules and type hints, we can express those ideas without creating a large inheritance hierarchy.
Objects hold state and expose behavior
A class defines a kind of object, and an instance is one particular object of that class. An attribute stores data on an object. A method is a function accessed through an object; Python supplies the instance as its first argument, conventionally called self.
__init__ initializes a newly created instance. Names surrounded by double underscores are special methods used by Python's object model. You usually call StudyPlan() rather than calling __init__ yourself. The classes tutorial explains this relationship.
Combine a record with a stateful object
Save this entire example as plans.py and run python plans.py:
from dataclasses import dataclass
@dataclass(frozen=True)
class Session:
topic: str
minutes: int
def __post_init__(self) -> None:
if not isinstance(self.topic, str) or not self.topic.strip():
raise ValueError("topic must be nonempty text")
if type(self.minutes) is not int or self.minutes <= 0:
raise ValueError("minutes must be a positive integer")
class StudyPlan:
def __init__(self) -> None:
self._sessions: list[Session] = []
def add(self, session: Session) -> None:
self._sessions.append(session)
@property
def total_minutes(self) -> int:
return sum(session.minutes for session in self._sessions)
first = StudyPlan()
second = StudyPlan()
first.add(Session("loops", 25))
first.add(Session("files", 35))
print(first.total_minutes)
print(second.total_minutes)
print(Session("loops", 25) == Session("loops", 25))
Expect 60, 0, and True. Each plan gets its own list because that list is created inside __init__. A list declared as an ordinary class attribute would be shared across instances. The leading underscore in _sessions asks callers to treat it as internal; it does not prevent access.
The @property decorator makes total_minutes readable like an attribute while computing its value through a method. A decorator is a callable applied to a function or class definition. You do not need to write your own decorators to use existing ones. Keep properties quick and unsurprising; a property that starts a network request would be harder for callers to reason about.
Know what a dataclass provides
@dataclass generates routine methods from annotated fields, including initialization, representation, and value-based equality. __post_init__ runs after generated initialization, giving this record one validation point. Annotations alone would not enforce valid values.
With frozen=True, ordinary reassignment of fields is blocked. This helps make the session record stable, but it does not deeply freeze referenced objects. A frozen dataclass containing a list could still expose a mutable list. Dataclass behavior is documented in the dataclasses reference.
For a dataclass that deliberately owns a mutable collection, use a factory:
from dataclasses import dataclass, field
@dataclass
class ReadingList:
topics: list[str] = field(default_factory=list)
first = ReadingList()
second = ReadingList()
first.topics.append("exceptions")
print(first.topics)
print(second.topics)
This prints ['exceptions'] and []. The factory creates a new list for each instance. It expresses ownership clearly and avoids a shared default.
Prefer a small model
The plan contains sessions. This relationship is called composition. Inheritance instead describes one class as a specialized form of another, with inherited behavior and possible overrides. Use inheritance when the specialized object can honor the original class's contract; a similar-looking set of fields is not enough justification.
Python code often works with any object providing the methods it needs, an approach called duck typing. That flexibility does not remove the need for clear expectations. StudyPlan.add assumes a session-like object, as its annotation communicates. An external JSON record still needs validation before becoming an internal object.
Pitfalls and practice
Avoid building classes that merely wrap a single stateless function. Avoid getters and setters that add no useful rule. Put checks where invalid state first enters the model, and keep the public interface smaller than the internal representation.
Exercise: add a session_count property to StudyPlan. Then verify that a zero-minute session is rejected and that adding to one plan does not change another.
Solution guidance: return len(self._sessions) from the property. Expect count 2 for the first plan and 0 for the second. Constructing Session("loops", 0) should raise ValueError before it can be added. If plans share data, inspect where the list was created.

