OOP15 min readFebruary 21, 2026

Object-Oriented Programming in Python: A Practical Tutorial

Learn OOP concepts in Python including classes, objects, inheritance, polymorphism, and encapsulation with real-world examples and best practices.

S

Soumyajit Sarkar

Partner & CTO, Greensolz

What Is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a programming paradigm that organizes code around objects rather than functions and logic. An object combines data (attributes) and behavior (methods) into a single unit. OOP helps you write code that is modular, reusable, and easier to maintain as projects grow in complexity.

Python is an object-oriented language at its core. Even basic types like integers, strings, and lists are objects. Understanding OOP is essential for writing professional Python code, working with frameworks like Django, and collaborating on large codebases.

Classes and Objects

A class is a blueprint for creating objects. An object is a specific instance of a class. Think of a class as a cookie cutter and objects as the cookies it produces.

Defining Your First Class

class Dog:
    # Class attribute (shared by all instances)
    species = "Canis familiaris"

    # Constructor (initializer)
    def __init__(self, name, age, breed):
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age
        self.breed = breed

    # Instance method
    def bark(self):
        return f"{self.name} says: Woof!"

    # Instance method
    def description(self):
        return f"{self.name} is a {self.age}-year-old {self.breed}"

    # String representation
    def __str__(self):
        return self.description()

# Creating objects (instances)
rex = Dog("Rex", 5, "German Shepherd")
bella = Dog("Bella", 3, "Golden Retriever")

print(rex.bark())         # "Rex says: Woof!"
print(bella.description()) # "Bella is a 3-year-old Golden Retriever"
print(rex.species)         # "Canis familiaris"
      

Understanding self

The self parameter refers to the current instance of the class. It is how an object accesses its own attributes and methods. Python passes self automatically when you call a method on an object, so you never need to pass it explicitly.

Class vs Instance Attributes

class Counter:
    # Class attribute - shared by ALL instances
    total_counters = 0

    def __init__(self, name):
        # Instance attribute - unique to each instance
        self.name = name
        self.count = 0
        Counter.total_counters += 1

    def increment(self):
        self.count += 1

    def __repr__(self):
        return f"Counter('{self.name}', count={self.count})"

c1 = Counter("page_views")
c2 = Counter("clicks")
c1.increment()
c1.increment()
c2.increment()

print(c1)  # Counter('page_views', count=2)
print(c2)  # Counter('clicks', count=1)
print(Counter.total_counters)  # 2
      

The Four Pillars of OOP

1. Encapsulation

Encapsulation is the bundling of data and methods that operate on that data within a single class, and restricting direct access to some of the object's components. In Python, we use naming conventions to indicate access levels.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner          # Public attribute
        self._account_type = "savings"  # Protected (convention)
        self.__balance = balance    # Private (name mangling)

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            return f"Deposited ${amount}. New balance: ${self.__balance}"
        return "Deposit amount must be positive"

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            return f"Withdrew ${amount}. New balance: ${self.__balance}"
        return "Invalid withdrawal amount"

    def get_balance(self):
        return self.__balance

    @property
    def balance(self):
        return self.__balance

account = BankAccount("Alice", 1000)
account.deposit(500)
print(account.balance)    # 1500 (using property)
# account.__balance       # AttributeError! Cannot access directly
      

2. Inheritance

Inheritance allows a class to inherit attributes and methods from a parent class. This promotes code reuse and establishes a natural hierarchy between classes.

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

    def speak(self):
        return f"{self.name} says {self.sound}!"

    def __str__(self):
        return f"Animal: {self.name}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "Woof")  # Call parent constructor
        self.breed = breed

    def fetch(self, item):
        return f"{self.name} fetches the {item}!"

class Cat(Animal):
    def __init__(self, name, indoor=True):
        super().__init__(name, "Meow")
        self.indoor = indoor

    def purr(self):
        return f"{self.name} purrs contentedly"

