Mahdi Shamlou here.
You've been through Design Patterns — Creational, Structural, Behavioral. now you already know one thing cold: Object-Oriented Programming.
Most developers know some OOP. They know classes, they know inheritance, they've written self a thousand times. But there's a gap between "I can write a class" and "I actually understand what OOP is doing for me" — and that gap is where bugs, bad abstractions, and unreadable codebases come from.
I've seen:
- Classes with 20 attributes and no clear responsibility
- Inheritance hierarchies four levels deep for what should've been one
ifstatement -
__init__methods doing validation, I/O, and business logic all at once - Getters and setters copy-pasted from Java, in a language that doesn't need them
- Multiple inheritance used without anyone understanding MRO
Let's fix that. This is OOP in Python, from the fundamentals to the parts most tutorials skip.
1. What Is OOP?
OOP is a way of structuring code around objects — bundles of data (attributes) and behavior (methods) — instead of around functions operating on loose data.
Instead of passing raw data between standalone functions, you model your problem as a set of objects that each know their own data and expose the operations that make sense on that data. A Dog knows its own name and knows how to bark(). A BankAccount knows its own balance and knows how to deposit() safely.
Everyone can quote "the four pillars" — Encapsulation, Inheritance, Polymorphism, Abstraction. Fewer people can tell you what each one actually buys you in real code, or how loosely Python enforces them compared to Java/C++. That's the gap Section 2 closes — one pillar at a time, with a sample, where you'd use it, and its pros and cons.
My Take:
Python implements all four pillars, but looser and more flexible than most languages that popularized OOP. That flexibility is a gift and a trap — Python won't stop you from breaking encapsulation or building a tangled inheritance mess, so the discipline has to come from you, not the compiler.
2. The Four Pillars of OOP, In Detail
2.1 Encapsulation
Description: Bundle data and behavior together in one unit, and control how the outside world is allowed to touch that data. Languages like Java give you real private/protected/public access modifiers enforced by the compiler. Python has the same three concepts, but only simulates them with naming conventions — nothing is actually locked:
-
Public (
self.balance) — no restriction, accessible from anywhere. -
Protected (
self._balance, single underscore) — a convention meaning "internal use, subclasses can touch this, outsiders shouldn't." Python does not enforce it at all. -
Private (
self.__balance, double underscore) — triggers name mangling (renamed internally to_ClassName__balance), which mostly prevents accidental name clashes in subclasses. It's not real security — it's still reachable if you know the mangled name.
Sample:
class BankAccount:
def __init__(self, balance):
self.balance = balance # public
self._pin = "1234" # protected (convention only)
self.__account_number = "9999" # private (name-mangled)
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
account = BankAccount(1000)
print(account.balance) # 1000 — fine
print(account._pin) # "1234" — works, but breaks convention
print(account._BankAccount__account_number) # "9999" — technically still reachable
Usage: Use encapsulation whenever an object owns state that must never become invalid — balances, quantities, statuses — so you route every change through a method (or a @property) instead of letting outside code poke at raw fields directly.
Pros:
- Keeps invalid states from happening by accident
- Internal representation can change without breaking the code that uses the object
- Groups related data and logic in one readable place
Cons:
- Python's privacy is convention-based, not enforced — a determined caller can bypass it
- Overusing
_/__and properties for trivial fields adds needless ceremony
2.2 Inheritance
Description: Reuse and extend behavior from a parent ("base") class in a child ("derived") class. Models a genuine "is-a" relationship — a Dog is an Animal.
Sample:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
dog = Dog("Rex")
print(dog.speak()) # Rex says Woof! — inherited __init__, own speak()
Dog gets __init__ for free from Animal and only adds what's actually different.
Usage: Use inheritance when several classes genuinely share identity and behavior, and you want changes to the shared behavior to propagate to all of them automatically.
Pros:
- Avoids duplicating shared behavior across related classes
- Enables polymorphism (next section)
- Models natural hierarchies cleanly when they're real
Cons:
- Deep hierarchies get fragile — a change to the base ripples into every subclass
- Tempts you into "is-a" relationships that are really "has-a" (see Composition, Section 4)
- Tight coupling between parent and child
2.3 Polymorphism
Description: Objects of different classes respond to the same interface, each in its own way. This pillar has two ideas people mix up, so let's separate them:
- Method overriding — a subclass redefines a method that its parent already has, giving it new behavior. This is fully supported in Python and is the normal way polymorphism works here.
-
Method overloading — defining multiple versions of the same method name that differ only by parameter type/count, and having the language pick the right one at call time (this is standard in Java/C++). Python does not support real method overloading — a later
defwith the same name simply replaces the earlier one. Python simulates the same outcome with default arguments,*args/**kwargs, orfunctools.singledispatch.
Sample — method overriding:
class Shape:
def area(self):
raise NotImplementedError
class Rectangle(Shape):
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height # overrides Shape.area()
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2 # overrides Shape.area()
for shape in [Rectangle(4, 5), Circle(3)]:
print(shape.area()) # caller doesn't care which shape it is
Sample — simulating overloading (Python has no real overloading):
from functools import singledispatchmethod
class Printer:
@singledispatchmethod
def show(self, value):
print(f"Value: {value}")
@show.register
def _(self, value: int):
print(f"Integer: {value}")
@show.register
def _(self, value: str):
print(f"String: {value}")
Printer().show(5) # Integer: 5
Printer().show("hi") # String: hi
Usage: Reach for overriding whenever subclasses need to specialize shared behavior. Reach for singledispatch/default arguments when you want "same name, different handling per type," since Python won't give you real overloading.
Pros:
- Write code against an interface, not a concrete type — easy to extend with new subclasses
- Duck typing (below) gets you polymorphism without even requiring a shared base class
Cons:
- No compile-time checking — a missing/misspelled override just silently doesn't run
- Simulated overloading (
singledispatch,*args) is less discoverable than real overloading in statically typed languages
Bonus — duck typing: Python's actual, most common form of polymorphism doesn't need inheritance at all:
class Duck:
def quack(self): print("Quack!")
class Person:
def quack(self): print("I'm imitating a duck!")
def make_it_quack(thing):
thing.quack() # only cares that .quack() exists
make_it_quack(Duck())
make_it_quack(Person())
"If it walks like a duck and quacks like a duck, it's a duck."
2.4 Abstraction
Description: Expose what an object does while hiding how it does it. Python formalizes this with abc (Abstract Base Classes), which enforce a contract at instantiation time instead of just by convention.
Sample:
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount): ...
@abstractmethod
def refund(self, amount): ...
class StripeGateway(PaymentGateway):
def charge(self, amount):
print(f"Charging ${amount} via Stripe")
def refund(self, amount):
print(f"Refunding ${amount} via Stripe")
# PaymentGateway() # TypeError — can't instantiate an abstract class
gateway = StripeGateway() # works — all abstract methods implemented
Anyone using PaymentGateway only needs to know it can charge(). They don't need to know if that means calling Stripe, PayPal, or a bank API underneath.
Usage: Use ABC when you're defining a contract that multiple implementations must follow — payment gateways, storage backends, exporters.
Pros:
- Forces subclasses to implement required behavior, caught at instantiation time, not later at runtime
- Defines a clear, explicit contract for a family of classes
- Keeps implementation details out of the code that consumes the abstraction
Cons:
- Adds ceremony for simple, one-off classes where a plain function or duck typing would do
- Overusing ABCs risks the same rigidity problem as heavy inheritance
3. Classes, Objects, and What's Actually Going On Under the Hood
A class is a blueprint. An object (instance) is a concrete thing built from that blueprint.
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def bark(self):
print(f"{self.name} says Woof!")
rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Poodle")
rex.bark() # Rex says Woof!
bella.bark() # Bella says Woof!
Dog is the class. rex and bella are objects — same blueprint, different data.
Attributes: instance vs. class
Instance attributes belong to one object. Class attributes are shared across every instance. Mixing these up is one of the most common real bugs in Python code.
class ShoppingCart:
items = [] # class attribute — shared by ALL instances!
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("Laptop")
cart2.add_item("Phone")
print(cart1.items) # ['Laptop', 'Phone'] — BUG!
Fix it by putting mutable state in __init__:
class ShoppingCart:
def __init__(self):
self.items = [] # instance attribute — unique per object
Class attributes are still the right tool for genuinely shared data:
class Employee:
company_name = "Acme Corp" # shared, doesn't change per employee
employee_count = 0
def __init__(self, name):
self.name = name
Employee.employee_count += 1
So what is a class, really? Meet type and metaclasses
Here's the part most tutorials skip: in Python, everything is an object — including the class itself. A class is just an instance of something else, and that "something else" is called a metaclass.
print(type(42)) # <class 'int'>
print(type(int)) # <class 'type'>
int is a class. But int is also an object — an instance of type. type is the default metaclass that builds every class in Python, whether you write class Dog: explicitly or not.
A metaclass is the class that creates classes. When you write:
class Dog:
def bark(self):
print("Woof")
Python is really doing something like Dog = type("Dog", (), {"bark": ...}) behind the scenes. You can hook into that process and customize it:
class MyMeta(type):
def __new__(cls, name, bases, namespace):
print(f"Creating class: {name}")
return super().__new__(cls, name, bases, namespace)
class Dog(metaclass=MyMeta):
pass
class Cat(metaclass=MyMeta):
pass
# output
# Creating class: Dog
# Creating class: Cat
This is how frameworks like Django build ORM models — the metaclass intercepts class creation and auto-registers fields before the class even finishes being defined.
My Take:
You almost never need to write a metaclass. But knowing they exist changes how you read library code — when you see "magic" happening at class-definition time in Django or SQLAlchemy, a metaclass is usually the reason. As Tim Peters put it, metaclasses are deeper magic than 99% of users should ever worry about. Know it, don't reach for it.
4. OOP in Practice: Methods, Encapsulation, Inheritance, Polymorphism, Abstraction
Now let's actually build with it.
Instance, class, and static methods
class Pizza:
def __init__(self, toppings):
self.toppings = toppings
def describe(self): # instance method — needs self
return f"Pizza with {', '.join(self.toppings)}"
@classmethod
def margherita(cls): # class method — alternative constructor
return cls(["tomato", "mozzarella", "basil"])
@staticmethod
def is_valid_topping(topping): # static method — just a utility
return topping in {"tomato", "mozzarella", "basil", "pepperoni"}
| Method type | First argument | Accesses instance? | Accesses class? | Common use |
|---|---|---|---|---|
| Instance method | self |
Yes | Yes (type(self)) |
Normal object behavior |
| Class method | cls |
No | Yes | Alternative constructors |
| Static method | none | No | No | Utility grouped with the class |
Encapsulation, the Pythonic way
Python has no true private, just naming conventions: balance (public), _pin (convention: internal), __account_number (name-mangled).
The real tool is @property:
class BankAccount:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
My Take: Don't cargo-cult Java's get_x()/set_x() into Python. Start with plain public attributes; add a @property only when you need validation or a computed value. It doesn't break your API later, since obj.attr still works.
Inheritance and composition
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class Manager(Employee):
def __init__(self, name, salary, team_size):
super().__init__(name, salary)
self.team_size = team_size
But not every relationship should be inheritance. A Car isn't a kind of Engine — it has one:
class Engine:
def start(self):
print("Engine starting...")
class Car:
def __init__(self):
self.engine = Engine() # Car HAS an engine
def drive(self):
self.engine.start()
My Take: This is the single most valuable lesson in this whole guide. Before writing class B(A):, ask: "Is B really a kind of A, or does B just need something A can do?" If it's the latter, compose.
Polymorphism and duck typing
class Duck:
def quack(self):
print("Quack!")
class Person:
def quack(self):
print("I'm imitating a duck!")
def make_it_quack(thing):
thing.quack() # doesn't care about type, only that .quack() exists
make_it_quack(Duck())
make_it_quack(Person())
No shared base class needed — this is Python's real polymorphism. "If it walks like a duck and quacks like a duck, it's a duck."
Abstraction with ABC
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount): ...
@abstractmethod
def refund(self, amount): ...
class StripeGateway(PaymentGateway):
def charge(self, amount):
print(f"Charging ${amount} via Stripe")
def refund(self, amount):
print(f"Refunding ${amount} via Stripe")
# PaymentGateway() # TypeError — can't instantiate an abstract class
If StripeGateway forgot refund(), Python refuses to instantiate it. The contract is enforced, not just a convention.
Dunder methods: making your objects feel native
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def __str__(self):
return f"'{self.title}' ({self.pages} pages)"
def __eq__(self, other):
return self.title == other.title and self.pages == other.pages
def __lt__(self, other):
return self.pages < other.pages
books = [Book("Refactoring", 448), Book("Clean Code", 464)]
print(sorted(books)) # uses __lt__
My Take: Always implement __repr__. It costs one line and saves hours of debugging.
Dataclasses: less boilerplate for data-holding classes
from dataclasses import dataclass, field
@dataclass
class Order:
id: int
items: list = field(default_factory=list) # avoids the mutable-default trap
total: float = 0.0
__init__, __repr__, and __eq__ are generated for you. If your __init__ is just self.x = x five times over, stop and use @dataclass.
5. super() and Friends: One Section for All the "Which Method Runs?" Confusion
This is the part that trips people up most, so it gets its own section, all together.
super() in single inheritance
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
class Manager(Employee):
def __init__(self, name, salary, team_size):
super().__init__(name, salary) # let the parent init its own fields
self.team_size = team_size
super() calls the parent's implementation instead of re-typing it.
Multiple inheritance and MRO
Python allows inheriting from more than one parent — and resolves conflicts with the Method Resolution Order (MRO), a deterministic left-to-right order computed by the C3 linearization algorithm.
class Flyer:
def move(self):
return "Flying"
class Swimmer:
def move(self):
return "Swimming"
class Duck(Flyer, Swimmer):
pass
print(Duck().move()) # "Flying" — Flyer is listed first
print(Duck.__mro__)
# (Duck, Flyer, Swimmer, object)
super() in multiple inheritance follows the MRO, not just "the parent"
class Base:
def greet(self):
print("Base greet")
class A(Base):
def greet(self):
print("A greet")
super().greet()
class B(Base):
def greet(self):
print("B greet")
super().greet()
class C(A, B):
def greet(self):
print("C greet")
super().greet()
C().greet()
# C greet
# A greet
# B greet
# Base greet
super() doesn't mean "my direct parent" — it means "the next class in the MRO chain." That's what lets cooperative multiple inheritance actually work.
Mixins: multiple inheritance, scoped responsibly
class JSONExportMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)
class LoggingMixin:
def log(self, message):
print(f"[{self.__class__.__name__}] {message}")
class User(JSONExportMixin, LoggingMixin):
def __init__(self, name):
self.name = name
User("Alice").log("User created") # [User] User created
A mixin isn't meant to stand alone — it exists purely to bolt one focused capability onto other classes. Django's class-based views are built almost entirely on this idea.
My Take: Multiple inheritance is a sharp tool. Understand __mro__ before using it beyond simple mixins. If you're ever confused about which method will run, that's your signal to simplify — often composition is the safer call here too.
6. __slots__ and the Other Attribute Tricks Worth Knowing
By default, every instance carries a __dict__ to hold its attributes — flexible, but memory-heavy. __slots__ fixes the attribute set in advance.
class PointDict:
def __init__(self, x, y):
self.x = x
self.y = y
# has a __dict__ — flexible, more memory per instance
class PointSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
# no __dict__ — fixed attributes, less memory per instance
p = PointSlots(1, 2)
p.z = 3 # AttributeError: 'PointSlots' object has no attribute 'z'
Pros: less memory per instance, slightly faster attribute access, catches typo-attributes early.
Cons: no dynamic attributes, and multiple inheritance with slots needs care.
My Take: __slots__ is a micro-optimization — genuinely useful when you're instantiating millions of objects (parsers, game entities, data records), pointless overhead everywhere else. Don't add it by default.
Other attribute-related tools worth having in your pocket
-
@property— expose a computed value as if it were plain data:
class Rectangle:
def __init__(self, width, height):
self.width, self.height = width, height
@property
def area(self):
return self.width * self.height # computed on access, not stored
If a value can be derived from other attributes, don't store it and risk it going stale.
-
__init_subclass__— a lighter alternative to a metaclass when you just want to hook into subclass creation:
class Plugin:
registry = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
Plugin.registry.append(cls)
-
Name mangling (
__attr) — mostly for avoiding naming collisions in subclasses, not real security.self.__xinside a class becomesself._ClassName__x.
Comparison Table: Choosing the Right Tool
| Concept | Use It When... |
|---|---|
| Plain class | You need bundled data + behavior, nothing fancy |
@dataclass |
The class is mostly data with little to no custom logic |
| Inheritance | A true "is-a" relationship exists with shared behavior |
| Composition | A "has-a" relationship exists, or you need swappable parts |
@property |
You need validation or a computed value that reads like data |
ABC / abstract methods |
Multiple implementations must follow the same contract |
| Mixin | You need one reusable, focused capability across unrelated classes |
__slots__ |
You're creating a huge number of instances and memory matters |
| Metaclass | You're building a framework-level tool (rare in application code) |
Key Takeaways
- The four pillars — encapsulation, inheritance, polymorphism, abstraction — each solves a different problem, not just theory to quote
-
Classes are objects too — every class is an instance of
type(or a custom metaclass) - Instance vs. class attributes — know which one you're actually modifying
- Composition over inheritance — the most valuable lesson in this whole guide
-
super()follows the MRO, not just "the direct parent" — check__mro__when in doubt -
__slots__is a memory optimization, not a default - Metaclasses — know they exist, rarely write your own
The Golden Rule of OOP in Python:
Model relationships honestly. "Is-a" gets inheritance. "Has-a" gets composition. Everything else is just a function that doesn't need a class at all.
Python doesn't force you into heavy OOP the way Java does. Use classes when they earn their complexity — not because "object-oriented" sounds more professional than a plain function.
Design Patterns & Architecture Series
- Creational Patterns — How to create objects properly
- Structural Patterns - Part 1 — Adapter, Bridge, Composite, Decorator
- Structural Patterns - Part 2 — Facade, Flyweight, Proxy
- Behavioral Patterns — How objects communicate
- OOP in Python (This Article) — The foundations everything else builds on
Want More Deep Dives?
If you enjoyed this article, check out my other production-focused guides:
- Message Brokers in 2026: Kafka, RabbitMQ, NATS — Architecture decisions
- OWASP Top 10 for Developers (2026 Edition) — Security patterns
- Injection Attacks Are Not Dead — Attack patterns and solutions
- Durable Workflow Engines: Temporal vs dbt OS — System patterns
🔗 LinkedIn: https://www.linkedin.com/in/mahdi-shamlou-3b52b8278
📱 Telegram: https://telegram.me/mahdi0shamlou
📸 Instagram: https://www.instagram.com/mahdi0shamlou/
Author: Mahdi Shamlou | مهدی شاملو



Top comments (0)