Object-Oriented Programming in Python

Object-Oriented Programming in Python

Exploring Object-Oriented Programming in Python

Object-oriented programming, often shortened to OOP, is one of those topics that can feel intimidating at first and then suddenly start making everything click. A lot of beginners begin Python by writing small scripts: a few variables here, a function there, maybe a loop or two. That works beautifully for simple tasks. But as projects grow, code starts to spread out, responsibilities get mixed together, and the once-clean script becomes harder to maintain.

That is where OOP enters the picture.

Python gives you the tools to model real-world ideas in code using classes and objects. You can represent a user, a product, a blog post, a file manager, a bank account, or even a game character in a way that feels natural and organized. Instead of thinking only in terms of functions and data, you start thinking in terms of things that have properties and behaviors.

That shift is powerful.

In this article, we will explore OOP in Python in a practical, friendly way. We will look at classes, objects, methods, attributes, constructors, inheritance, polymorphism, encapsulation, abstraction, and a few modern Python features that make OOP feel elegant. Along the way, we will build examples that are easy to follow and realistic enough to be useful.


Why OOP matters in Python

Before diving into code, it helps to understand why people use OOP in the first place.

Imagine you are building a small system for managing a bookstore. At first, you might store everything in dictionaries:

book = {
    "title": "Clean Code",
    "author": "Robert C. Martin",
    "price": 29.99
}

That is fine for one book. But what if you have hundreds of books? What if books need methods like apply_discount() or display_info()? What if you also want customers, orders, invoices, and inventory? Suddenly plain dictionaries start feeling limited.

With OOP, you can group data and behavior together:

class Book:
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

    def display_info(self):
        return f"{self.title} by {self.author} - ${self.price:.2f}"

Now the data and the logic live together. That makes the code easier to read, easier to expand, and easier to reuse.

OOP helps you:

  • organize complex code into smaller, meaningful pieces

  • reuse logic without rewriting it

  • model real-world relationships more naturally

  • make maintenance easier over time

  • improve readability for teams and future you

That last one matters more than people admit. Code is not only written for computers. It is also written for humans.


Thinking in objects

A Python object is just a thing that contains data and behavior.

For example, a list is an object:

numbers = [1, 2, 3]

It has data, and it also has behavior:

numbers.append(4)
numbers.pop()
numbers.sort()

You already use objects all the time in Python, even if you do not always think about them that way.

When you create your own classes, you are defining your own custom objects. A class is like a blueprint. An object is the actual thing built from that blueprint.

For example:

class Dog:
    pass

dog1 = Dog()
dog2 = Dog()

Dog is the class. dog1 and dog2 are objects, also called instances.


Creating your first class

Let’s start with something simple.

class Car:
    pass

This class does nothing yet, but it is still a valid class. You can create an object from it:

my_car = Car()
print(my_car)

This will show something like:

<__main__.Car object at 0x0000012345ABCDEF>

That output tells you that my_car is an instance of Car.

Of course, a class with pass is not very useful. Let’s add attributes.


Attributes and the constructor

Attributes are pieces of data attached to an object.

The most common way to initialize attributes in Python is with the __init__ method. This method runs automatically when you create a new object.

class Car:
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year

Now we can create a car:

car1 = Car("Toyota", "Corolla", 2022)
print(car1.brand)
print(car1.model)
print(car1.year)

Output:

Toyota
Corolla
2022

What does self mean?

self refers to the current object being created or used. It allows each object to store its own data.

If you create two cars:

car1 = Car("Toyota", "Corolla", 2022)
car2 = Car("Honda", "Civic", 2024)

Each object has its own separate values.

print(car1.brand)  # Toyota
print(car2.brand)  # Honda

This is one of the most important ideas in OOP: each instance carries its own state.


Adding methods

Methods are functions inside a class. They define behavior.

Let us extend our Car class:

class Car:
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year

    def start(self):
        return f"{self.brand} {self.model} is starting."

    def stop(self):
        return f"{self.brand} {self.model} has stopped."

    def description(self):
        return f"{self.year} {self.brand} {self.model}"

Now create an object and call its methods:

car1 = Car("Toyota", "Corolla", 2022)

print(car1.start())
print(car1.stop())
print(car1.description())

Output:

Toyota Corolla is starting.
Toyota Corolla has stopped.
2022 Toyota Corolla

