Object-Oriented Programming (OOP) is a way of structuring a program by bundling related data and the behaviour that works on that data into single units called objects. Instead of a long list of functions and loose variables, you model your program as a collection of interacting objects — which scales far better as projects grow.

Python is a multi-paradigm language, but its OOP support is clean and complete. This guide covers everything end-to-end: classes and objects, __init__ and self, instance vs class attributes, the three kinds of methods, and the four pillars of OOP — encapsulation, inheritance, polymorphism and abstraction — plus the dunder methods that make your objects feel native. Every concept has runnable code with its output.

What Is a Class and an Object?

A class is a blueprint; an object (or instance) is a concrete thing built from that blueprint. A Car class describes what every car has and does; my_car is one actual car.

Syntax:

class ClassName:
    # class body: attributes and methods
    ...

obj = ClassName()   # create (instantiate) an object

The __init__ Method and self

__init__ is the constructor — it runs automatically when you create an object and sets up its initial state. self refers to the specific object being created, so self.brand stores data on that object.

class Car:
    def __init__(self, brand, speed):
        self.brand = brand      # instance attribute
        self.speed = speed

    def describe(self):
        return f"{self.brand} going {self.speed} km/h"

car1 = Car("Toyota", 120)
car2 = Car("Tesla", 150)
print(car1.describe())
print(car2.describe())

Output:

Toyota going 120 km/h
Tesla going 150 km/h

Instance vs Class Attributes

An instance attribute belongs to one object (set with self.x). A class attribute is shared by all instances (defined directly in the class body).

class Dog:
    species = "Canis familiaris"   # class attribute (shared)

    def __init__(self, name):
        self.name = name           # instance attribute (per object)

a = Dog("Rex")
b = Dog("Bella")
print(a.name, b.name)       # different
print(a.species, b.species) # same, shared

Output:

Rex Bella
Canis familiaris Canis familiaris

Instance, Class, and Static Methods

Python has three method types: instance methods (take self), class methods (take cls, marked @classmethod), and static methods (take nothing special, marked @staticmethod).

class Pizza:
    def __init__(self, size):
        self.size = size

    def area(self):                       # instance method
        return 3.14159 * (self.size / 2) ** 2

    @classmethod
    def medium(cls):                      # class method: alternate constructor
        return cls(size=12)

    @staticmethod
    def is_valid_size(size):              # static method: utility, no self/cls
        return 6 <= size <= 18

p = Pizza.medium()
print(round(p.area(), 2))
print(Pizza.is_valid_size(20))

Output:

113.1
False

Pillar 1: Encapsulation

Encapsulation means bundling data with the methods that use it, and controlling access to it. By convention, a single underscore _x means β€œinternal”, and a double underscore __x triggers name mangling to discourage outside access. Expose controlled access through methods or @property.

class Account:
    def __init__(self, balance):
        self.__balance = balance          # "private" (name-mangled)

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    @property
    def balance(self):                    # read-only access
        return self.__balance

acc = Account(100)
acc.deposit(50)
print(acc.balance)        # 150 via the property
# print(acc.__balance)    # AttributeError - cannot touch it directly

Output:

150

Pillar 2: Inheritance

Inheritance lets a child class reuse and extend a parent class. Use super() to call the parent's methods (e.g. its __init__).

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "Some sound"

class Dog(Animal):                 # Dog inherits from Animal
    def __init__(self, name, breed):
        super().__init__(name)     # call parent constructor
        self.breed = breed

    def speak(self):               # override
        return "Woof!"

d = Dog("Rex", "Labrador")
print(d.name, d.breed)
print(d.speak())
print(isinstance(d, Animal))      # True - a Dog is an Animal

Output:

Rex Labrador
Woof!
True

Pillar 3: Polymorphism

Polymorphism (β€œmany forms”) lets different classes respond to the same method call in their own way. Because Python uses duck typing, code only cares that an object has the method, not what class it is.

class Cat:
    def speak(self): return "Meow"

class Cow:
    def speak(self): return "Moo"

class Duck:
    def speak(self): return "Quack"

for animal in (Cat(), Cow(), Duck()):
    print(animal.speak())          # same call, different behaviour

Output:

Meow
Moo
Quack

Pillar 4: Abstraction

Abstraction hides implementation details behind a common interface. Python's abc module lets you define abstract base classes with methods that subclasses must implement.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Square(Shape):
    def __init__(self, side):
        self.side = side
    def area(self):
        return self.side ** 2

s = Square(5)
print(s.area())
# Shape()  -> TypeError: Can't instantiate abstract class Shape

Output:

25

Dunder (Magic) Methods

Special methods with double underscores let your objects work with built-in operations like print(), ==, len() and +. The most common are __str__ (readable text), __repr__ (debug text), and __eq__ (equality).

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __add__(self, other):
        return Point(self.x + other.x, self.y + other.y)

p1, p2 = Point(1, 2), Point(3, 4)
print(p1)                # uses __str__
print(p1 + p2)           # uses __add__
print(p1 == Point(1, 2)) # uses __eq__

Output:

(1, 2)
(4, 6)
True

Real-World Example: A Bank Account System

Putting it together — encapsulation (private balance), inheritance (a savings account), and a dunder method, in one small but realistic class hierarchy.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

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

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        return self._balance

    def __str__(self):
        return f"{self.owner}'s account: ${self._balance}"

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0, rate=0.05):
        super().__init__(owner, balance)
        self.rate = rate

    def add_interest(self):
        self._balance += self._balance * self.rate
        return self._balance

acc = SavingsAccount("Tushar", 1000)
acc.deposit(500)
acc.add_interest()
print(acc)

Output:

Tushar's account: $1575.0

Common Mistakes to Avoid

  • Forgetting self in method definitions or when accessing attributes.
  • Using a mutable default like def __init__(self, items=[]) — the list is shared across instances. Use None and create inside.
  • Confusing class and instance attributes — reassigning a mutable class attribute affects every object.
  • Skipping super().__init__() in a child class, leaving the parent's state unset.
  • Thinking __private is truly private — it is only name-mangled, not locked.

Summary Table

ConceptKeyword / SyntaxPurpose
Classclass Name:Blueprint for objects
Constructor__init__(self)Initialize object state
Encapsulation_x / __x / @propertyControl access to data
Inheritanceclass Child(Parent) + super()Reuse and extend
Polymorphismmethod overriding / duck typingSame call, many forms
AbstractionABC, @abstractmethodDefine a required interface
Dunder methods__str__, __eq__, __add__Integrate with built-ins/operators

Conclusion

OOP gives you a vocabulary for modelling real systems: classes describe things, objects are the things, and the four pillars — encapsulation, inheritance, polymorphism and abstraction — keep large codebases organized and reusable. Add dunder methods and your objects behave like first-class Python citizens.

Practice by modelling something you know: a playlist, a to-do list, a deck of cards. Once the four pillars click, frameworks like Django (models) and data libraries (custom classes) become far easier to read.