In the previous tutorial, we learned about modules, packages, and virtual environments. Now let’s learn about object-oriented programming (OOP) — the most important paradigm in Python.
OOP lets you group related data and behavior into classes. Instead of passing data between separate functions, you bundle everything together. By the end of this tutorial, you will know how to create classes, use inheritance, write magic methods, and design abstract interfaces.
What is a Class?
A class is a blueprint for creating objects. An object is an instance of a class. Think of a class like a cookie cutter, and objects are the cookies.
class Dog:
"""A simple dog class."""
# Class variable — shared by all instances
species = "Canis familiaris"
def __init__(self, name: str, age: int) -> None:
self.name = name # Instance variable
self.age = age
def bark(self) -> str:
return f"{self.name} says: Woof!"
def human_age(self) -> int:
return self.age * 7
Let me break this down:
class Dog:— defines a new class called Dog__init__— the constructor, called when you create a new Dogself— refers to the current instance (likethisin Java, Kotlin, or JavaScript)self.name— an instance variable (each dog has its own name)species— a class variable (shared by all dogs)
Use the class like this:
dog = Dog("Rex", 3)
print(dog.bark()) # Rex says: Woof!
print(dog.human_age()) # 21
print(Dog.species) # Canis familiaris
Every method in a class takes self as its first parameter. Python passes the instance automatically when you call a method. When you write dog.bark(), Python calls Dog.bark(dog) behind the scenes.
Class Variables vs Instance Variables
This distinction is important. Class variables belong to the class. Instance variables belong to each object:
dog1 = Dog("Rex", 3)
dog2 = Dog("Luna", 5)
# Instance variables — different for each dog
print(dog1.name) # Rex
print(dog2.name) # Luna
# Class variable — shared by all dogs
print(dog1.species) # Canis familiaris
print(dog2.species) # Canis familiaris
print(Dog.species) # Canis familiaris
You can access class variables through instances or through the class itself. But instance variables only exist on specific objects.
Properties
Properties let you control how attributes are accessed and changed. Use the @property decorator to create a getter and @name.setter to create a setter:
class Circle:
def __init__(self, radius: float) -> None:
self._radius = radius # Convention: _ means "private"
@property
def radius(self) -> float:
"""Get the radius."""
return self._radius
@radius.setter
def radius(self, value: float) -> None:
"""Set the radius. Must be positive."""
if value <= 0:
raise ValueError("Radius must be positive.")
self._radius = value
@property
def area(self) -> float:
"""Calculate the area (read-only property)."""
import math
return math.pi * self._radius ** 2
Now radius looks like a regular attribute, but it has validation:
circle = Circle(5)
print(circle.radius) # 5
print(circle.area) # 78.54 (read-only, calculated)
circle.radius = 10 # OK — setter validates
print(circle.area) # 314.16 — recalculated
circle.radius = -1 # Raises ValueError: Radius must be positive.
The area property has no setter, so it is read-only. Python calculates it every time you access it. This is useful for values that depend on other attributes.
The _radius naming convention (single underscore) means “this is internal, don’t access it directly.” Python does not enforce this — it is just a convention. The property provides the public interface.
Magic Methods (Dunder Methods)
Magic methods have double underscores on both sides (like __init__). They are also called “dunder methods” (double underscore). They let your objects work with Python’s built-in operators and functions.
class Vector:
"""A 2D vector with magic methods."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
"""Developer-friendly string: Vector(3, 4)"""
return f"Vector({self.x}, {self.y})"
def __str__(self) -> str:
"""User-friendly string: (3, 4)"""
return f"({self.x}, {self.y})"
def __eq__(self, other: object) -> bool:
"""Check if two vectors are equal."""
if not isinstance(other, Vector):
return NotImplemented
return self.x == other.x and self.y == other.y
def __add__(self, other: "Vector") -> "Vector":
"""Add two vectors."""
return Vector(self.x + other.x, self.y + other.y)
def __len__(self) -> int:
"""Return the number of dimensions."""
return 2
def __abs__(self) -> float:
"""Return the magnitude of the vector."""
return (self.x ** 2 + self.y ** 2) ** 0.5
Now your Vector works with Python’s built-in functions and operators:
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1) # (3, 4) — uses __str__
print(repr(v1)) # Vector(3, 4) — uses __repr__
print(v1 + v2) # (4, 6) — uses __add__
print(v1 == Vector(3, 4)) # True — uses __eq__
print(v1 == Vector(1, 2)) # False
print(len(v1)) # 2 — uses __len__
print(abs(v1)) # 5.0 — uses __abs__
repr vs str
These two are easy to confuse:
__repr__— for developers. Should look like valid Python code. Used in the REPL and debugger.__str__— for users. Human-readable format. Used byprint()andstr().
If you only implement one, implement __repr__. Python falls back to __repr__ when __str__ is not defined.
eq and NotImplemented
The __eq__ method returns NotImplemented when comparing with an incompatible type. This tells Python to try the other object’s __eq__ method. Never return False for incompatible types — that breaks Python’s comparison protocol.
Here are the most useful magic methods:
| Method | Purpose | Example |
|---|---|---|
__str__ | User-friendly string | print(obj) |
__repr__ | Developer string | repr(obj) |
__eq__ | Equality check | obj1 == obj2 |
__lt__ | Less than | obj1 < obj2 |
__add__ | Addition | obj1 + obj2 |
__sub__ | Subtraction | obj1 - obj2 |
__mul__ | Multiplication | obj1 * obj2 |
__len__ | Length | len(obj) |
__abs__ | Absolute value | abs(obj) |
__contains__ | Membership | x in obj |
__getitem__ | Indexing | obj[key] |
__hash__ | Hash value | hash(obj) |
__bool__ | Truth value | if obj: |
Inheritance
Inheritance lets a class reuse code from another class. The child class inherits all methods and attributes from the parent class. It can also add new methods or override existing ones.
class Animal:
"""Base class for all animals."""
def __init__(self, name: str, sound: str) -> None:
self.name = name
self.sound = sound
def speak(self) -> str:
return f"{self.name} says: {self.sound}"
def __str__(self) -> str:
return f"{self.name} the {type(self).__name__}"
class Cat(Animal):
"""A cat is an animal."""
def __init__(self, name: str, indoor: bool = True) -> None:
super().__init__(name, sound="Meow")
self.indoor = indoor
def purr(self) -> str:
"""Cats can purr. This method is only on Cat, not Animal."""
return f"{self.name} purrs..."
Cat(Animal) means Cat inherits from Animal. The super() function calls the parent class constructor. This is how Cat passes name and sound to Animal.
cat = Cat("Luna")
print(cat.speak()) # Luna says: Meow (inherited from Animal)
print(cat.purr()) # Luna purrs... (Cat's own method)
print(str(cat)) # Luna the Cat (inherited __str__)
print(cat.indoor) # True (Cat's own attribute)
The type(self).__name__ expression in __str__ returns the actual class name. So for a Cat, it returns “Cat”, not “Animal”. This is useful when the parent class needs to know the child’s name.
Multi-Level Inheritance
You can chain inheritance across multiple levels:
class Kitten(Cat):
"""A kitten is a young cat."""
def __init__(self, name: str) -> None:
super().__init__(name, indoor=True)
def play(self) -> str:
return f"{self.name} is playing!"
kitten = Kitten("Milo")
print(kitten.speak()) # Milo says: Meow (from Animal)
print(kitten.purr()) # Milo purrs... (from Cat)
print(kitten.play()) # Milo is playing! (from Kitten)
print(kitten.indoor) # True (from Cat)
Kitten inherits from Cat, which inherits from Animal. Kitten has access to all methods from both parent classes.
isinstance and issubclass
Use isinstance() to check if an object belongs to a class (including parent classes):
print(isinstance(kitten, Kitten)) # True
print(isinstance(kitten, Cat)) # True
print(isinstance(kitten, Animal)) # True
print(isinstance(kitten, str)) # False
Use issubclass() to check the class hierarchy:
print(issubclass(Kitten, Cat)) # True
print(issubclass(Kitten, Animal)) # True
print(issubclass(Cat, Kitten)) # False — Cat is not a Kitten
Class Methods and Static Methods
Class Methods
A class method receives the class as its first argument instead of an instance. Use @classmethod to create one. Class methods are often used as alternative constructors — different ways to create an object:
class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, fahrenheit: float) -> "Temperature":
"""Create a Temperature from Fahrenheit."""
celsius = (fahrenheit - 32) * 5 / 9
return cls(celsius)
@classmethod
def from_kelvin(cls, kelvin: float) -> "Temperature":
"""Create a Temperature from Kelvin."""
celsius = kelvin - 273.15
return cls(celsius)
def to_fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32
def __str__(self) -> str:
return f"{self.celsius:.1f}°C"
The cls parameter is the class itself (like self is the instance). This lets you create Temperature objects from different units:
t1 = Temperature(100) # 100.0°C
t2 = Temperature.from_fahrenheit(212) # 100.0°C
t3 = Temperature.from_kelvin(373.15) # 100.0°C
All three create a Temperature of 100 degrees Celsius, but from different starting units. This is cleaner than putting conversion logic in __init__.
Static Methods
A static method does not receive the instance or the class. It is just a regular function that lives inside a class:
@staticmethod
def is_freezing(celsius: float) -> bool:
"""Check if a temperature is below freezing."""
return celsius <= 0
print(Temperature.is_freezing(0)) # True
print(Temperature.is_freezing(20)) # False
Use static methods for utility functions that are related to the class but do not need access to instance or class data. If the function does not use self or cls, it should probably be a static method.
When to Use Each
| Method type | First parameter | Use case |
|---|---|---|
| Regular method | self (instance) | Most methods — needs instance data |
| Class method | cls (class) | Alternative constructors, factory methods |
| Static method | None | Utility functions related to the class |
Abstract Base Classes
An abstract class is a class that cannot be created directly. It defines a contract that child classes must follow. Use the abc module:
from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract base class for shapes."""
@abstractmethod
def area(self) -> float:
"""Calculate the area."""
pass
@abstractmethod
def perimeter(self) -> float:
"""Calculate the perimeter."""
pass
def describe(self) -> str:
"""Describe the shape. This is a concrete method — shared by all shapes."""
return f"{type(self).__name__}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"
You cannot create a Shape directly:
shape = Shape() # TypeError: Can't instantiate abstract class Shape
Child classes must implement all abstract methods:
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 Square(Rectangle):
"""A square is a rectangle with equal sides."""
def __init__(self, side: float) -> None:
super().__init__(side, side)
Now you can use the shared describe method:
rect = Rectangle(5, 3)
print(rect.describe()) # Rectangle: area=15.00, perimeter=16.00
square = Square(4)
print(square.describe()) # Square: area=16.00, perimeter=16.00
The power of abstract classes: you can write code that works with any Shape, without knowing the specific type. The describe method calls area() and perimeter(), which are implemented differently in each child class.
If a child class does not implement all abstract methods, Python raises a TypeError when you try to create an instance. This catches missing methods early.
When to Use OOP
OOP is not always the best choice. Here are some guidelines:
- Use classes when you have data and behavior that belong together (a User with methods, a ShoppingCart with items, a database connection with queries)
- Use functions when you just need to transform data (parse a string, calculate a sum, filter a list)
- Use dataclasses when you mainly need to store data with some methods (we cover this in the next tutorial)
- Use abstract classes when you want to define a contract for multiple implementations
A good rule of thumb: if your class only has __init__ and one other method, it should probably be a function. Classes are best when objects have state that changes over time.
Common Mistakes
Forgetting self
Every method needs self as the first parameter:
# BAD — missing self
class Dog:
def bark(): # Missing self!
return "Woof!"
# When you call dog.bark(), Python tries to pass the instance
# as the first argument, but bark() takes no arguments.
# Result: TypeError: bark() takes 0 positional arguments but 1 was given
# GOOD
class Dog:
def bark(self):
return "Woof!"
Mutable Class Variables
Class variables are shared between all instances. If the class variable is mutable (like a list), changes affect every object:
# BAD — the list is shared!
class Team:
members = [] # Shared between ALL Team instances!
t1 = Team()
t2 = Team()
t1.members.append("Alex")
print(t2.members) # ["Alex"] — oops!
# GOOD — each instance gets its own list
class Team:
def __init__(self):
self.members = [] # Instance variable
t1 = Team()
t2 = Team()
t1.members.append("Alex")
print(t2.members) # [] — correct, independent list
Deep Inheritance Hierarchies
Do not create class hierarchies more than 2-3 levels deep. Deep hierarchies are hard to understand and maintain. Prefer composition (storing objects as attributes) over deep inheritance:
# Instead of deep inheritance:
# Vehicle -> Car -> ElectricCar -> TeslaModelS
# Use composition:
class Engine:
def __init__(self, type: str):
self.type = type
class Car:
def __init__(self, name: str, engine: Engine):
self.name = name
self.engine = engine
Source Code
You can find the code for this tutorial on GitHub:
kemalcodes/python-tutorial — tutorial-09-oop
Run the examples:
python src/py09_oop.py
Run the tests:
python -m pytest tests/test_py09.py -v
What’s Next?
In the next tutorial, we will learn about dataclasses and Pydantic — modern ways to model data in Python without writing boilerplate code.
Related Articles
- Python Tutorial #5: Functions — def, args, kwargs, lambdas
- Python Tutorial #8: Modules and Packages — pip, uv, venv
- Python Cheat Sheet — quick reference for Python syntax