When you first start coding in Python, you write functions and variables. That works great for small scripts. But as your programs get bigger, functions and variables alone become hard to organise. You end up with hundreds of loose pieces that do not clearly belong together.

Object Oriented Programming (OOP) is a way of organising code around things rather than just actions. Instead of having separate variables for a user's name, age and email, you group them into one User object. The object holds both the data and the actions that can be performed on that data.

This guide explains every OOP concept from scratch using clear analogies and working Python code. No confusing jargon — just simple explanations you can follow right away.

What Is Object Oriented Programming

Think about a car. A car has properties: colour, brand, number of seats, speed. A car also has actions it can perform: start, stop, accelerate, honk.

In OOP, a class is the blueprint for a car. It describes what properties and actions every car will have. An object (also called an instance) is an actual car built from that blueprint. You can create many different car objects from the same blueprint — a red one, a blue one, a fast one — and each is independent.

The four main ideas in OOP are:

  • Encapsulation — keep data and the code that works with it bundled together, and control what is accessible from outside
  • Inheritance — a child class can get all the properties and methods of a parent class and then add or change things
  • Polymorphism — different classes can have methods with the same name that each behave differently
  • Abstraction — hide the complex inner workings and only show a simple interface to the outside world

Classes and Objects

Your First Class

You define a class using the class keyword followed by the class name. Class names use CamelCase by convention — each word starts with a capital letter.

Python — defining a class and creating objects
# Define the class — this is the blueprint class Dog: # A class attribute — shared by ALL dogs species = "Canis lupus familiaris" # The constructor — runs automatically when you create a new Dog def __init__(self, name, breed, age): # Instance attributes — unique to EACH dog self.name = name self.breed = breed self.age = age def bark(self): print(f"{self.name} says: Woof!") # Create objects from the class dog1 = Dog("Bruno", "Labrador", 3) dog2 = Dog("Milo", "Poodle", 5) # Access attributes print(dog1.name) # Bruno print(dog2.breed) # Poodle print(dog1.species) # Canis lupus familiaris (class attribute) # Call methods dog1.bark() # Bruno says: Woof! dog2.bark() # Milo says: Woof!

The Constructor — __init__

The __init__ method is the constructor. It runs automatically every time you create a new object. You use it to set up the initial state of the object — the starting values for all its attributes.

Think of it like filling in a form when you register for something. The moment you sign up, certain fields get filled in right away: your name, email, account creation date. The constructor does the same thing for your objects.

What Is self

self is a reference to the specific object being created or used at that moment. It is how an object refers to its own attributes and methods.

When you call dog1.bark(), Python automatically passes dog1 as the self argument. So inside the bark method, self.name gives you "Bruno" specifically — not Milo's name. Every instance gets its own copy of the data.

ℹ️ self is just a convention. You could technically name it anything, but the entire Python community uses self. Always use it — any other name will confuse other developers and code editors.

Methods — Functions That Belong to a Class

A method is a function defined inside a class. There are three kinds and each one serves a different purpose.

Instance Methods — the Most Common Type

An instance method works with a specific object's data. It always takes self as its first parameter and can read and modify the object's attributes.

Python — instance methods that read and modify state
class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.balance = balance def deposit(self, amount): if amount > 0: self.balance += amount print(f"Deposited ₹{amount}. New balance: ₹{self.balance}") else: print("Deposit amount must be positive.") def withdraw(self, amount): if amount > self.balance: print("Insufficient funds.") else: self.balance -= amount print(f"Withdrew ₹{amount}. New balance: ₹{self.balance}") def get_balance(self): return self.balance account = BankAccount("Shashank", 5000) account.deposit(2000) # Deposited ₹2000. New balance: ₹7000 account.withdraw(3000) # Withdrew ₹3000. New balance: ₹4000

Class Methods — Work on the Class Itself

A class method does not work with a specific object. Instead it receives the class itself as its first argument, conventionally called cls. It is used most often as an alternative constructor — a second way to create objects.

Python — class methods as alternative constructors
class Student: def __init__(self, name, grade, score): self.name = name self.grade = grade self.score = score @classmethod def from_string(cls, data_string): # Create a student from a comma-separated string like "Shashank,10,92" name, grade, score = data_string.split(',') return cls(name, int(grade), int(score)) # Normal creation s1 = Student("Shashank", 10, 92) # Creation from a string using the class method s2 = Student.from_string("Priya,11,88") print(s2.name, s2.score) # Priya 88

Static Methods — No Object, No Class

A static method does not receive self or cls. It is just a regular function that lives inside a class because it logically belongs there. Use it for utility functions related to the class.

