☰ 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 BankAccount class blueprint on the left producing three separate account objects on the right

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 write BankAccount("Sana", 15000) and sets up the object's data.
  • self is the object being worked on. Every method receives it as the first parameter, and you reach the object's data as self.something. You never pass self yourself. Python fills it in when you call acct.deposit(5000).
  • self.owner and self.balance are attributes, variables attached to this particular object.
  • deposit and withdraw are 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 whenStick with functions and dicts when
Data and the operations on it always travel togetherThe script is a short one-off
You need many instances with the same behaviourThere is one blob of config
Invalid states must be prevented (negative balance)Data is read-only pass-through
You are building a reusable libraryYou are exploring in a notebook

What the loan calculator became: A Loan class with attributes for principal, rate and tenure, plus methods installment(), schedule() and outstanding_on(date). The 700 lines came down to about 180. New staff read loan.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. self refers to the object a method is working on.
  • Define __str__ for readable printing and use @property for derived values.
  • Create per-object lists inside __init__, never as class attributes.
  • @dataclass generates the boilerplate for record-like classes.

Build these

  1. Write a Student class with name, roll number and a list of marks, plus methods add_mark(), average() and a __str__ showing name and average.
  2. Turn the Invoice example into a dataclass with a lines field using default_factory, keeping the total() method.
  3. Write a MobileWallet class where send(amount) raises ValueError if 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.

Example

print("Hello from CSA!")

for i in range(5):
    print(i * i)
Try it Yourself »