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.