Object-oriented programming (OOP) is a way of structuring code around objects — bundles of data (attributes) and the behaviour (methods) that operates on that data. In Python, a class defines the blueprint and an object is a concrete instance of it. OOP matters because it lets you model real-world entities, group related state and behaviour together, reuse code through inheritance, and keep large programs readable and maintainable.
Python supports both procedural and object-oriented styles. Procedural code works fine for short scripts, but as a program grows, scattered functions and global state become hard to follow. OOP gives you a clear home for each piece of state and the operations on it. This guide uses modern, runnable Python 3 (tested on 3.12 / 3.13). At MicroPyramid we have shipped 50+ Python projects over 12+ years, and clean OOP design is one of the things that keeps those codebases easy to evolve.
If you prefer to learn by analogy: think of a class as the design for an electric fan, and each physical fan you build from that design as an object.
The four pillars of OOP
Every OOP language is built on four core ideas. In Python they look like this:
- Encapsulation — bundling data and the methods that act on it inside a class, and controlling access to internal state (in Python, by convention with a leading underscore and with
@property). - Inheritance — a class can acquire attributes and methods from a parent class, so shared behaviour is written once.
- Polymorphism — the same method name behaves differently depending on the object it is called on; Python leans heavily on duck typing for this.
- Abstraction — exposing a simple interface while hiding the implementation details, often expressed with abstract base classes (
abc.ABC).
The rest of this article walks through each concept with code you can paste into a REPL.
Classes and objects
A class is created with the class keyword. In Python 3 you no longer need to inherit from object — every class is a new-style class automatically, so class Fan: and class Fan(object): are equivalent. The __init__ method is the initialiser: it runs when you create an object and sets up its initial state. self is the conventional name for the instance being operated on.
Let's model an electric fan. The nouns/adjectives in the requirement (company, colour, number of wings) become attributes; the verbs (switch on, switch off, speed up, speed down) become methods.
class Fan:
"""A simple electric fan."""
def __init__(self, company: str, color: str, number_of_wings: int = 3) -> None:
self.company = company
self.color = color
self.number_of_wings = number_of_wings
self._speed = 0
def switch_on(self) -> None:
self._speed = 1
print("Fan started")
def switch_off(self) -> None:
self._speed = 0
print("Fan stopped")
def speed_up(self) -> None:
self._speed += 1
print(f"Speed increased to {self._speed}")
def speed_down(self) -> None:
self._speed = max(0, self._speed - 1)
print(f"Speed decreased to {self._speed}")Create an object (an instance) by calling the class like a function. The arguments are passed to __init__:
>>> usha_fan = Fan(company="Usha", color="blue", number_of_wings=3)
>>> usha_fan
<__main__.Fan object at 0x7f105819ff50>
# Read attributes
>>> usha_fan.company
'Usha'
>>> usha_fan.color
'blue'
>>> usha_fan.number_of_wings
3
# Call methods
>>> usha_fan.switch_on()
Fan started
>>> usha_fan.speed_up()
Speed increased to 2
>>> usha_fan.switch_off()
Fan stoppedInstance attributes vs class attributes
There are two kinds of data on a class:
- Instance attributes are set on
self(usually inside__init__) and are unique to each object. - Class attributes are defined directly in the class body and shared by every instance.
A common gotcha: never use a mutable default (like a list) as a class attribute that you mutate per instance — all instances would share it. Use __init__ for per-object state instead.
class Circle:
pi = 3.14159 # class attribute, shared by all circles
def __init__(self, radius: float = 1.0) -> None:
self.radius = radius # instance attribute, per object
def area(self) -> float:
return self.radius * self.radius * Circle.pi
>>> c = Circle(radius=15)
>>> round(c.area(), 2)
706.86
>>> Circle.pi # accessed on the class
3.14159
>>> c.pi # also visible on the instance
3.14159Instance, class and static methods
Python has three method types. The decorator you use controls what the method receives as its first argument.
| Method type | Decorator | First argument | Typical use |
|---|---|---|---|
| Instance method | (none) | self (the instance) |
Read/modify per-object state |
| Class method | @classmethod |
cls (the class) |
Alternative constructors, class-wide state |
| Static method | @staticmethod |
(none) | Utility logic related to the class but needing no self/cls |
The example below shows all three on one class.
class Temperature:
def __init__(self, celsius: float) -> None:
self.celsius = celsius
# instance method: uses self
def to_fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32
# class method: alternative constructor, uses cls
@classmethod
def from_fahrenheit(cls, fahrenheit: float) -> "Temperature":
return cls((fahrenheit - 32) * 5 / 9)
# static method: no self/cls needed
@staticmethod
def is_freezing(celsius: float) -> bool:
return celsius <= 0
>>> t = Temperature.from_fahrenheit(212)
>>> t.celsius
100.0
>>> t.to_fahrenheit()
212.0
>>> Temperature.is_freezing(-4)
TrueInheritance and the MRO
Inheritance lets a child class reuse and extend a parent class. Use super() to call the parent's implementation — the modern, zero-argument form replaces the old super(ChildClass, self) style.
When a class inherits from several parents, Python resolves which method to use via the Method Resolution Order (MRO), computed with the C3 linearisation algorithm. You can inspect it with ClassName.__mro__ or ClassName.mro().
class Animal:
"""Base class with properties common to all animals."""
def __init__(self, name: str, no_of_legs: int = 4) -> None:
self.name = name
self.no_of_legs = no_of_legs
def kingdom(self) -> str:
return "Animalia"
def talk(self) -> str:
raise NotImplementedError("Subclasses must implement talk()")
class Dog(Animal):
def talk(self) -> str:
return "Woof!"
class Cat(Animal):
def __init__(self, name: str) -> None:
super().__init__(name, no_of_legs=4) # reuse parent __init__
def talk(self) -> str:
return "Meow!"
>>> cat = Cat("Missy")
>>> cat.kingdom() # inherited from Animal
'Animalia'
>>> cat.talk()
'Meow!'
>>> Cat.__mro__
(<class '__main__.Cat'>, <class '__main__.Animal'>, <class 'object'>)Polymorphism and duck typing
Polymorphism means the same operation works on different types. Here talk() returns different sounds depending on the object. Because Python is dynamically typed, you don't need a shared base class for this to work — if an object has the method you call, it fits. That is duck typing: "if it walks like a duck and quacks like a duck, it's a duck."
>>> animals = [Dog("Rocky"), Cat("Missy")]
>>> for animal in animals:
... print(f"{animal.name} says {animal.talk()}")
Rocky says Woof!
Missy says Meow!
# Duck typing: no common base class required
class Duck:
def talk(self) -> str:
return "Quack!"
def make_it_talk(thing) -> None:
print(thing.talk()) # works for anything with a talk() method
>>> make_it_talk(Duck())
Quack!Encapsulation: properties and name-mangling
Encapsulation hides internal state behind a controlled interface. Python does not have truly private attributes, but it offers two conventions:
- A single leading underscore (
_balance) signals "internal — please don't touch." - A double leading underscore (
__balance) triggers name-mangling: Python renames it to_ClassName__balance, making accidental access from outside or subclasses unlikely.
The Pythonic way to expose computed or validated access is the @property decorator, which lets a method behave like an attribute — no Java-style getX()/setX() needed.
class Account:
def __init__(self, balance: float = 0) -> None:
self.__balance = balance # name-mangled to _Account__balance
@property
def balance(self) -> float:
"""Read-only view of the balance."""
return self.__balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive")
self.__balance += amount
>>> acc = Account(100)
>>> acc.balance # looks like an attribute, runs the property
100
>>> acc.deposit(50)
>>> acc.balance
150
>>> acc.balance = 999 # blocked: no setter defined
AttributeError: property 'balance' of 'Account' object has no setter
>>> acc._Account__balance # name-mangling in action (avoid doing this)
150Abstraction with abc.ABC
Abstraction lets you define what an interface looks like without specifying how it works. The abc module provides abstract base classes: mark a class with ABC and decorate required methods with @abstractmethod. Python then refuses to instantiate any subclass that hasn't implemented every abstract method — a clear, enforced contract.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
...
@abstractmethod
def perimeter(self) -> float:
...
class Rectangle(Shape):
def __init__(self, width: float, height: float) -> None:
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def perimeter(self) -> float:
return 2 * (self.width + self.height)
>>> Shape() # cannot instantiate an abstract class
TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter
>>> r = Rectangle(4, 5)
>>> r.area(), r.perimeter()
(20, 18)Modern Python OOP: dataclasses, slots and type hints
Since Python 3.7, the @dataclass decorator removes most of the boilerplate for classes that mainly hold data. It auto-generates __init__, __repr__ and __eq__ from your type-annotated fields. Adding slots=True (Python 3.10+) stores attributes in a fixed __slots__ layout instead of a per-instance __dict__, which lowers memory use and speeds up attribute access — at the cost of not being able to add new attributes at runtime.
Here is a plain class versus its dataclass equivalent:
# Plain class: lots of boilerplate
class PointPlain:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"PointPlain(x={self.x}, y={self.y})"
def __eq__(self, other) -> bool:
return isinstance(other, PointPlain) and (self.x, self.y) == (other.x, other.y)
# Dataclass: __init__, __repr__ and __eq__ are generated for you
from dataclasses import dataclass
@dataclass(slots=True)
class Point:
x: float
y: float
def distance_to(self, other: "Point") -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
>>> p = Point(0, 0)
>>> q = Point(3, 4)
>>> p
Point(x=0, y=0)
>>> p == Point(0, 0)
True
>>> p.distance_to(q)
5.0Dataclass vs plain class — when to use which
| Aspect | Plain class | @dataclass |
|---|---|---|
__init__ / __repr__ / __eq__ |
Write by hand | Generated automatically |
| Best for | Rich behaviour, custom construction | Data-centric records and DTOs |
| Immutability | Manual | @dataclass(frozen=True) |
| Memory optimisation | Manual __slots__ |
@dataclass(slots=True) |
| Boilerplate | High | Low |
Reach for a dataclass when a class is mostly a typed bundle of fields; keep a plain class when construction or behaviour is complex enough that the generated methods would get in your way. Type hints (the x: float annotations) are optional at runtime but make your intent explicit and unlock tooling like mypy and editor autocompletion — both well worth adopting on any serious Python codebase.
Frequently Asked Questions
What is object-oriented programming in Python?
Object-oriented programming in Python is a style where you organise code into classes that bundle data (attributes) with the functions that operate on it (methods). You create objects from those classes and interact with them through their methods. It helps you model real-world entities, reuse code via inheritance, and keep large programs readable compared to a purely procedural approach.
What are the four pillars of OOP?
The four pillars are encapsulation (bundling data with the methods that act on it and controlling access), inheritance (a class acquiring behaviour from a parent), polymorphism (the same method working differently across types, often via duck typing in Python), and abstraction (exposing a simple interface while hiding implementation, typically with abc.ABC).
What is the difference between a class and an object in Python?
A class is the blueprint — it defines the attributes and methods. An object (also called an instance) is a concrete thing built from that blueprint. For example, Fan is a class, while usha_fan = Fan("Usha", "blue", 3) is an object created from it. You can make many independent objects from a single class.
When should I use a dataclass instead of a regular class?
Use @dataclass when a class is mainly a typed container of fields, because it generates __init__, __repr__ and __eq__ for you and removes boilerplate. Add slots=True for lower memory use or frozen=True for immutability. Stick with a plain class when construction or behaviour is complex enough that the generated methods would get in the way.
How does inheritance and the MRO work in Python?
A child class lists its parent in parentheses, e.g. class Dog(Animal):, and inherits the parent's attributes and methods. Call the parent's version with the modern super() (no arguments needed). With multiple inheritance, Python decides which method to use through the Method Resolution Order, computed by the C3 linearisation algorithm; inspect it with ClassName.__mro__.
Are there truly private attributes in Python?
Not strictly. Python uses conventions instead of access modifiers: a single leading underscore (_value) signals "internal use," and a double leading underscore (__value) triggers name-mangling to _ClassName__value, which discourages accidental access. To expose controlled or computed access, use the @property decorator rather than Java-style getter and setter methods.
Where to go next
Once you are comfortable with these fundamentals, dig into the special class methods (magic/dunder methods) that let your objects support operators and built-ins, and learn how decorators build on the same callable-object ideas. If you are building production systems and want a hand, see our Python development services — and for web work, our Django and FastAPI teams apply exactly these OOP patterns every day.