The nice thing here is that the behavior belongs to the object itself. You are not passing the car data into separate functions every time. You are asking the car to do things.

That sounds simple, but it makes a big difference in larger programs.


A more realistic example: bank accounts

Let us build a small bank account class.

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

    def deposit(self, amount):
        self.balance += amount
        return f"Deposited {amount}. New balance: {self.balance}"

    def withdraw(self, amount):
        if amount > self.balance:
            return "Insufficient funds"
        self.balance -= amount
        return f"Withdrew {amount}. New balance: {self.balance}"

    def show_balance(self):
        return f"{self.account_holder}'s balance: {self.balance}"

Use it like this:

account = BankAccount("Hassan", 1000)

print(account.show_balance())
print(account.deposit(500))
print(account.withdraw(300))
print(account.withdraw(1500))

Output:

Hassan's balance: 1000
Deposited 500. New balance: 1500
Withdrew 300. New balance: 1200
Insufficient funds

This is a great example of OOP in action because the class protects the logic around the account. The balance and the operations on the balance stay together in one place.


Class attributes and instance attributes

Python classes can have two main kinds of attributes:

  • instance attributes, which belong to each object

  • class attributes, which belong to the class itself and are shared by all instances

Here is an example:

class Dog:
    species = "Canis familiaris"

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

species is a class attribute. Every dog shares that same species value. name and age are instance attributes because they vary from object to object.

dog1 = Dog("Buddy", 4)
dog2 = Dog("Luna", 2)

print(dog1.species)
print(dog2.species)

print(dog1.name)
print(dog2.name)

Output:

Canis familiaris
Canis familiaris
Buddy
Luna

Class attributes are useful when a value should be shared by every instance. For example, a default tax rate, a version number, or a counter can sometimes be stored at the class level.


Methods that change object state

In OOP, methods often do more than just return text. They may modify the object itself.

Here is a Student class:

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def update_grade(self, new_grade):
        self.grade = new_grade
        return f"{self.name}'s grade updated to {self.grade}"

Now:

student = Student("Sara", "B")
print(student.update_grade("A"))
print(student.grade)

Output:

Sara's grade updated to A
A

This is very common in OOP. The object holds data, and methods act on that data.


The __str__ method: making objects readable

One of the first frustrations beginners face is printing a custom object. By default, Python displays something not very friendly.

You can improve that with __str__.

class Book:
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

    def __str__(self):
        return f"{self.title} by {self.author} (${self.price:.2f})"

Now:

book = Book("Atomic Habits", "James Clear", 18.99)
print(book)

Output:

Atomic Habits by James Clear ($18.99)

This is one of those small Python features that makes code feel polished.


Understanding encapsulation

Encapsulation means keeping data and related logic bundled together, while also limiting direct access to internal details.

In plain English, it means: do not let outside code mess with everything directly if you can avoid it.

Python does not enforce strict private variables the way some other languages do, but it gives you conventions:

  • a single underscore _value suggests internal use

  • a double underscore __value triggers name mangling and discourages direct access

Example:

class Wallet:
    def __init__(self, balance):
        self._balance = balance

The underscore tells other developers, “this is intended to be internal.”

A more controlled version uses property methods.

class Wallet:
    def __init__(self, balance):
        self._balance = balance

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

    def add_money(self, amount):
        self._balance += amount

Now the balance can be read, but direct modification is more controlled.

wallet = Wallet(100)
print(wallet.balance)
wallet.add_money(50)
print(wallet.balance)

Encapsulation helps protect your objects from accidental misuse.


Using properties for cleaner APIs

Properties are one of the most elegant features in Python OOP.

They let you define getter and setter behavior while making the attribute look simple to the user.

Example:

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 cannot be below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return (self._celsius * 9/5) + 32

Use it like this:

temp = Temperature(25)
print(temp.celsius)
print(temp.fahrenheit)

temp.celsius = 30
print(temp.celsius)

# temp.celsius = -300  # would raise ValueError

This is powerful because the user of the class writes simple code, while the class itself protects its rules internally.


Inheritance: building on existing classes

Inheritance lets one class reuse and extend another class.

This is useful when one thing is a more specific version of another thing.

For example:

  • a Vehicle is a general concept

  • a Car is a type of vehicle

  • a Truck is a type of vehicle

  • a Motorcycle is a type of vehicle

Let’s write that in Python.

