Basics

Classes and OOP in Python: __init__, self, methods, inheritance and dataclass

Updated: · Skillaria.Top

OOP in Python is built on classes: a class describes fields and methods, and objects (instances) are created by calling the class; __init__ fills in the fields, and self refers to the current object. Inheritance lets you reuse a parent's behaviour and override some of its methods, and the @dataclass decorator generates __init__, __repr__ and __eq__ for classes that mainly store data.

A class in Python is a blueprint: it describes what data an object stores and what can be done with it. An object (an instance) is a specific item made from that blueprint. Python OOP rests on three things: classes with methods, inheritance and special methods such as __init__ and __repr__. Let us go through everything in order using one running example — tracking expenses.

Why use classes when there are dictionaries

You can store data in a dictionary: {"title": "Coffee", "amount": 250}. The problem is that a dictionary does not know which keys are correct. A typo like expense.get("amout", 0) raises no error — it returns zero, and the report quietly starts lying.

An object of a class has its fields declared in advance. Accessing a non-existent attribute immediately fails with AttributeError, and modern Python even suggests the correct name. In addition, a class gathers the data and the actions on it in one place: an expense can compute its amount with tax by itself, and a report computes its total by itself.

How to create a class in Python

A class is declared with the word class; by PEP 8 convention, class names are written in CamelCase. The simplest class example:

class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

coffee = Expense("Coffee", 250)
taxi = Expense("Taxi", 300)
print(coffee.title, coffee.amount)   # Coffee 250

The call Expense("Coffee", 250) creates a new instance of the class. coffee and taxi are two different objects of the same type, each with its own field values. You can check membership with isinstance(coffee, Expense) — it returns True.

The __init__ constructor: initialising a class

__init__ is often called the class constructor, though "initialiser" is more accurate. The object itself is created by the __new__ method, and __init__ is called right after that and places the passed arguments into the fields. It does not need to return anything (strictly speaking, it must return None).

Inside __init__ it is useful to validate input data, so that obviously incorrect objects never appear in the program:

class Expense:
    def __init__(self, title, amount):
        if amount < 0:
            raise ValueError("The amount cannot be negative")
        self.title = title
        self.amount = amount

What self means in Python

self is a reference to the very object the method is currently working with. When you write coffee.with_tax(), Python effectively calls Expense.with_tax(coffee) and passes the object as the first argument. That is why self comes first in the method declaration, and why it is not written at the call site.

self is not a keyword but a convention: technically the parameter can be called anything, but nobody does that. A common beginner mistake is forgetting self in the method declaration. The call then fails with TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given.

Class methods and special methods

A method is a function declared inside a class. It sees the object's fields through self:

class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    def with_tax(self, rate=0.2):
        return self.amount * (1 + rate)

    def __repr__(self):
        return f"Expense({self.title!r}, {self.amount})"

coffee = Expense("Coffee", 250)
print(coffee.with_tax())   # 300.0
print(coffee)              # Expense('Coffee', 250)

Without __repr__, the output would look like <__main__.Expense object at 0x7f...> — useless for debugging. Methods with double underscores on both sides are called special (dunder) methods: Python calls them itself. __str__ is responsible for a "pretty" output for the user, __eq__ for comparison with ==, and __len__ for len(obj).

@classmethod and @staticmethod

An ordinary method works with an instance. A method decorated with @classmethod receives the class itself (cls) as its first argument — it is often used as an alternative constructor. A static method marked @staticmethod receives neither the object nor the class: it is an ordinary function that logically belongs to the class.

class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    @classmethod
    def from_csv(cls, line):
        title, amount = line.split(";")
        return cls(title, float(amount))

    @staticmethod
    def is_valid_amount(value):
        return value >= 0

e = Expense.from_csv("Lunch;520")
print(e.amount)                    # 520.0
print(Expense.is_valid_amount(-1)) # False

A class destructor: __del__

