Object-oriented Python: classes and dunder methods

Classes that behave like built-in types: instance versus class state, __repr__ and __eq__, properties, dataclasses, and inheritance with super().

Classes, self and instance state

A class bundles data with the behaviour that operates on it. __init__ runs at construction and receives the new object as self; every method that works on an instance takes it as the first parameter.

class Account:
    """A bank account with a running balance."""

    bank = "Example Bank"           # class attribute: shared by all instances

    def __init__(self, owner, balance=0.0):
        self.owner = owner          # instance attribute: one per object
        self.balance = balance
        self._history = []          # leading underscore = internal by convention

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self.balance += amount
        self._history.append(("deposit", amount))
        return self.balance

a, b = Account("Ada", 10), Account("Grace")
a.deposit(5)
print(a.balance, b.balance)     # 15 0.0
print(a.bank, Account.bank)     # Example Bank Example Bank

Account.bank = "New Bank"       # rebinding the class attribute affects everyone
print(b.bank)                   # New Bank
AccessResolved fromWatch out for
self.xinstance first, then the classAn unset instance attribute silently falls through to the class
self.x = valways the instancePer-object shadowing of a class attribute, usually by mistake
A mutable class attributeshared by every instanceUse self.x = [] inside __init__ instead
@classmethod / @staticmethodthe class, not the objectUse for alternative constructors and pure helpers
  • Prefer a classmethod alternative constructor such as Account.from_row(row) over parsing inside __init__.
  • Use a single leading underscore for internal data and never enforce privacy with name mangling tricks.

Dunder methods

Methods named with double underscores hook your object into Python's own syntax: printing, comparison, hashing and iteration. Without __repr__ you see <Account object at 0x7f...>, which is useless in a debugger or a log line.

class Money:
    def __init__(self, amount, currency="GBP"):
        self.amount = amount
        self.currency = currency

    def __repr__(self):                 # unambiguous, for developers
        return f"Money({self.amount!r}, {self.currency!r})"

    def __str__(self):                  # readable, for users
        return f"{self.amount:.2f} {self.currency}"

    def __eq__(self, other):            # value equality
        if not isinstance(other, Money):
            return NotImplemented
        return (self.amount, self.currency) == (other.amount, other.currency)

    def __hash__(self):                 # keeps equal objects in the same bucket
        return hash((self.amount, self.currency))

    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("currency mismatch")
        return Money(self.amount + other.amount, self.currency)

print(Money(3))             # 3.00 GBP
print([Money(3)])           # [Money(3, 'GBP')]  <- uses __repr__
print(Money(3) == Money(3)) # True
print(Money(3) + Money(4))  # 7.00 GBP
⚠️
Defining __eq__ sets __hash__ to None unless you define it too, which makes instances unhashable and breaks their use as dict keys or set members. Return NotImplemented (not False) for unknown types.
  • __repr__ should look like code that could rebuild the object; __str__ falls back to it when omitted.
  • __len__, __getitem__ and __iter__ make an object work with len(), indexing and for.
  • __enter__/__exit__ make it usable in a with block.

Properties, dataclasses and inheritance

A @property exposes a method as an attribute so you can add validation or computed values later without breaking callers.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def fahrenheit(self):               # computed, read-only
        return self.celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self.celsius = (value - 32) * 5 / 9

t = Temperature(20)
print(t.fahrenheit)      # 68.0
t.fahrenheit = 212
print(t.celsius)         # 100.0

from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float
    tags: tuple = ()         # frozen means no mutable defaults allowed

p = Point(1, 2)
print(p)                     # Point(x=1, y=2, tags=())
print(p == Point(1, 2))      # True - generated for you
NeedUseWhy
A record with a few fields@dataclassGenerates __init__, __repr__, __eq__
Immutable or hashable records@dataclass(frozen=True)Also safe as dict keys and set members
Validation on assignment@dataclass + __post_init__Runs once the generated init finishes
Computed or guarded attributes@propertyKeeps the call-site syntax unchanged
Shared behaviour with variationsInheritance + super()Reuse and extend rather than copy
class Base:
    def __init__(self, name):
        self.name = name

    def describe(self):
        return f"base:{self.name}"

class Child(Base):
    def __init__(self, name, level):
        super().__init__(name)       # always call the parent initializer
        self.level = level

    def describe(self):
        return f"{super().describe()} level={self.level}"   # extend, do not replace

print(Child("x", 2).describe())      # base:x level=2
print(isinstance(Child("x", 2), Base))   # True

Prefer composition when the relationship is not genuinely an is-a: store a helper object as an attribute instead of inheriting from it. Deep hierarchies are harder to reason about than a small object that does one job.

FAQ

When should I use a dataclass instead of a plain class?
When the class is mostly data with little or no custom behaviour. A dataclass removes the boilerplate and keeps equality and repr consistent; write a plain class when construction or invariants need real logic.
Do I need __slots__?
Only when you create very many instances and memory matters. slots=True on a dataclass removes the per-instance dict and blocks setting undeclared attributes, which is also a useful correctness check.

Type hints and static checking Iterators, generators and itertools

Last refreshed 2026-09-18.