☰ Learn Python Tutorial Menu
Classes and Objects (OOP)
Written by CSA mentors · Updated 26 Sept 2026 · 5 min read
A microfinance bank in Karachi had a loan calculator that was 700 lines of functions, and every function took the same eleven arguments in the same order. Principal, rate, tenure, start date, and so on, over and over. Nobody dared reorder them. That's the moment a script is asking to become a class. So far you've kept data in dicts and behaviour in functions and passed one to the other, which works until the same dict shape and the same five functions start travelling together everywhere. A class bundles them into one unit. You already use classes all day, since str, list, datetime and a pandas DataFrame are all classes. Now you'll write your own.
Class and object
A class is a blueprint. An object (or instance) is one concrete thing built from it. BankAccount is the idea of an account. acct1 is Sana's account with Rs 15,000 in it.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("insufficient funds")
self.balance -= amount
acct = BankAccount("Sana", 15000)
acct.deposit(5000)
acct.withdraw(2000)
print(acct.owner, acct.balance) # Sana 18000
Walking through it, top to bottom.
class BankAccount:starts the definition. Class names use CapWords.__init__is the constructor. It runs automatically when you writeBankAccount("Sana", 15000)and sets up the object's data.selfis the object being worked on. Every method receives it as the first parameter, and you reach the object's data asself.something. You never passselfyourself. Python fills it in when you callacct.deposit(5000).self.ownerandself.balanceare attributes, variables attached to this particular object.depositandwithdraware methods, functions that belong to the class.
Each object has its own data
a = BankAccount("Hamza", 500)
b = BankAccount("Zara", 82000)
a.deposit(100)
print(a.balance, b.balance) # 600 82000 (b is untouched)
That's the whole point. The methods are shared, the data is per object. With a dict-plus-functions approach nothing stops someone calling withdraw on the wrong dict or setting the balance to a string.
Printing objects nicely
By default print(acct) shows something unhelpful like <__main__.BankAccount object at 0x7f...>. Define __str__ to control it.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def __str__(self):
return f"{self.owner}: Rs {self.balance:,}"
print(BankAccount("Sana", 15000)) # Sana: Rs 15,000
Methods with double underscores are "dunder" methods, and Python calls them for you in specific situations. Lesson 19 covers more of them.
Class attributes and methods that compute
class Invoice:
GST_RATE = 0.17 # shared by all invoices (class attribute)
def __init__(self, number, customer):
self.number = number
self.customer = customer
self.lines = [] # per-invoice list
def add_line(self, description, qty, unit_price):
self.lines.append((description, qty, unit_price))
def subtotal(self):
return sum(qty * price for _, qty, price in self.lines)
def total(self):
return round(self.subtotal() * (1 + Invoice.GST_RATE), 2)
inv = Invoice("INV-2041", "Sana Traders")
inv.add_line("Notebook", 30, 250)
inv.add_line("Pen", 100, 40)
print(inv.subtotal(), inv.total()) # 11500 13455.0
Notice that self.lines = [] is created inside __init__, so each invoice gets its own list. Put it at class level instead and every invoice shares one list, which is the mutable-default trap from Lesson 11 wearing a different hat. We see this bug in student projects every single term.
Properties, or computed attributes
class Employee:
def __init__(self, first, last, monthly):
self.first = first
self.last = last
self.monthly = monthly
@property
def full_name(self):
return f"{self.first} {self.last}"
@property
def annual(self):
return self.monthly * 12
e = Employee("Hira", "Baig", 145000)
print(e.full_name, e.annual) # Hira Baig 1740000 (no parentheses)
@property lets a method be read like an attribute. Use it for values derived from other attributes, so they can never go stale.
Dataclasses, less boilerplate
For classes that are mostly data, the standard library's dataclass writes __init__, a readable __repr__ and equality for you.
from dataclasses import dataclass, field
@dataclass
class Product:
sku: str
name: str
price: float
stock: int = 0
tags: list = field(default_factory=list)
def value_on_hand(self):
return self.price * self.stock
p = Product("NB-01", "Notebook", 250.0, 40)
print(p) # Product(sku='NB-01', name='Notebook', price=250.0, stock=40, tags=[])
print(p.value_on_hand()) # 10000.0
print(p == Product("NB-01", "Notebook", 250.0, 40)) # True
Dataclasses are the modern way to model records. Reach for them before writing __init__ by hand. In our own projects, most classes that hold a row of data are dataclasses now.
When to use a class
| Use a class when | Stick with functions and dicts when |
|---|---|
| Data and the operations on it always travel together | The script is a short one-off |
| You need many instances with the same behaviour | There is one blob of config |
| Invalid states must be prevented (negative balance) | Data is read-only pass-through |
| You are building a reusable library | You are exploring in a notebook |
What the loan calculator became: A
Loanclass with attributes for principal, rate and tenure, plus methodsinstallment(),schedule()andoutstanding_on(date). The 700 lines came down to about 180. New staff readloan.schedule()and understand it at once, and a check in__init__stops anyone creating a loan with a zero tenure, which used to produce a division error three functions later.
Before you move on
- A class bundles attributes (data) and methods (behaviour). Objects are instances of it.
__init__sets up each object.selfrefers to the object a method is working on.- Define
__str__for readable printing and use@propertyfor derived values. - Create per-object lists inside
__init__, never as class attributes. @dataclassgenerates the boilerplate for record-like classes.
Build these
- Write a
Studentclass with name, roll number and a list of marks, plus methodsadd_mark(),average()and a__str__showing name and average. - Turn the
Invoiceexample into a dataclass with alinesfield usingdefault_factory, keeping thetotal()method. - Write a
MobileWalletclass wheresend(amount)raisesValueErrorif the amount exceeds the balance or a daily limit of Rs 50,000, and track the total sent today.
Lesson 18 of 26
Sign in to track your progress and earn learning points for every lesson you finish.
