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| Access | Resolved from | Watch out for |
|---|---|---|
self.x | instance first, then the class | An unset instance attribute silently falls through to the class |
self.x = v | always the instance | Per-object shadowing of a class attribute, usually by mistake |
| A mutable class attribute | shared by every instance | Use self.x = [] inside __init__ instead |
@classmethod / @staticmethod | the class, not the object | Use 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__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 withlen(), indexing andfor.__enter__/__exit__make it usable in awithblock.
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| Need | Use | Why |
|---|---|---|
| A record with a few fields | @dataclass | Generates __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 | @property | Keeps the call-site syntax unchanged |
| Shared behaviour with variations | Inheritance + 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)) # TruePrefer 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?
Do I need __slots__?
slots=True on a dataclass removes the per-instance dict and blocks setting undeclared attributes, which is also a useful correctness check.Related
Type hints and static checking Iterators, generators and itertools
Last refreshed 2026-09-18.