Toolmingo
Guides10 min read

Python OOP Guide: Classes, Inheritance, and Design Patterns

Master Python object-oriented programming — classes, __init__, inheritance, dunder methods, properties, dataclasses, ABCs, and design patterns with practical examples.

Python classes let you bundle data and behaviour into reusable blueprints. This guide covers everything from basic class syntax to advanced patterns — inheritance, dunder methods, properties, dataclasses, abstract base classes, and common OOP design patterns — with runnable examples throughout.


Quick Reference

Concept Syntax
Define a class class Dog:
Constructor def __init__(self, name):
Create instance dog = Dog("Rex")
Instance method def bark(self):
Class method @classmethod def create(cls):
Static method @staticmethod def helper():
Inheritance class Puppy(Dog):
Call parent super().__init__(name)
Property getter @property def name(self):
Property setter @name.setter def name(self, v):
Dunder repr def __repr__(self):
Dataclass @dataclass class Point:
Abstract class class Animal(ABC):

Defining a Class

class Dog:
    # Class variable — shared by all instances
    species = "Canis lupus familiaris"

    def __init__(self, name: str, age: int) -> None:
        # Instance variables — unique per instance
        self.name = name
        self.age = age

    def bark(self) -> str:
        return f"{self.name} says: Woof!"

    def __repr__(self) -> str:
        return f"Dog(name={self.name!r}, age={self.age})"


rex = Dog("Rex", 3)
print(rex.bark())        # Rex says: Woof!
print(rex.species)       # Canis lupus familiaris
print(Dog.species)       # Same — class variable
print(repr(rex))         # Dog(name='Rex', age=3)

Instance vs Class vs Static Methods

class Circle:
    _count = 0  # class variable

    def __init__(self, radius: float) -> None:
        self.radius = radius
        Circle._count += 1

    # Instance method — receives self
    def area(self) -> float:
        import math
        return math.pi * self.radius ** 2

    # Class method — receives cls, not self
    @classmethod
    def unit(cls) -> "Circle":
        """Factory: create a unit circle."""
        return cls(radius=1.0)

    @classmethod
    def count(cls) -> int:
        return cls._count

    # Static method — no self or cls, just a utility
    @staticmethod
    def from_diameter(d: float) -> "Circle":
        return Circle(d / 2)


c = Circle(5)
print(c.area())              # 78.539...
unit = Circle.unit()
print(unit.radius)           # 1.0
big = Circle.from_diameter(10)
print(big.radius)            # 5.0
print(Circle.count())        # 3

Inheritance

class Animal:
    def __init__(self, name: str) -> None:
        self.name = name

    def speak(self) -> str:
        raise NotImplementedError("Subclasses must implement speak()")

    def __repr__(self) -> str:
        return f"{type(self).__name__}({self.name!r})"


class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name}: Woof!"


class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name}: Meow!"


animals: list[Animal] = [Dog("Rex"), Cat("Whiskers"), Dog("Buddy")]
for animal in animals:
    print(animal.speak())
# Rex: Woof!
# Whiskers: Meow!
# Buddy: Woof!

Calling the Parent with super()

class Vehicle:
    def __init__(self, make: str, model: str) -> None:
        self.make = make
        self.model = model

    def description(self) -> str:
        return f"{self.make} {self.model}"


class ElectricCar(Vehicle):
    def __init__(self, make: str, model: str, range_km: int) -> None:
        super().__init__(make, model)      # call Vehicle.__init__
        self.range_km = range_km

    def description(self) -> str:
        base = super().description()       # call Vehicle.description
        return f"{base} (electric, {self.range_km} km range)"


tesla = ElectricCar("Tesla", "Model 3", 500)
print(tesla.description())
# Tesla Model 3 (electric, 500 km range)

Multiple Inheritance and MRO

class Flyable:
    def fly(self) -> str:
        return "I can fly"


class Swimmable:
    def swim(self) -> str:
        return "I can swim"


class Duck(Animal, Flyable, Swimmable):
    def speak(self) -> str:
        return f"{self.name}: Quack!"


duck = Duck("Donald")
print(duck.speak())   # Donald: Quack!
print(duck.fly())     # I can fly
print(duck.swim())    # I can swim

# Method Resolution Order
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Animal'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)

Essential Dunder Methods

Dunder (double-underscore) methods let your classes work with Python's built-in operators.