class Vehicle:
    def __init__(self, brand):
        self.brand = brand

    def start(self):
        return f"{self.brand} vehicle is starting"

Now a Car can inherit from Vehicle:

class Car(Vehicle):
    def __init__(self, brand, model):
        super().__init__(brand)
        self.model = model

    def honk(self):
        return f"{self.brand} {self.model} says beep beep"

Use it:

car = Car("Toyota", "Corolla")
print(car.start())
print(car.honk())

Output:

Toyota vehicle is starting
Toyota Corolla says beep beep

Why use inheritance?

Inheritance helps avoid duplication. Instead of rewriting the same methods in every class, you define common behavior once in the parent class and extend it in child classes.

That said, inheritance should be used carefully. Not every relationship is truly “is a.” Sometimes composition is a better choice, and we will get to that soon.


Method overriding

A child class can replace a parent method with its own version.

Example:

class Animal:
    def speak(self):
        return "Some sound"

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

class Dog(Animal):
    def speak(self):
        return "Woof"

Now:

animals = [Cat(), Dog(), Animal()]

for animal in animals:
    print(animal.speak())

Output:

Meow
Woof
Some sound

This is method overriding. The same method name behaves differently depending on the object.

That behavior is one of the foundations of polymorphism.


Polymorphism: one interface, many forms

Polymorphism means different objects can respond to the same method or action in different ways.

Here is another example:

class Rectangle:
    def area(self):
        return "Area of rectangle"

class Circle:
    def area(self):
        return "Area of circle"

A function can work with either object:

def print_area(shape):
    print(shape.area())

Use it:

print_area(Rectangle())
print_area(Circle())

In real projects, polymorphism lets you write flexible code that does not care about the exact class as long as the object offers the methods you need.

That is a very Pythonic idea.


Abstract classes: defining a contract

Sometimes you want to define a class that should not be used directly, but should serve as a template for other classes.

Python supports this using the abc module.

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    @abstractmethod
    def pay(self, amount):
        pass

Now any class that inherits from PaymentMethod must implement pay.

class CreditCard(PaymentMethod):
    def pay(self, amount):
        return f"Paid {amount} using credit card"

class PayPal(PaymentMethod):
    def pay(self, amount):
        return f"Paid {amount} using PayPal"

Use them:

methods = [CreditCard(), PayPal()]

for method in methods:
    print(method.pay(100))

Abstract classes are useful when you want to make sure every child class follows the same structure.


Composition: a powerful alternative to inheritance

Inheritance is useful, but composition is often even better.

Composition means building objects out of other objects.

For example, instead of saying “a car is a vehicle” only in terms of inheritance, you might say “a car has an engine.”

class Engine:
    def start(self):
        return "Engine started"

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        return self.engine.start()

Use it:

car = Car()
print(car.start())

Composition often gives you more flexibility because you can swap parts without changing the entire class hierarchy.

A good rule of thumb:

  • use inheritance when there is a clear “is-a” relationship

  • use composition when there is a “has-a” relationship

That simple distinction can save you from messy designs.


Class methods and static methods

Python gives you two useful decorators for class-related behavior.

Class methods

A class method receives the class itself as the first argument, usually named cls.

class Book:
    total_books = 0

    def __init__(self, title):
        self.title = title
        Book.total_books += 1

    @classmethod
    def get_total_books(cls):
        return cls.total_books

Use it:

book1 = Book("Book A")
book2 = Book("Book B")

print(Book.get_total_books())

Static methods

A static method belongs to the class namespace, but it does not automatically receive self or cls.

class MathHelper:
    @staticmethod
    def add(a, b):
        return a + b

Use it:

print(MathHelper.add(5, 7))

Static methods are helpful when a function logically belongs to a class, but does not need object state.


Dunder methods: Python’s special magic

Python has many special methods with double underscores, often called dunder methods.

These methods let your objects integrate smoothly with Python’s built-in behavior.

Some common ones:

  • __init__ — object initialization

  • __str__ — user-friendly string representation

  • __repr__ — developer-friendly representation

  • __len__ — used by len()

  • __eq__ — used for equality comparison

  • __add__ — used for +

  • __getitem__ — used for indexing

Example:

class Playlist:
    def __init__(self, songs):
        self.songs = songs

    def __len__(self):
        return len(self.songs)

    def __str__(self):
        return f"Playlist with {len(self)} songs"