Python — static methods for utility functions
class MathHelper: @staticmethod def is_even(n): return n % 2 == 0 @staticmethod def celsius_to_fahrenheit(c): return (c * 9 / 5) + 32 # Call without creating an object print(MathHelper.is_even(8)) # True print(MathHelper.celsius_to_fahrenheit(100)) # 212.0

Encapsulation — Controlling What Is Accessible

Encapsulation means bundling data and methods together and controlling which parts are accessible from outside the class. The idea is that the internal workings of a class should be hidden. Outside code should only interact through the methods you have deliberately exposed.

Think of a vending machine. You can press a button and get a snack. But you cannot reach inside and take whatever you want or change the pricing directly. The machine controls what you can and cannot do. That is encapsulation.

Private Attributes

In Python, prefixing an attribute name with a double underscore __ makes it "private" — it signals that this should not be accessed directly from outside the class.

Python — private attributes with double underscore
class BankAccount: def __init__(self, owner, balance): self.owner = owner # public — anyone can read this self.__balance = balance # private — double underscore hides it def deposit(self, amount): if amount > 0: self.__balance += amount def get_balance(self): return self.__balance acc = BankAccount("Shashank", 5000) print(acc.owner) # Shashank — works fine print(acc.get_balance()) # 5000 — access through method # acc.__balance would raise AttributeError — cannot access directly # This forces code to use deposit() and withdraw() which have validation

Properties — Getters and Setters the Python Way

Python's @property decorator lets you access a method like an attribute. You get the clean syntax of attribute access with the control of a method underneath.

Python — @property for clean getters and setters
class Temperature: def __init__(self, celsius): self.__celsius = celsius @property def celsius(self): return self.__celsius @celsius.setter def celsius(self, value): if value < -273.15: raise ValueError("Temperature below absolute zero is impossible.") self.__celsius = value @property def fahrenheit(self): return (self.__celsius * 9 / 5) + 32 t = Temperature(100) print(t.celsius) # 100 — looks like attribute, works through getter print(t.fahrenheit) # 212.0 — computed on the fly t.celsius = 25 # uses the setter with validation

Inheritance — Building on What Already Exists

Inheritance lets you create a new class that automatically gets everything from an existing class, and then you can add new things or change existing ones. The original class is called the parent (or base class). The new class is the child (or subclass).

Think of it like how a child inherits traits from a parent. A child naturally has some of the same traits (brown eyes, curly hair) but might also develop new ones or express existing traits differently.

Basic Inheritance

Python — child class inheriting from parent class
# Parent class class Animal: def __init__(self, name, age): self.name = name self.age = age def breathe(self): print(f"{self.name} is breathing.") def speak(self): print("...") # Child class — put the parent name in parentheses class Dog(Animal): def __init__(self, name, age, breed): super().__init__(name, age) # call the parent constructor self.breed = breed # add a new attribute # Override the parent's speak method def speak(self): print(f"{self.name} says: Woof!") # Add a new method that Animal does not have def fetch(self): print(f"{self.name} fetches the ball!") class Cat(Animal): def speak(self): print(f"{self.name} says: Meow!") dog = Dog("Bruno", 3, "Labrador") cat = Cat("Whiskers", 4) dog.breathe() # inherited from Animal: Bruno is breathing. dog.speak() # overridden: Bruno says: Woof! dog.fetch() # only on Dog: Bruno fetches the ball! cat.speak() # Whiskers says: Meow! # Check relationships print(isinstance(dog, Dog)) # True print(isinstance(dog, Animal)) # True — Dog IS an Animal print(issubclass(Dog, Animal)) # True

The super Function

super() gives you access to the parent class. It is most often used inside the child's __init__ to call the parent's constructor first, so you set up everything the parent would set up, and then add your own extra attributes on top.

Always call super().__init__(). If your child class has its own constructor, always call the parent constructor first using super().__init__(). This ensures the parent's attributes are properly created before you add new ones.

Multi Level Inheritance

A child class can itself be the parent of another class, creating a chain. Each class in the chain inherits from all the classes above it.

Python — a three level inheritance chain
class Vehicle: def __init__(self, brand, speed): self.brand = brand self.speed = speed def move(self): print(f"{self.brand} is moving at {self.speed} km/h.") class Car(Vehicle): def __init__(self, brand, speed, doors): super().__init__(brand, speed) self.doors = doors class ElectricCar(Car): # inherits from Car which inherits from Vehicle def __init__(self, brand, speed, doors, battery_kw): super().__init__(brand, speed, doors) self.battery_kw = battery_kw def charge(self): print(f"{self.brand} is charging its {self.battery_kw}kW battery.") tesla = ElectricCar("Tesla", 200, 4, 75) tesla.move() # from Vehicle: Tesla is moving at 200 km/h. tesla.charge() # from ElectricCar: Tesla is charging its 75kW battery.