class Vector:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    # String representations
    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

    def __str__(self) -> str:
        return f"({self.x}, {self.y})"

    # Arithmetic operators
    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other: "Vector") -> "Vector":
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar: float) -> "Vector":
        return Vector(self.x * scalar, self.y * scalar)

    # Comparison
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    # Length (makes len() work)
    def __abs__(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

    # Makes the object callable
    def __call__(self, scale: float) -> "Vector":
        return Vector(self.x * scale, self.y * scale)


v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)       # (4, 6)
print(v1 * 3)        # (3, 6)
print(abs(v2))       # 5.0
print(v1 == Vector(1, 2))  # True
print(v1(10))        # (10, 20)

Container Dunder Methods

class Playlist:
    def __init__(self) -> None:
        self._songs: list[str] = []

    def add(self, song: str) -> None:
        self._songs.append(song)

    def __len__(self) -> int:
        return len(self._songs)

    def __getitem__(self, index: int) -> str:
        return self._songs[index]

    def __contains__(self, song: str) -> bool:
        return song in self._songs

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

    def __repr__(self) -> str:
        return f"Playlist({self._songs!r})"


pl = Playlist()
pl.add("Bohemian Rhapsody")
pl.add("Stairway to Heaven")

print(len(pl))                        # 2
print(pl[0])                          # Bohemian Rhapsody
print("Stairway to Heaven" in pl)     # True
for song in pl:
    print(song)

Properties

Use @property to expose attributes with validation and computed values.

class Temperature:
    def __init__(self, celsius: float) -> None:
        self._celsius = celsius    # single leading _ = "private by convention"

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

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

    @property
    def fahrenheit(self) -> float:
        """Computed property — no setter needed."""
        return self._celsius * 9 / 5 + 32

    @property
    def kelvin(self) -> float:
        return self._celsius + 273.15


t = Temperature(100)
print(t.fahrenheit)   # 212.0
print(t.kelvin)       # 373.15

t.celsius = 0
print(t.fahrenheit)   # 32.0

t.celsius = -300      # ValueError: Temperature below absolute zero

Dataclasses

@dataclass generates __init__, __repr__, and __eq__ automatically.

from dataclasses import dataclass, field


@dataclass
class Point:
    x: float
    y: float

    def distance_to(self, other: "Point") -> float:
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5


p1 = Point(0, 0)
p2 = Point(3, 4)
print(p1)                    # Point(x=0, y=0)
print(p1 == Point(0, 0))     # True
print(p1.distance_to(p2))    # 5.0

Dataclass with Defaults and Post-init

from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Order:
    item: str
    quantity: int
    price_per_unit: float
    created_at: datetime = field(default_factory=datetime.now)
    tags: list[str] = field(default_factory=list)
    total: float = field(init=False)      # not in __init__

    def __post_init__(self) -> None:
        # runs after __init__
        self.total = self.quantity * self.price_per_unit

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


order = Order("Laptop", 2, 999.99)
print(order.total)          # 1999.98
order.add_tag("electronics")
print(order.tags)           # ['electronics']

Frozen Dataclass (Immutable)

@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float


coord = Coordinate(43.84, 18.36)
coord.lat = 0   # FrozenInstanceError — immutable!

# Frozen dataclasses are hashable and can be dict keys
locations = {coord: "Sarajevo"}

Abstract Base Classes

Use ABC to enforce that subclasses implement specific methods.

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        ...

    @abstractmethod
    def perimeter(self) -> float:
        ...

    def describe(self) -> str:
        return f"{type(self).__name__}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"


class Rectangle(Shape):
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)


class Circle(Shape):
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        import math
        return math.pi * self.radius ** 2

    def perimeter(self) -> float:
        import math
        return 2 * math.pi * self.radius


shapes: list[Shape] = [Rectangle(4, 5), Circle(3)]
for s in shapes:
    print(s.describe())
# Rectangle: area=20.00, perimeter=18.00
# Circle: area=28.27, perimeter=18.85

# Shape()  # TypeError: Can't instantiate abstract class

Class Patterns

Singleton

class Config:
    _instance: "Config | None" = None

    def __new__(cls) -> "Config":
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self) -> None:
        # Guard so __init__ only runs once
        if not hasattr(self, "_initialised"):
            self._initialised = True
            self.debug = False
            self.log_level = "INFO"


a = Config()
b = Config()
print(a is b)   # True — same object

Mixin

class TimestampMixin:
    """Add created_at / updated_at to any class."""
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        from datetime import datetime
        self.created_at = datetime.now()
        self.updated_at = datetime.now()

    def touch(self) -> None:
        from datetime import datetime
        self.updated_at = datetime.now()


class User(TimestampMixin):
    def __init__(self, name: str) -> None:
        super().__init__()
        self.name = name


