Object-oriented Python: from loose dicts to classes that earn their keep
Learn how OOP actually works in Python by building a small food-delivery order system — classes, self, inheritance, dunder methods, and composition. Three simulators let you watch objects get built, method calls get dispatched, and operators turn into dunder calls.
mindmap — quick refresh
Prerequisites: comfort with Python functions, lists, and dicts — no OOP background needed. This guide stands on its own.
Three facts make object-oriented Python easier than the theory suggests. First, everything in Python is already an object — 3, "hi", lists, functions, even classes themselves. Second, the "magic" syntax is a thin disguise: len(x) literally calls x.__len__(), and o.total() literally calls Order.total(o). Third, a class is just two things glued together: a dict of data and the functions allowed to touch it. Writing a class isn't switching paradigms — it's joining how the language already works.
We'll build the core of a small food-delivery app — orders, menu items, payments — because it's a domain where the objects are obvious: real systems like this are why OOP exists.
Why classes at all
Start with what goes wrong without them. Here's the no-class version of an order:
order = {"customer": "Asha", "items": []}
def add_item(order, name, price):
order["items"].append((name, price))
def order_total(order):
return sum(price for _, price in order["items"])
This works — until the app grows. The data shape lives in one place, the functions that depend on that shape live somewhere else, and nothing ties them together. Type order["cutsomer"] and Python happily returns a KeyError at some later, unrelated line. Pass the wrong dict to order_total and nothing complains until production. Every new teammate has to discover, by reading, which functions belong to which dict shape.
A class fixes exactly this — it puts the data and its functions in the same box, with a name:
class Order:
def __init__(self, customer):
self.customer = customer
self.items = [] # list of (name, price) tuples
def add(self, name, price):
self.items.append((name, price))
def total(self):
return sum(price for _, price in self.items)
Same logic, but now Order is the shape. o.add("momo", 150) can't be called on the wrong thing by accident, autocomplete knows what an order can do, and there is one obvious place to add behavior.
Anatomy of an object
The mechanics are simpler than they look, and worth seeing once, literally:
- The
classstatement runs once and builds a namespace holding the functions (__init__,add,total). - Calling
Order("Asha")does two steps: Python allocates an empty object, then calls__init__(new_object, "Asha").selfis not a keyword — it's just the first parameter, and it is that new object. - Every
self.x = ...line lands in that object's own__dict__. Data lives per-instance; the methods exist once, on the class. o.add("momo", 150)triggers a lookup: isaddino.__dict__? No → check the class → found → call it withself=o.
Watch it happen — the class namespace on the left, each instance's __dict__ on the right:
That's the whole trick. Once you see that o1 and o2 share one copy of the methods but own separate __dict__s, most OOP confusion — "where does this attribute live?", "why did changing one object not affect the other?" — disappears.
The vocabulary: member variables, methods, and functions
OOP textbooks (and C++ or Java courses) use terms Python renames. If you're coming from either direction, this table is the translation layer:
| Textbook / C++ / Java term | Python term | Where it lives |
|---|---|---|
| member variable | attribute (instance variable) | in one object's __dict__ — self.customer |
| static / class member variable | class attribute | on the class itself — one copy shared by all instances |
| member function | method | on the class; self gets bound when you call it |
| constructor | __init__ | on the class; runs right after the object is allocated |
Two of these deserve a closer look, because the distinction does real work in Python.
Member variable vs class attribute. Everything assigned through self is a member variable — per-object data, in that object's __dict__. But you can also assign a variable directly in the class body, and then there's exactly one copy, shared:
class Order:
delivery_fee = 50 # class attribute — ONE copy, on the class
def __init__(self, customer):
self.customer = customer # member variables (instance attributes) —
self.items = [] # a fresh copy per object
The attribute lookup you just watched in the simulator explains how both work with the same dot syntax: o1.delivery_fee misses in o1.__dict__ and falls through to the class. Use class attributes for constants shared by every instance; use self.x for anything that varies per object. (Classic trap: a mutable class attribute like items = [] in the class body would be shared by every order — always create mutable state in __init__.)
Method vs function. In Python a method is not a different kind of thing — it's a plain function that lives in a class, plus one convenience:
def total(order): # a function — you pass the data in explicitly
return sum(p for _, p in order.items)
class Order:
def total(self): # the same function, as a method (member function)
return sum(p for _, p in self.items)
o = Order("Asha")
Order.total # ← just a function; you'd call Order.total(o)
o.total # ← a *bound method*: o is pre-filled as self
o.total() # ← so this equals Order.total(o)
So the practical differences are exactly three: a method is defined inside a class, it receives the instance as its first parameter (self), and when you access it through an object Python hands you a bound method with self already filled in. That's why o.total() takes no arguments even though total declares one. A function stands alone and must be given all its data; a method travels with the object that owns the data.
Inheritance: lookup with a fallback chain
Our app takes payments, and payments come in kinds — card, wallet, QR. They share an amount and a receipt, but each pays differently. That's the shape inheritance is for:
class Payment:
def __init__(self, amount):
self.amount = amount
def pay(self):
raise NotImplementedError # every subclass must provide this
def receipt(self):
return f"paid Rs {self.amount} via {type(self).__name__}"
class CardPayment(Payment):
def __init__(self, amount, last4):
super().__init__(amount) # run Payment's __init__ first
self.last4 = last4
def pay(self):
return f"charging card •••• {self.last4}"
class WalletPayment(Payment):
def pay(self):
return f"deducting Rs {self.amount} from wallet"
The checkout loop is where it pays off — one call site, no if payment_type == ... ladder:
for p in [CardPayment(850, "4242"), WalletPayment(120)]:
print(p.pay()) # same line — different code runs
print(p.receipt()) # not on the subclass; found on Payment
Two mechanisms are working here, and they're both just the attribute lookup from the last section, extended one hop:
- Inheritance = the lookup doesn't stop at the class.
p.receiptmisses onCardPayment, so Python climbs toPaymentand finds it there. The climb order is the MRO — method resolution order. - Polymorphism = the lookup starts at the object's own class.
p.pay()findsCardPayment.payfor a card andWalletPayment.payfor a wallet — the same line of code dispatches to different behavior.
Step through the dispatch — including what happens when a subclass forgets to implement pay():
One rule of thumb before you build deep hierarchies: inherit for is-a with shared contract (a CardPayment is a Payment), not for code reuse alone. Two levels is usually plenty; we'll see the alternative below.
Dunder methods: speaking native Python
Right now our Order is a second-class citizen. print(o) shows <__main__.Order object at 0x104f83b90>, len(o) is a TypeError, and == compares memory addresses. Python's fix is the dunder (double-underscore) methods — the hooks the language calls behind its own syntax:
class Order:
# ... __init__, add, total as before ...
def __repr__(self):
return f"Order({self.customer!r}, {len(self.items)} items)"
def __len__(self):
return len(self.items)
def __eq__(self, other):
return (isinstance(other, Order)
and self.customer == other.customer
and self.items == other.items)
Now the built-in syntax just works — because each piece of syntax is a method call in disguise:
The one to always implement is __repr__: it's what appears in tracebacks, logs, and the debugger, so ten minutes writing it repays itself on the first bad day. Beyond these three, the pattern generalizes: o[k] calls __getitem__, for x in o calls __iter__, with o: calls __enter__/__exit__, a + b calls __add__. You don't memorize the list — you look up the hook for the syntax you want your object to support.
Composition: has-a beats is-a
How do Order and Payment meet? The beginner trap is inheritance everywhere — class CardOrder(Order, CardPayment) — which welds the two hierarchies together and multiplies every future kind-of-order by every kind-of-payment. The real-world relationship isn't is-a, it's has-a: an order has a payment. So hold one, don't inherit one:
class Order:
def __init__(self, customer, payment):
self.customer = customer
self.items = []
self.payment = payment # any object with .pay() — injected
def checkout(self):
return self.payment.pay()
order = Order("Asha", CardPayment(850, "4242"))
order.checkout() # 'charging card •••• 4242'
This is composition, and it's the workhorse of real Python systems: build small classes that each do one thing, then wire them together by passing objects in. Swapping card for wallet is now a one-argument change; testing Order needs only a fake object with a pay() method. When in doubt between inheritance and composition, reach for composition — you can always add inheritance later, but un-inheriting is surgery.
When not to write a class
OOP earns its keep when data and behavior genuinely travel together. Two common cases where a class is the wrong tool:
- Behavior with no state. If it's two functions that share nothing between calls, a module with plain functions is the honest design. A class with only
@staticmethods is a folder pretending to be an object. - State with no behavior. If it's pure data — a menu item has a name and a price, and that's it — write a
dataclassand let Python generate__init__,__repr__, and__eq__for you:
from dataclasses import dataclass
@dataclass
class MenuItem:
name: str
price: int
MenuItem("momo", 150) # MenuItem(name='momo', price=150) — repr & == for free
@dataclass isn't a different kind of class — it's the same machinery from this guide with the boilerplate auto-written. Which is the right closing note: none of what you've seen is a separate "OOP layer" bolted onto Python. It's attribute lookup, __dict__s, and dunder hooks — the language you were already using.
Takeaways
- A class glues data to the functions that own it — reach for one when dicts and loose functions start drifting apart, not because "OOP is proper".
selfis just the object, passed explicitly —o.total()isOrder.total(o); everyself.x = ...lands in that one object's__dict__, while methods live once on the class.- Inheritance is attribute lookup with a fallback chain — the search runs instance → class → bases; polymorphism is that search starting at the object's own class.
- Operators and built-ins are dunder calls in disguise — implement
__repr__always, then add the hook (__len__,__eq__,__iter__, …) for whatever syntax your object should support. - Prefer composition (has-a) over inheritance (is-a) for wiring parts together — inject the payment into the order; don't weld hierarchies with multiple inheritance.
- Skip the class when it earns nothing — stateless behavior belongs in module functions, and pure data belongs in a
@dataclass.
References
- Python Software Foundation. (n.d.). Classes (The Python tutorial, chapter 9). Retrieved August 16, 2026, from https://docs.python.org/3/tutorial/classes.html
- Python Software Foundation. (n.d.). Data model (The Python language reference). Retrieved August 16, 2026, from https://docs.python.org/3/reference/datamodel.html
- Python Software Foundation. (n.d.). dataclasses — Data Classes (Python documentation). Retrieved August 16, 2026, from https://docs.python.org/3/library/dataclasses.html
- Ramalho, L. (2022). Fluent Python: Clear, concise, and effective programming (2nd ed.). O'Reilly Media.