Use it:

playlist = Playlist(["Song 1", "Song 2", "Song 3"])
print(len(playlist))
print(playlist)

Output:

3
Playlist with 3 songs

This is where Python OOP becomes especially elegant. Your objects can behave naturally inside the language.


Building a mini project: a library system

Let us combine several OOP ideas into a small example.

We will create:

  • Book

  • Member

  • Library

The book

class Book:
    def __init__(self, title, author, isbn, available=True):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.available = available

    def __str__(self):
        status = "Available" if self.available else "Borrowed"
        return f"{self.title} by {self.author} [{status}]"

The member

class Member:
    def __init__(self, name):
        self.name = name
        self.borrowed_books = []

    def borrow_book(self, book):
        if book.available:
            book.available = False
            self.borrowed_books.append(book)
            return f"{self.name} borrowed {book.title}"
        return f"{book.title} is not available"

    def return_book(self, book):
        if book in self.borrowed_books:
            book.available = True
            self.borrowed_books.remove(book)
            return f"{self.name} returned {book.title}"
        return f"{self.name} did not borrow {book.title}"

The library

class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def list_books(self):
        return [str(book) for book in self.books]

    def find_book(self, title):
        for book in self.books:
            if book.title.lower() == title.lower():
                return book
        return None

Using the system

library = Library()

book1 = Book("Clean Code", "Robert C. Martin", "111")
book2 = Book("The Pragmatic Programmer", "Andrew Hunt", "222")
book3 = Book("Python Crash Course", "Eric Matthes", "333")

library.add_book(book1)
library.add_book(book2)
library.add_book(book3)

member = Member("Hassan")

found_book = library.find_book("Clean Code")
if found_book:
    print(member.borrow_book(found_book))

print(library.list_books())

print(member.return_book(found_book))
print(library.list_books())

This example shows how OOP helps separate responsibilities:

  • Book knows about book data

  • Member handles borrowing and returning

  • Library manages the collection

That separation makes the code easier to extend. If later you want to add due dates, late fees, reservations, or multiple branches, you already have a good structure to work with.


A note on design: keep classes focused

A class should usually have one main responsibility.

That does not mean one tiny responsibility in a useless sense. It means one coherent job.

For example:

  • User handles user-related data and behavior

  • Invoice handles billing details

  • ReportGenerator handles report creation

A class that tries to do everything becomes hard to test and hard to understand.

A clean class often answers a simple question: “What is this object responsible for?”

If the answer gets too long, the class may need to be split.


Common OOP mistakes in Python

When people first learn OOP, they often make a few predictable mistakes. That is normal. The important thing is noticing them early.

1. Turning everything into a class

Not every piece of code needs a class.

If you only need a simple calculation, a function may be better than a class. OOP is a tool, not a rule.

2. Using inheritance too much

Inheritance can create rigid designs. If classes become deeply nested, the code may get harder to understand. In many cases, composition is cleaner.

3. Putting too much logic in one class

A class with too many responsibilities becomes bloated. Keep things focused.

4. Accessing internals directly when you should not

Even though Python allows it, try not to modify internal attributes casually if the class provides proper methods or properties.

5. Ignoring readability

A fancy OOP design is not automatically a good design. The best code is often the code that another developer can understand quickly.


OOP and Python style

Python OOP has a different flavor from OOP in some other languages.

Python tends to be:

  • simple

  • readable

  • flexible

  • less strict

  • often more practical than ceremonial

You do not need huge amounts of boilerplate to write object-oriented code in Python. A few well-designed classes can go a long way.

That is part of what makes Python enjoyable.


OOP versus procedural programming

It is tempting to ask which style is “better,” but the better question is: which style fits the problem?

Procedural style

This style focuses on functions and step-by-step instructions.

Example:

def calculate_total(price, tax):
    return price + (price * tax)

This is simple and great for small tasks.

OOP style

This style groups related data and behavior.

Example:

class Product:
    def __init__(self, price, tax):
        self.price = price
        self.tax = tax

    def calculate_total(self):
        return self.price + (self.price * self.tax)

This works well when you have objects with behavior, state, and relationships.

Often, the best Python programs mix both styles. You might use functions for utility work and classes for structured domains.

That balance is usually where Python shines.


Real-world uses of OOP in Python