user = User("Alice")
print(user.created_at)
user.touch()

Context Manager

class ManagedFile:
    def __init__(self, path: str, mode: str = "r") -> None:
        self.path = path
        self.mode = mode
        self._file = None

    def __enter__(self):
        self._file = open(self.path, self.mode)
        return self._file

    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        if self._file:
            self._file.close()
        return False   # don't suppress exceptions


with ManagedFile("data.txt", "w") as f:
    f.write("hello")
# file is automatically closed after the block

Name Mangling and Access Control

Python has no true private attributes, but conventions and name mangling exist:

class BankAccount:
    def __init__(self, balance: float) -> None:
        self.owner = "Public"           # public
        self._balance = balance         # "private by convention"
        self.__pin = "1234"             # name-mangled → _BankAccount__pin

    def check_pin(self, pin: str) -> bool:
        return self.__pin == pin


account = BankAccount(1000)
print(account.owner)             # Public
print(account._balance)          # 1000  (accessible but "don't touch")
# print(account.__pin)           # AttributeError
print(account._BankAccount__pin) # "1234"  (mangled name still accessible)

Common Mistakes

Mistake Problem Fix
def __init__(self) forgetting self on first param TypeError on call Always include self
Mutable default argument in __init__ Shared state between instances Use field(default_factory=list) or None guard
Forgetting super().__init__() Parent not initialised Always call super().__init__() in children
__eq__ without __hash__ Object becomes unhashable Define __hash__ or use @dataclass(frozen=True)
Using is for value comparison None check is fine, everything else risky Use == for value equality
class Foo(object) in Python 3 Redundant, all classes inherit from object Just class Foo:
Calling classmethod on instance Works but misleading Call class methods on the class: Foo.create()

Quick Patterns Cheat Sheet

# 1. Named constructor pattern
class Color:
    def __init__(self, r: int, g: int, b: int) -> None:
        self.r, self.g, self.b = r, g, b

    @classmethod
    def from_hex(cls, hex_color: str) -> "Color":
        hex_color = hex_color.lstrip("#")
        r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
        return cls(r, g, b)

    @classmethod
    def white(cls) -> "Color":
        return cls(255, 255, 255)


red = Color.from_hex("#FF0000")
white = Color.white()


# 2. Fluent interface (method chaining)
class QueryBuilder:
    def __init__(self, table: str) -> None:
        self._table = table
        self._conditions: list[str] = []
        self._limit: int | None = None

    def where(self, condition: str) -> "QueryBuilder":
        self._conditions.append(condition)
        return self

    def limit(self, n: int) -> "QueryBuilder":
        self._limit = n
        return self

    def build(self) -> str:
        sql = f"SELECT * FROM {self._table}"
        if self._conditions:
            sql += " WHERE " + " AND ".join(self._conditions)
        if self._limit is not None:
            sql += f" LIMIT {self._limit}"
        return sql


query = QueryBuilder("users").where("age > 18").where("active = TRUE").limit(10).build()
print(query)
# SELECT * FROM users WHERE age > 18 AND active = TRUE LIMIT 10


# 3. __slots__ for memory efficiency
class Coordinate:
    __slots__ = ("lat", "lon")   # no __dict__ created

    def __init__(self, lat: float, lon: float) -> None:
        self.lat = lat
        self.lon = lon

FAQ

What is self in Python?
self is a reference to the current instance. It's passed automatically when you call an instance method. The name self is a convention — Python doesn't require it, but everyone uses it.

What's the difference between __str__ and __repr__?
__repr__ is for developers — it should be unambiguous and ideally eval()-able to recreate the object. __str__ is for end users — a readable description. If only __repr__ is defined, Python uses it for both.

When should I use a dataclass vs a regular class?
Use @dataclass when the primary purpose of the class is to hold data (like a struct). Use regular classes when you need complex behaviour, computed state, or a class hierarchy with custom __init__ logic.

What's the difference between class variables and instance variables?
Class variables are shared across all instances. Instance variables are per-instance. If you assign to a class variable through an instance (self.x = ...), Python creates a new instance variable that shadows the class variable for that instance only.

How do I make a class hashable?
Define both __eq__ and __hash__. If you define __eq__ without __hash__, Python sets __hash__ to None making the object unhashable. Use @dataclass(frozen=True) for the easiest path to a hashable, immutable value object.

When should I use ABC vs raising NotImplementedError?
Use ABC with @abstractmethod — it prevents instantiation of the base class at class definition time, giving a clear error immediately. Raising NotImplementedError in a method body only fails at call time, which is harder to catch early.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools