🐍

Python Classes and OOP: Dataclasses, Properties, and Composition

Python4 min readUpdated October 2026

Go beyond the basics of Python classes. Learn dataclasses, properties, __repr__, and why composition often beats inheritance, with short runnable examples.

You know what a class is and how to write __init__. The next step is writing classes that are pleasant to use, hard to misuse, and easy to change later.

This guide covers the handful of habits that make the biggest difference: readable output, dataclasses, properties, and thinking in composition instead of deep inheritance.

Make your objects print properly

By default, printing an object gives you something useless:

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

b = Book("Dune", 412)
print(b)
<__main__.Book object at 0x7f...>

Add a __repr__ method and debugging gets much easier. It should return a string that shows what the object contains.

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

    def __repr__(self):
        return f"Book(title={self.title!r}, pages={self.pages})"

print(Book("Dune", 412))
Book(title='Dune', pages=412)

This one habit saves a surprising amount of time when you're hunting for bugs.

Let dataclasses do the boring part

Most small classes just hold data. Writing __init__, __repr__, and __eq__ by hand for each of them gets old fast. The dataclass decorator generates all three:

from dataclasses import dataclass

@dataclass
class Book:
    title: str
    pages: int
    in_stock: bool = True

a = Book("Dune", 412)
b = Book("Dune", 412)

print(a)
print(a == b)
Book(title='Dune', pages=412, in_stock=True)
True

You get a readable repr and value-based equality for free. Two books with the same fields are equal, which usually matches what you'd expect.

A few options worth knowing:

  • @dataclass(frozen=True) makes instances read-only, which is safer for values that shouldn't change.
  • @dataclass(order=True) lets you compare and sort objects.
  • For lists or dicts as defaults, use field(default_factory=list). Never write tags: list = [].
from dataclasses import dataclass, field

@dataclass
class Shelf:
    name: str
    books: list = field(default_factory=list)

s1 = Shelf("Fiction")
s2 = Shelf("Science")
s1.books.append("Dune")

print(s1.books)
print(s2.books)
['Dune']
[]

Each shelf gets its own list. A plain shared [] default would be reused across every instance, a classic Python bug.

Properties: validation without ugly getters

In some languages you write get_price() and set_price() for every field. In Python you start with a plain attribute. If you later need a rule, convert it to a property and nobody using your class has to change their code.

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price          # goes through the setter below

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("price can't be negative")
        self._price = value

p = Product("Mug", 12.5)
p.price = 15
print(p.price)

try:
    p.price = -3
except ValueError as error:
    print("Rejected:", error)
15
Rejected: price can't be negative

Properties are also handy for values calculated on the fly, like full_name built from first and last. The caller reads it like a normal attribute.

Inheritance: use it sparingly

Inheritance says "X is a kind of Y." That works when it's true:

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

class Dog(Animal):
    def speak(self):
        return f"{self.name} says woof"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow"

for pet in [Dog("Rex"), Cat("Tia")]:
    print(pet.speak())
Rex says woof
Tia says meow

Each subclass changes one method, and the loop doesn't care which type it's dealing with. That's polymorphism, and it's the good side of inheritance.

The bad side shows up when hierarchies grow deep. If you have Vehicle → Car → ElectricCar → SportsElectricCar, any change at the top ripples down through everything. Debugging means jumping between four files.

Composition: "has a" instead of "is a"

Composition builds a class out of smaller parts. Instead of asking "what is this?", you ask "what does it have?"

class Engine:
    def start(self):
        return "engine running"

class Battery:
    def start(self):
        return "battery powering on"

class Car:
    def __init__(self, power_source):
        self.power_source = power_source

    def start(self):
        return f"Car: {self.power_source.start()}"

print(Car(Engine()).start())
print(Car(Battery()).start())
Car: engine running
Car: battery powering on

Car doesn't care what kind of power source it has, as long as it has a start() method. You can add a hybrid or a hydrogen engine later without touching Car.

A decent rule of thumb: reach for composition first. Use inheritance when you have a clear "is a" relationship and a stable base class.

Duck typing and abstract classes

Python doesn't force you to declare interfaces. If it walks like a duck and quacks like a duck, it's treated as one. That's why Car above works with any object that has start().

When you do want to enforce a contract, use an abstract base class:

from abc import ABC, abstractmethod

class Exporter(ABC):
    @abstractmethod
    def export(self, data):
        ...

class CsvExporter(Exporter):
    def export(self, data):
        return ",".join(str(x) for x in data)

print(CsvExporter().export([1, 2, 3]))

try:
    Exporter()
except TypeError as error:
    print("Can't create it:", error)
1,2,3
Can't create it: Can't instantiate abstract class Exporter without an implementation for abstract method 'export'

Trying to create an abstract class directly, or a subclass that forgot to implement a required method, fails immediately with a clear message.

Common mistakes

  • Mutable class attributes. If you define items = [] at the class level, every instance shares it. Set lists and dicts inside __init__.
  • Overusing classes. If it's a single function with no state, it doesn't need to be a class.
  • Deep hierarchies. More than two or three levels is a warning sign.

Key takeaways

  • Add __repr__, or use @dataclass, so objects print clearly.
  • Start with plain attributes, and convert to @property when you need a rule.
  • Prefer composition ("has a") over inheritance ("is a") when in doubt.
  • Use abstract base classes only when you want to enforce a contract.

Try it yourself

Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.

Open the Python editor

Related Articles