Python Classes and Object-Oriented Programming Explained

Python Classes and Object-Oriented Programming Explained

Slug: python-classes-and-object-oriented-programming-explained
Author: Vandutz Academy Editorial Team
Category: Python Basics • Object-Oriented Programming

A class is most useful when a concept in your program needs coherent state and behavior, and the rules around that concept are growing. This article walks a practical path from loose data to a clear responsibility boundary, showing how and when to introduce a Python class. We cover instances, methods, validation, class versus instance attributes, composition, careful inheritance, and when a dictionary or function is enough.

From scattered values to a responsible unit

Imagine you’re building a to-do app. You start small—just track a task’s title, whether it’s done, and a due date. At first, you don’t need a class. Let’s walk the common path and see where a class naturally becomes the right tool.

Stage 1: A few variables

from datetime import date

title = "Write OOP article"
done = False
due_date = date(2026, 9, 1)

This is fine for quick scripts or one-off values. But there’s no single “thing” that represents the task—just separate variables. If you’re not yet comfortable with Python’s basic data types, see Python Variables and Data Types.

Stage 2: Bundle related fields in a dictionary

from datetime import date

task = {
    "title": "Write OOP article",
    "done": False,
    "due_date": date(2026, 9, 1),
}

A dictionary collects the fields into a single object you can pass around, serialize, and store. It’s a great starting point for many problems. For a refresher on keys, values, and safe lookups, see Python Dictionaries Explained.

As behavior appears, you may add functions that operate on task dictionaries:

from datetime import date

def is_overdue(task: dict) -> bool:
    d = task.get("due_date")
    return bool(d and d < date.today())

def rename(task: dict, new_title: str) -> None:
    if not new_title.strip():
        raise ValueError("Title cannot be empty.")
    task["title"] = new_title

The cracks show up when rules multiply:

  • Validation logic repeats across functions (“title cannot be empty”).
  • Typos in keys are runtime bugs (task["titel"] vs "title").
  • Unclear ownership of invariants: who ensures consistency after changes?

Stage 3: Introduce a class when rules and behavior grow

As the concept gets rules and a life of its own, a class gives it a clear boundary. A class groups data (state) and operations (behavior), and becomes the place where you enforce invariants consistently.

from datetime import date

class Task:
    def __init__(self, title: str, due_date: date | None = None) -> None:
        if not title or not title.strip():
            raise ValueError("Title cannot be empty.")
        if due_date is not None and not isinstance(due_date, date):
            raise TypeError("due_date must be a datetime.date or None.")
        self.title = title
        self.done = False
        self.due_date = due_date

    def mark_done(self) -> None:
        self.done = True

    def is_overdue(self) -> bool:
        return bool(self.due_date and self.due_date < date.today())

    def rename(self, new_title: str) -> None:
        if not new_title.strip():
            raise ValueError("Title cannot be empty.")
        self.title = new_title

# Usage
t = Task("Write OOP article", due_date=date(2026, 9, 1))
t.mark_done()

Instances and methods, in plain terms

  • Class: the blueprint (Task).
  • Instance: a concrete object created from the blueprint (t = Task(...)).
  • Instance attributes: data stored on each instance (t.title, t.done).
  • Methods: functions defined in the class that operate on an instance (t.mark_done()).

Python passes the instance as the first method argument, conventionally named self; you don’t pass it explicitly.

Validating and protecting invariants

Invariants are rules that should always hold true for a valid object. In Task, our invariants include:

  • Title must be non-empty.
  • due_date is either None or a datetime.date.

Validation belongs in __init__ and in any method that changes state. You can also use properties to validate during assignment and keep a clean attribute syntax:

from datetime import date

class Task:
    def __init__(self, title: str, due_date: date | None = None) -> None:
        self._set_title(title)
        self._due_date = None
        if due_date is not None:
            self.due_date = due_date  # triggers validation below
        self.done = False

    @property
    def title(self) -> str:
        return self._title

    @title.setter
    def title(self, value: str) -> None:
        self._set_title(value)

    def _set_title(self, value: str) -> None:
        if not value or not value.strip():
            raise ValueError("Title cannot be empty.")
        self._title = value

    @property
    def due_date(self) -> date | None:
        return self._due_date

    @due_date.setter
    def due_date(self, value: date | None) -> None:
        if value is not None and not isinstance(value, date):
            raise TypeError("due_date must be datetime.date or None.")
        self._due_date = value