Polymorphism — Same Name, Different Behaviour

Polymorphism means "many forms". In Python it means you can have multiple classes that each have a method with the same name, and when you call that method the right version runs automatically based on which class the object belongs to.

This lets you write code that works with many different types of objects without needing to know which specific type it is dealing with.

Python — polymorphism lets you treat different objects the same way
class Shape: def area(self): raise NotImplementedError("Every shape must implement area()") class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): return 3.14159 * self.radius ** 2 class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height class Triangle(Shape): def __init__(self, base, height): self.base = base self.height = height def area(self): return 0.5 * self.base * self.height # This function works with ANY shape — it does not care which one def print_area(shape): print(f"Area: {shape.area():.2f}") shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)] for s in shapes: print_area(s) # Area: 78.54 # Area: 24.00 # Area: 12.00
ℹ️ Duck typing: Python's polymorphism does not require formal inheritance. Any object that has an area() method will work with print_area(). Python just looks for the method by name. If it has it, great. If not, it throws an error. This philosophy is called "duck typing" — if it walks like a duck and quacks like a duck, treat it as a duck.

Dunder Methods — Making Objects Feel Like Built-ins

Dunder methods (short for "double underscore") are special methods with names like __str__, __len__ and __add__. They let your objects work with Python's built-in operations like printing, len() and the + operator.

Python — dunder methods make objects feel native
class Book: def __init__(self, title, author, pages): self.title = title self.author = author self.pages = pages # Controls what print(book) shows def __str__(self): return f'"{self.title}" by {self.author}' # Controls what the developer sees in the console (e.g. repr(book)) def __repr__(self): return f'Book("{self.title}", "{self.author}", {self.pages})' # Controls len(book) def __len__(self): return self.pages # Controls == comparison def __eq__(self, other): return self.title == other.title and self.author == other.author # Controls less than, enables sorting def __lt__(self, other): return self.pages < other.pages b1 = Book("Clean Code", "Robert C. Martin", 464) b2 = Book("Python Tricks", "Dan Bader", 302) print(b1) # "Clean Code" by Robert C. Martin print(len(b1)) # 464 print(b1 == b2) # False print(b2 < b1) # True — b2 has fewer pages print(sorted([b1, b2])) # sorted by pages automatically

Dataclasses — Less Boilerplate for Simple Classes

If you just need a class to hold some data, writing __init__, __str__ and __eq__ every time gets repetitive. Python's @dataclass decorator generates these automatically from simple field declarations.

Python — dataclasses reduce boilerplate significantly
from dataclasses import dataclass, field # Without @dataclass — you write __init__, __repr__, __eq__ manually class PointOld: def __init__(self, x, y): self.x = x self.y = y # With @dataclass — Python generates __init__, __repr__ and __eq__ for you @dataclass class Point: x: float y: float p1 = Point(3.0, 4.0) print(p1) # Point(x=3.0, y=4.0) — __repr__ auto-generated print(p1.x) # 3.0 # More complex dataclass with defaults @dataclass class User: name: str email: str age: int = 0 active: bool = True tags: list = field(default_factory=list) user = User("Shashank", "shashank@example.com", 25) print(user) # User(name='Shashank', email='shashank@example.com', age=25, active=True, tags=[])
Use dataclasses for data holder classes. If a class is mainly just storing data with no complex logic, @dataclass is the cleaner modern way to write it. You get __init__, __repr__ and __eq__ for free with far less code.

⚡ Key Takeaways
  • A class is a blueprint. An object is a real thing built from that blueprint. You can create many objects from one class and each has its own independent data.
  • __init__ is the constructor. It runs automatically when you create an object and sets up the initial attributes.
  • self is a reference to the current object. Every instance method takes it as its first parameter so Python knows which object's data to use.
  • Instance methods work with a specific object's data. Class methods work with the class itself and are used as alternative constructors. Static methods are utility functions that live in the class but do not need the object or class.
  • Encapsulation means bundling data and methods together and controlling access. Use double underscores (__attr) to make attributes private, and @property for clean getters and setters.
  • Inheritance lets a child class get all the attributes and methods of a parent class. Put the parent name in parentheses: class Dog(Animal).
  • Always call super().__init__() in a child constructor to ensure the parent's setup runs first.
  • Polymorphism lets different classes have methods with the same name that each behave differently. This lets you write one function that works with many different types of objects.
  • Dunder methods like __str__, __len__ and __eq__ let your objects work naturally with Python's built-in functions and operators.
  • Use @dataclass for simple classes that mainly store data. Python generates the boilerplate for you automatically.