Class basics
A class is a blueprint. Calling it creates an instance, an object with its own copy of the data defined in __init__. Every method receives the instance as its first argument, conventionally named self.
1class BankAccount:2 def __init__(self, owner, balance=0):3 self.owner = owner # instance attribute4 self.balance = balance56 def deposit(self, amount):7 self.balance += amount89 def withdraw(self, amount):10 if amount > self.balance:11 raise ValueError("insufficient funds")12 self.balance -= amount1314 def __repr__(self):15 return f"BankAccount({self.owner!r}, {self.balance})"1617# Each call creates a separate, independent instance18alice = BankAccount("Alice", 100)19bob = BankAccount("Bob") # balance defaults to 02021alice.deposit(50)22print(alice.balance) # 15023print(bob.balance) # 0, completely independent24print(alice) # BankAccount('Alice', 150)
Class attributes vs instance attributes
Class attributes live on the class itself and are shared across all instances. Instance attributes live on the object and are unique to each one.
1class BankAccount:2 interest_rate = 0.02 # class attribute, shared by all instances34 def __init__(self, owner, balance=0):5 self.owner = owner # instance attribute, unique per object6 self.balance = balance78 def apply_interest(self):9 self.balance += self.balance * BankAccount.interest_rate1011a = BankAccount("Alice", 1000)12b = BankAccount("Bob", 500)1314BankAccount.interest_rate = 0.05 # changes for all instances15a.apply_interest()16print(a.balance) # 1050.017print(b.balance) # 500, apply_interest not called on b
Create instances and call methods:
No instances yet, create one above
Inheritance
A subclass inherits all methods from its parent. Override only the ones that need to change. Use super() to call the parent implementation rather than duplicating it.
1class Animal:2 def __init__(self, name, sound):3 self.name = name4 self.sound = sound56 def speak(self):7 return f"{self.name} says {self.sound}"89 def __repr__(self):10 return f"{type(self).__name__}({self.name!r})"1112class Dog(Animal):13 def __init__(self, name, breed):14 super().__init__(name, sound="Woof") # delegate to Animal15 self.breed = breed1617 def fetch(self):18 return f"{self.name} fetches!"1920class Cat(Animal):21 def __init__(self, name, indoor=True):22 super().__init__(name, sound="Meow")23 self.indoor = indoor2425 def speak(self): # override26 return f"{self.name} says Meow... or maybe not."2728d = Dog("Rex", "Labrador")29c = Cat("Luna")30print(d.speak()) # Rex says Woof31print(c.speak()) # Luna says Meow... or maybe not.32print(d.fetch()) # Rex fetches!33print(d) # Dog('Rex')
isinstance and issubclass
1d = Dog("Rex", "Labrador")23print(isinstance(d, Dog)) # True4print(isinstance(d, Animal)) # True, Dog is a subclass of Animal5print(isinstance(d, Cat)) # False67print(issubclass(Dog, Animal)) # True8print(issubclass(Cat, Dog)) # False
Explore the hierarchy and method resolution order:
Properties
A @property turns a method into attribute-style access. Callers write obj.radius instead of obj.radius(), but you can add validation or computation behind it.
1class Circle:2 def __init__(self, radius):3 self._radius = radius # private by convention45 @property6 def radius(self):7 return self._radius89 @radius.setter10 def radius(self, value):11 if value < 0:12 raise ValueError("radius must be non-negative")13 self._radius = value1415 @property16 def area(self): # computed, no setter17 import math18 return math.pi * self._radius ** 21920c = Circle(5)21print(c.radius) # 5, looks like attribute access22print(c.area) # 78.53...2324c.radius = 1025print(c.area) # 314.15...2627c.radius = -1 # ValueError: radius must be non-negative
Two-way conversion
A property with both getter and setter lets callers set via either representation, useful for unit conversion.
1class Temperature:2 def __init__(self, celsius=0.0):3 self.celsius = celsius # plain attribute is fine to start45 @property6 def fahrenheit(self):7 return self.celsius * 9 / 5 + 3289 @fahrenheit.setter10 def fahrenheit(self, value):11 self.celsius = (value - 32) * 5 / 91213t = Temperature(100)14print(t.fahrenheit) # 212.01516t.fahrenheit = 3217print(t.celsius) # 0.0
Start with a plain attribute. Only add @property when you need to enforce a constraint or derive a value. Do not use properties just to follow a Java-style getter/setter pattern.
Dunder methods
Dunder (double-underscore) methods let your class hook into Python's built-in operators and functions. Python calls them automatically, you never call __str__ yourself.
1class Vector:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __repr__(self):7 return f"Vector({self.x}, {self.y})" # for debuggers and repls89 def __str__(self):10 return f"({self.x}, {self.y})" # for print()1112 def __add__(self, other):13 return Vector(self.x + other.x, self.y + other.y)1415 def __len__(self):16 return 2 # dimensionality1718 def __eq__(self, other):19 return self.x == other.x and self.y == other.y2021v1 = Vector(1, 2)22v2 = Vector(3, 4)23print(v1) # (1, 2) __str__24print(repr(v1)) # Vector(1, 2) __repr__25print(v1 + v2) # (4, 6) __add__26print(len(v1)) # 2 __len__27print(v1 == v1) # True __eq__
__repr__ vs __str__
Always define __repr__. It shows up in the REPL, inside collections, and in error messages. __str__ is optional, Python falls back to __repr__ when it is absent.
1class Point:2 def __init__(self, x, y):3 self.x = x4 self.y = y56 def __repr__(self):7 return f"Point({self.x!r}, {self.y!r})"89points = [Point(1, 2), Point(3, 4)]10print(points) # [Point(1, 2), Point(3, 4)] __repr__ used inside list
Sortable objects with @total_ordering
1from functools import total_ordering23@total_ordering # fills in <=, >, >= from __eq__ and __lt__4class Temperature:5 def __init__(self, celsius):6 self.celsius = celsius78 def __repr__(self):9 return f"Temperature({self.celsius})"1011 def __eq__(self, other):12 return self.celsius == other.celsius1314 def __lt__(self, other):15 return self.celsius < other.celsius1617temps = [Temperature(30), Temperature(10), Temperature(20)]18print(sorted(temps)) # [Temperature(10), Temperature(20), Temperature(30)]19print(max(temps)) # Temperature(30)
When to use classes
Classes exist for one reason: bundling mutable state and the operations on that state. If you do not have persistent state across calls, a plain function is simpler and easier to test.
1# Unnecessary class, no useful state2class MathHelper:3 def add(self, a, b): return a + b4 def square(self, x): return x * x56# Better: plain functions7def add(a, b): return a + b8def square(x): return x * x
1# Good use: state + behavior together2class RateLimiter:3 def __init__(self, max_calls, period_seconds):4 self.max_calls = max_calls5 self.period_seconds = period_seconds6 self._calls: list[float] = []78 def allow(self) -> bool:9 import time10 now = time.time()11 self._calls = [t for t in self._calls if now - t < self.period_seconds]12 if len(self._calls) >= self.max_calls:13 return False14 self._calls.append(now)15 return True1617# Each endpoint gets its own independent limiter18api_limiter = RateLimiter(100, 60)19login_limiter = RateLimiter(5, 60)
@dataclass for pure data
When you need structured data without behavior, @dataclass generates __init__, __repr__, and __eq__ for free.
1from dataclasses import dataclass, field23@dataclass4class Point:5 x: float6 y: float78@dataclass(frozen=True) # immutable, safe in sets and dicts9class Color:10 r: int11 g: int12 b: int1314@dataclass15class Config:16 host: str = "localhost"17 port: int = 808018 tags: list[str] = field(default_factory=list) # mutable default1920p = Point(1.0, 2.0)21print(p) # Point(x=1.0, y=2.0)22print(p == Point(1.0, 2.0)) # True2324c = Config()25print(c.host, c.port) # localhost 8080
Composition over inheritance
Deep inheritance hierarchies are hard to change and reason about. When you find yourself inheriting just to share code, pass an object instead.
1# Inheritance: Logger is tightly coupled to every subclass2class ServiceBase:3 def log(self, msg): print(f"[LOG] {msg}")45class UserService(ServiceBase):6 pass # inherits log, but must carry all of ServiceBase78# Composition: inject the dependency9class Logger:10 def log(self, msg): print(f"[LOG] {msg}")1112class UserService:13 def __init__(self, logger: Logger):14 self.logger = logger1516 def create_user(self, name):17 self.logger.log(f"Creating {name}")1819svc = UserService(Logger())20svc.create_user("Alice") # [LOG] Creating Alice