For property semantics and other special hooks, see the Python Data Model.

Class attributes versus instance attributes

A class attribute is stored on the class and shared by all instances unless shadowed by an instance attribute of the same name. Use class attributes for constants and configuration that apply to every instance:

class Task:
    VALID_STATUSES = {"todo", "doing", "done"}  # class attribute

    def __init__(self, title: str, status: str = "todo") -> None:
        if status not in Task.VALID_STATUSES:
            raise ValueError(f"status must be one of {Task.VALID_STATUSES}")
        self.title = title           # instance attributes
        self.status = status

t1 = Task("Write")
t2 = Task("Edit", status="doing")

# Shadowing: setting on an instance does not change the class-level set
t1.VALID_STATUSES = {"todo"}  # now t1 has its own attribute of that name

Guidelines:

  • Prefer class attributes for constants (e.g., status choices, default formats).
  • Avoid mutable class attributes as per-instance defaults. If you need a default list or dict, set it per instance in __init__.
# Pitfall: all instances share the same tags list
class BadTask:
    tags = []  # mutable class attribute (shared!)

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

# Correct: create a new list per instance
class GoodTask:
    def __init__(self) -> None:
        self.tags = []  # instance attribute (unique per object)

Composition: build bigger things from simpler objects

Composition means one object holds other objects as parts. It’s usually the first choice for growing systems, because it keeps responsibilities focused and avoids tight coupling.

from datetime import date
from typing import Iterable

class Task:
    def __init__(self, title: str, due_date: date | None = None) -> None:
        if not title.strip():
            raise ValueError("Title cannot be empty.")
        self.title = title
        self.due_date = due_date
        self.done = False

    def mark_done(self) -> None:
        self.done = True

    def is_overdue(self, today: date | None = None) -> bool:
        today = today or date.today()
        return bool(self.due_date and self.due_date < today)

class TodoList:
    def __init__(self, name: str, tasks: Iterable[Task] = ()) -> None:
        self.name = name
        self._tasks: list[Task] = list(tasks)

    def add(self, task: Task) -> None:
        self._tasks.append(task)

    def done_count(self) -> int:
        return sum(1 for t in self._tasks if t.done)

    def overdue(self, today: date | None = None) -> list[Task]:
        return [t for t in self._tasks if t.is_overdue(today)]

    def __iter__(self):
        return iter(self._tasks)

TodoList composes multiple Task instances. The list type here matters—tuples are immutable, lists are mutable. If you need to review the trade-offs, see Python Lists vs Tuples.

When a dictionary or function is enough

You do not need a class just to be “professional.” Stick with dictionaries and functions when:

  • Data is short-lived or only passed through a few functions.
  • Rules are minimal and validation is simple or not needed.
  • You are mostly transforming data (e.g., parsing, filtering, mapping) without object lifecycle concerns.

Also consider standard library tools like dataclasses for plain data carriers with minimal behavior:

from dataclasses import dataclass
from datetime import date

@dataclass
class SimpleTask:
    title: str
    due_date: date | None = None
    done: bool = False

You can add validation to a dataclass via __post_init__ if needed, but once rules and methods start to grow, a regular class boundary with explicit methods keeps responsibilities clearer. If you hit type or key errors while experimenting, you might find Common Python Errors and How to Fix Them helpful.

Decision checklist: do you need a class yet?