dog = Dog("Rex", "German Shepherd")
cat = Cat("Whiskers")

print(dog.speak())   # "Rex says Woof!"
print(dog.fetch("ball"))  # "Rex fetches the ball!"
print(cat.speak())   # "Whiskers says Meow!"
print(cat.purr())    # "Whiskers purrs contentedly"

# isinstance checks
print(isinstance(dog, Dog))     # True
print(isinstance(dog, Animal))  # True
      

3. Polymorphism

Polymorphism means that different classes can be used through the same interface. Objects of different types can respond to the same method call in their own way.

class Shape:
    def area(self):
        raise NotImplementedError("Subclasses must implement area()")

    def perimeter(self):
        raise NotImplementedError("Subclasses must implement perimeter()")

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        import math
        return math.pi * self.radius ** 2

    def perimeter(self):
        import math
        return 2 * math.pi * self.radius

# Polymorphism in action
shapes = [Rectangle(5, 3), Circle(4), Rectangle(10, 2)]

for shape in shapes:
    # Same method call, different behavior
    print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}")
      

4. Abstraction

Abstraction hides complex implementation details and shows only the necessary features. Python supports abstraction through abstract base classes.

from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def connect(self):
        pass

    @abstractmethod
    def execute(self, query):
        pass

    def log(self, message):
        print(f"[DB LOG] {message}")

class PostgresDB(Database):
    def connect(self):
        self.log("Connecting to PostgreSQL...")
        return "Connected to PostgreSQL"

    def execute(self, query):
        self.log(f"Executing: {query}")
        return f"PostgreSQL result for: {query}"

class MongoDB(Database):
    def connect(self):
        self.log("Connecting to MongoDB...")
        return "Connected to MongoDB"

    def execute(self, query):
        self.log(f"Executing: {query}")
        return f"MongoDB result for: {query}"

# db = Database()  # TypeError! Cannot instantiate abstract class
pg = PostgresDB()
pg.connect()
pg.execute("SELECT * FROM users")
      

Advanced OOP Concepts

Class Methods and Static Methods

class Employee:
    raise_percentage = 1.05  # 5% raise

    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    # Regular method - operates on instance
    def apply_raise(self):
        self.salary *= self.raise_percentage

    # Class method - operates on class
    @classmethod
    def set_raise_percentage(cls, percentage):
        cls.raise_percentage = percentage

    # Class method as alternative constructor
    @classmethod
    def from_string(cls, emp_string):
        name, salary = emp_string.split("-")
        return cls(name, int(salary))

    # Static method - no access to instance or class
    @staticmethod
    def is_workday(day):
        return day.weekday() < 5

emp = Employee.from_string("Alice-75000")
print(emp.name, emp.salary)  # Alice 75000
      

Dunder (Magic) Methods

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

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

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

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

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

    def __len__(self):
        return int((self.x ** 2 + self.y ** 2) ** 0.5)

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)   # Vector(4, 6)
print(v1 - v2)   # Vector(2, 2)
print(v1 * 3)    # Vector(9, 12)
print(len(v1))   # 5
      

OOP Best Practices

  • Single Responsibility Principle - Each class should have one reason to change. Do not create god classes that do everything.
  • Favor Composition Over Inheritance - Use inheritance for "is-a" relationships and composition for "has-a" relationships.
  • Keep It Simple - Do not use OOP just for the sake of it. Simple scripts do not need classes.
  • Use Properties - Use @property instead of getter/setter methods for cleaner APIs.
  • Write Docstrings - Document your classes and methods so others (and future you) can understand them.

Object-oriented programming is a powerful tool in your Python toolkit. Master these concepts and you will be able to build complex, maintainable applications that scale with your needs.

pythonoopclassesinheritancepolymorphism

Want to Master This Topic?

Our interactive course goes way beyond articles. Get hands-on with 31 lessons, 25 coding exercises, and AI-evaluated quizzes.