You do not need to build a giant enterprise system to benefit from OOP. It appears in many kinds of projects:

  • web applications

  • desktop software

  • games

  • APIs

  • data processing tools

  • automation scripts

  • scientific applications

  • GUI programs

For example:

  • in a web app, a User, Post, and Comment model may be classes

  • in a game, Player, Enemy, and Weapon may be classes

  • in a finance tool, Transaction, Account, and Report may be classes

  • in an automation script, FileManager and BackupJob may be classes

Whenever you need to model a thing with state and behavior, OOP becomes a strong candidate.


Building better mental models

A useful way to understand OOP is to think in terms of nouns and verbs.

  • nouns are objects: car, user, file, order, book

  • verbs are methods: start, login, open, pay, borrow

This is not a perfect rule, but it helps.

If you are designing software and you can clearly name the things and the actions, your classes will often become easier to shape.

For example:

  • Order can add_item(), remove_item(), calculate_total()

  • User can login(), logout(), update_profile()

  • FileManager can read_file(), write_file(), delete_file()

That natural language thinking is one reason OOP feels intuitive once it clicks.


Testing OOP code

Classes are not only for organization. They also make testing easier when designed well.

Example:

class Calculator:
    def add(self, a, b):
        return a + b

    def multiply(self, a, b):
        return a * b

A test could look like this:

calc = Calculator()
assert calc.add(2, 3) == 5
assert calc.multiply(4, 5) == 20

For more complex classes, testing helps ensure that your methods behave correctly and that future changes do not break old behavior.

When classes are small and focused, tests become much easier to write.


A polished example: employee management

Here is another example that brings together several ideas.

class Employee:
    company = "TechCorp"

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

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, value):
        if value < 0:
            raise ValueError("Salary cannot be negative")
        self._salary = value

    def promote(self, new_role, raise_amount):
        self.role = new_role
        self.salary += raise_amount
        return f"{self.name} promoted to {self.role}"

    def __str__(self):
        return f"{self.name} - {self.role} - ${self.salary}"

Use it:

emp = Employee("Amina", "Developer", 5000)

print(emp)
print(emp.promote("Senior Developer", 1500))
print(emp)
print(Employee.company)

This class uses:

  • instance attributes

  • class attributes

  • property

  • setter validation

  • behavior through methods

  • a custom string representation

That is a lot of power in a compact design.


When OOP is not the best choice

OOP is useful, but not magical.

Sometimes a simple function-based solution is better. For example:

  • quick data transformations

  • short scripts

  • one-time automation

  • simple calculations

  • small utilities

In those cases, classes may add unnecessary complexity.

Good programming is not about forcing every problem into one style. It is about choosing the simplest tool that still keeps the code clear and maintainable.

That is a very mature programming habit, and it becomes easier to develop with experience.


Practical tips for learning OOP in Python

If OOP feels confusing at first, that is normal. The ideas often make more sense after a few real examples.

A few habits help a lot:

  1. Start with simple classes.

  2. Focus on objects from daily life.

  3. Practice with small projects.

  4. Use self carefully and consistently.

  5. Try building with composition and inheritance.

  6. Read other people’s code.

  7. Refactor your own code after it works.

The real breakthrough usually comes when you stop memorizing definitions and start using classes to solve actual problems.


A small challenge for practice

Try modeling one of these in Python:

  • a shopping cart

  • a todo list

  • a car rental system

  • a school class

  • a movie collection

  • a game character

For each one, think about:

  • what the objects are

  • what data each object stores

  • what actions each object can perform

  • whether inheritance or composition makes sense

That kind of practice builds real understanding.


Final thoughts

Object-oriented programming in Python is not just about classes and syntax. It is about learning to organize code around ideas, responsibilities, and relationships. Once you begin thinking this way, your programs often become cleaner, easier to expand, and easier to reason about.

The best part is that Python makes OOP approachable. You do not need complicated setup or heavy ceremony. You can begin with a simple class and gradually build toward more advanced patterns as your needs grow.

If you are just starting, do not rush to memorize every term. Build small things. Tweak them. Break them. Fix them. Add methods. Add validation. Try inheritance. Replace inheritance with composition. Observe how the design changes. That hands-on process is where OOP really becomes yours.

And once it does, you will start seeing objects everywhere in your code, in your tools, and in the systems you build.

That is when OOP stops being a topic and starts becoming a way of thinking.