QuestionIf “Mostly No”If “Mostly Yes”Notes
Are there invariants to enforce (e.g., fields must be validated on every change)?Keep simple dicts and functions.Introduce a class to centralize validation.Class concentrates rules in one place.
Do multiple functions operate on the same related fields?Standalone functions may be fine.Methods on a class improve cohesion.Reduces parameter lists and key typos.
Will instances be long-lived or passed broadly?Ad-hoc structures can suffice.A class offers a stable interface.Easier to evolve behavior safely.
Do you need to prevent invalid intermediate states?Not necessary.Use methods/properties to guard state.Protects invariants between steps.
Do you need polymorphic behavior (same interface, different implementations)?Probably not a class hierarchy yet.Define a common protocol or base class.Favor composition; inherit carefully.
Is the data primarily transformed rather than “living”?Functions + dicts suffice.Objects with methods clarify lifecycle.Transform pipelines rarely need classes.
Do tests repeat setup and validation patterns?Keep it flat.Class centralizes setup and validation.Improves test clarity and reuse.
Are there constants shared by all instances?Module-level constants are fine.Class attributes group related config.Avoid mutable shared defaults.
Are there collaborating concepts (list of tasks, line items, etc.)?Keep them as separate lists/dicts.Model composition explicitly.Clearer boundaries, easier evolution.

Designing methods with clear intent

Good methods do one thing, use descriptive names, and keep invalid states out. A helpful pattern distinguishes commands (change state) from queries (read state):

class Budget:
    def __init__(self, limit: float) -> None:
        if limit < 0:
            raise ValueError("limit must be non-negative")
        self.limit = limit
        self._spent = 0.0

    # Commands (change state)
    def record_purchase(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("amount must be positive")
        if self._spent + amount > self.limit:
            raise ValueError("would exceed budget limit")
        self._spent += amount

    # Queries (read state)
    def remaining(self) -> float:
        return self.limit - self._spent

    def is_over_budget(self) -> bool:
        return self._spent > self.limit

Putting it all together: a small, cohesive domain

from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Iterable

@dataclass(frozen=True)
class Money:
    amount: int  # cents
    currency: str = "USD"

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Money cannot be negative.")

class LineItem:
    def __init__(self, title: str, price: Money, quantity: int = 1) -> None:
        if not title.strip():
            raise ValueError("Title cannot be empty.")
        if quantity <= 0:
            raise ValueError("Quantity must be positive.")
        self.title = title
        self.price = price
        self.quantity = quantity

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

class Order:
    TAX_RATE = 0.1  # 10% as an example (use config in real code)

    def __init__(self, items: Iterable[LineItem], due: date | None = None) -> None:
        self._items = list(items)
        self.due = due
        self.paid = False

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

    def subtotal(self) -> Money:
        if not self._items:
            return Money(0, "USD")
        currency = self._items[0].price.currency
        total_cents = sum(i.total().amount for i in self._items)
        return Money(total_cents, currency)

    def tax(self) -> Money:
        s = self.subtotal()
        return Money(int(s.amount * self.TAX_RATE), s.currency)

    def total(self) -> Money:
        s = self.subtotal()
        t = self.tax()
        return Money(s.amount + t.amount, s.currency)

    def mark_paid(self) -> None:
        self.paid = True

This miniature domain mixes composition (Order has LineItems; LineItem has Money) and class attributes (TAX_RATE) with focused methods (subtotal, tax, total, mark_paid). Replace magic numbers like tax rates with configuration in production code.

Common beginner questions

Do I need getters and setters for every attribute?
No. Prefer direct access (task.title). If you later need validation or computed behavior, add a @property without changing callers:

class Task:
    def __init__(self, title: str) -> None:
        self._title = title

    @property
    def title(self) -> str:
        return self._title

    @title.setter
    def title(self, value: str) -> None:
        if not value.strip():
            raise ValueError("Title cannot be empty.")
        self._title = value
Should I use inheritance or composition?
Default to composition. Reach for inheritance when there is a strong “is-a” relationship and you need polymorphism. If you only want to reuse code, composition plus helper functions is usually more flexible.
What about dataclasses—are they classes?
Yes. @dataclass generates common methods (__init__, __repr__, comparisons). They shine for simple data carriers. As behavior and validation grow, add methods and properties as you would in any class.

The main takeaway: choose a class when a concept needs a clear home for both state and behavior, and your domain rules are accumulating. Otherwise, let simple data and functions carry the load.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top