Python has a __del__ method, which is called before the object is destroyed by the garbage collector. Do not rely on it for releasing resources: the moment of the call is not guaranteed. For files and connections use the with context manager or an explicit close() method.

Class attributes and instance attributes

Fields set through self.name = ... belong to a specific object. Variables declared directly in the class body are class attributes: they are shared by all instances. This is where class constants are usually stored.

class Expense:
    CURRENCY = "USD"          # a class attribute, a constant

    def __init__(self, title, amount):
        self.title = title    # instance attributes
        self.amount = amount

a = Expense("Coffee", 250)
b = Expense("Tea", 150)
print(a.CURRENCY, Expense.CURRENCY)   # USD USD

A dangerous trap is a mutable class attribute. If you declare rows = [] in the class body and add to it through self.rows.append(...), the list becomes shared by all objects. Always create mutable fields in __init__.

You can inspect an object's fields with vars(obj) or obj.__dict__, and read an attribute by its name from a string with getattr(obj, "amount").

Class properties: @property

A property is a method that is read from outside like an ordinary field, without brackets. This is convenient for computed values: the report's total does not need to be stored separately, and you will not forget to update it.

class Report:
    def __init__(self):
        self.rows = []

    def add(self, expense):
        self.rows.append(expense)

    @property
    def total(self):
        return sum(e.amount for e in self.rows)

r = Report()
r.add(Expense("Coffee", 250))
r.add(Expense("Taxi", 300))
print(r.total)   # 550

If you need to control writing, add a setter to the property with @total.setter. Without a setter, the assignment r.total = 0 raises AttributeError.

A word on encapsulation. Python has no truly private fields. A single underscore (_cache) is an agreement "do not touch from outside", while two (__secret) trigger name mangling, so that a subclass does not accidentally overwrite the field.

Class inheritance in Python

Inheritance lets you create a child class based on a parent class: the child gets all the fields and methods, and overrides the ones it needs. The parent class is named in parentheses.

class MonthlyReport(Report):
    def __init__(self, month):
        super().__init__()        # call the parent's __init__
        self.month = month

    def add(self, expense):       # method override
        print(f"[{self.month}] + {expense}")
        super().add(expense)      # keep the parent's behaviour

m = MonthlyReport("October")
m.add(Expense("Coffee", 250))   # [October] + Expense('Coffee', 250)
print(m.total)                  # 250 — the property is inherited

super() returns a proxy object for calling the parent's methods. Forgetting super().__init__() in a child's __init__ is a classic mistake: the rows field is never created, and the first add fails with AttributeError.

You can check the relationship with issubclass(MonthlyReport, Report). All classes in Python 3 implicitly inherit from object — this is where the base __repr__, __eq__ and other methods come from. Python also supports multiple inheritance (class C(A, B)); the lookup order of methods is shown by C.__mro__.

An abstract class in Python

An abstract class defines an interface: which methods subclasses must implement. You cannot create an instance of it. The abc module serves this purpose:

from abc import ABC, abstractmethod

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

class CsvExporter(Exporter):
    def export(self, report):
        return "\n".join(f"{e.title};{e.amount}" for e in report.rows)

Exporter()   # TypeError: Can't instantiate abstract class Exporter ...

A subclass that does not implement all abstract methods also remains abstract, and will raise the same error when you try to create an object from it.

Composition instead of deep hierarchies

Once you have met inheritance, you may want to build long ladders: a base expense, a monetary expense, an expense in dollars — five levels deep. Python code rarely does this. Inheritance describes an "is-a" relationship, while composition describes "has-a": a report is not a list of expenses, it contains one in its rows field.

A practical rule: if you only need to use another object's ability, store the object in a field rather than inheriting from it. For data, a dataclass is often enough, and the logic can live in ordinary functions.

Dataclasses in Python: a class without boilerplate

For classes that mainly store data, writing __init__, __repr__ and __eq__ by hand is tedious. The @dataclass decorator from the dataclasses module generates them from the field annotations:

from dataclasses import dataclass, field

@dataclass
class Expense:
    title: str
    amount: float
    tags: list[str] = field(default_factory=list)

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

e = Expense("Coffee", 250)
print(e)                           # Expense(title='Coffee', amount=250, tags=[])
print(e == Expense("Coffee", 250))   # True

Value checks that used to be written in __init__ move to __post_init__ — it is called right after the generated initialiser. Mutable default values are set only through field(default_factory=list): the dataclass forbids the form tags: list = [] and raises ValueError.

frozen and slots

The frozen=True parameter makes an object immutable, and slots=True (since Python 3.10) creates __slots__ and saves memory. An attempt to change a field of a frozen object raises FrozenInstanceError. To get an object with a new value, create a copy:

from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Expense:
    title: str
    amount: float

e = Expense("Coffee", 250)
# e.amount = 300   -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2)   # Expense(title='Coffee', amount=250) Expense(title='Coffee', amount=300)

Since Python 3.13 the general-purpose function copy.replace() does the same. Frozen dataclass objects can be put into sets and used as dictionary keys — they have a __hash__.

What is new for classes: Self, @override, __slots__

For typing classes there are two useful tools. typing.Self (Python 3.11, PEP 673) means "an object of the same class will be returned" — this works in inherited methods too. The @override decorator (Python 3.12, PEP 698) marks a method that must override a parent method: if you misspell the name, mypy or pyright will report that there is no base method with that name.

from typing import Self, override

class Report:
    def __init__(self):
        self.rows = []

    def add(self, e) -> Self:
        self.rows.append(e)
        return self              # can be chained

class MonthlyReport(Report):
    @override
    def add(self, e) -> Self:
        return super().add(e)

Python 3.15 lifted a niche restriction: subclasses of tuple are allowed non-empty __slots__, and __dict__ and __weakref__ can be listed in the __slots__ of any class. In 3.14, such a declaration in a tuple subclass failed with TypeError: nonempty __slots__ not supported for subtype of 'tuple'.

Common mistakes when working with classes

MistakeSymptomHow to fix
self forgotten in a methodTypeError: ... takes 0 positional arguments but 1 was givenThe first parameter of a method is always self
A field without self. in __init__The value disappears after __init__Write self.amount = amount
A list as a class attributeData "leaks" between objectsCreate lists in __init__ or through default_factory
No super().__init__()AttributeError on the parent's fieldsCall the parent's initialiser
Calling a property with bracketsTypeError: 'int' object is not callableRead the property without brackets: r.total

Summary

Python OOP starts simply: a class describes fields and methods, __init__ fills in the fields, and self refers to the current object. Inheritance and super() let you extend behaviour, abstract classes define interfaces, and @property creates computed fields. For data classes use @dataclass, and prefer composition over deep hierarchies. In practice, classes and dataclasses are covered in a lecture of our Python course using the expense tracker as an example.

FAQ

How is a class different from an object in Python?

A class is a description of a type: which fields and methods it has. An object, or instance, is created by calling the class, for example Expense("Coffee", 250), and holds specific field values.

Is it mandatory to write __init__ in a class?

No. If __init__ is not declared, the one inherited from object is used, which accepts no arguments. In a dataclass the decorator generates it.

How does __str__ differ from __repr__?

__repr__ is an unambiguous representation for developers and the debugger, while __str__ is a readable output for the user in print and f-strings. If __str__ is missing, Python uses __repr__.

When should I choose a dataclass and when an ordinary class?

A dataclass suits a class that mainly stores data and needs ready-made __init__, __repr__ and comparison. An ordinary class is more convenient when complex initialisation logic or behaviour matters more than data.

Does Python have private fields?

There is no strict privacy. A single underscore at the start of a name is an agreement "not for external use"; two underscores trigger name mangling, which protects the field from accidental overriding in a subclass.

Learn this topic on video

This topic is covered in depth with practice in the course “Python 3.15: от новичка до профи”.

Go to course