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
_valuesuggests internal usea double underscore
__valuetriggers 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
Vehicleis a general concepta
Caris a type of vehiclea
Truckis a type of vehiclea
Motorcycleis 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 bylen()__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:
BookMemberLibrary
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:
Bookknows about book dataMemberhandles borrowing and returningLibrarymanages 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:
Userhandles user-related data and behaviorInvoicehandles billing detailsReportGeneratorhandles 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, andCommentmodel may be classesin a game,
Player,Enemy, andWeaponmay be classesin a finance tool,
Transaction,Account, andReportmay be classesin an automation script,
FileManagerandBackupJobmay 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:
Ordercanadd_item(),remove_item(),calculate_total()Usercanlogin(),logout(),update_profile()FileManagercanread_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:
Start with simple classes.
Focus on objects from daily life.
Practice with small projects.
Use
selfcarefully and consistently.Try building with composition and inheritance.
Read other people’s code.
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.