The Story That Explains Classes and Objects
The cutter is the class. It defines the shape. Each individual cookie is an object. You define the class once, then create as many objects as you need — each with its own data.
A class is a blueprint. An object is a real instance built from that blueprint. The blueprint costs nothing to keep — objects hold the actual data.
Class vs Object — The Fundamental Difference
Defined once using class |
| Describes the shape of a thing |
| Does not hold real data of its own |
| Like a recipe or a form template |
| Created by calling the class |
| Holds actual values in its own memory |
| Each object is independent of the others |
| Like a cooked meal or filled-in form |
Your First Class — Absolute Minimum
# Define the blueprint — as empty as it gets
class Dog:
pass # empty body — no data, no methods, no __init__
# Create objects (instances) from it
d1 = Dog() # object #1 — a bare, empty Dog
d2 = Dog() # object #2 — another bare, empty Dog
print(d1) # <__main__.Dog object at 0x7f8b12c1a410>
print(d2) # <__main__.Dog object at 0x7f8b12c1a4d0>
print(d1 is d2) # False — two distinct objects
So we can already make objects. But right now they're empty — no name, no breed, no age. How do we give them data?
Adding Data From Outside — The Manual Way
In Python, any object lets you attach attributes to it from outside the class. You just
reach in with dot notation and assign a value: d1.name = "Buddy".
These are instance variables — but created in the most manual way possible.
class Dog:
pass # still just an empty blueprint
# ── Step 1: create three bare, empty objects ──────────────
d1 = Dog()
d2 = Dog()
d3 = Dog()
# ── Step 2: attach every attribute manually, from outside ─
d1.name = "Buddy"
d1.breed = "Labrador"
d1.age = 3
d2.name = "Milo"
d2.breed = "Beagle"
d2.age = 5
d3.name = "Luna"
d3.breed = "Poodle"
d3.age = 2
print(d1.name, d1.breed, d1.age) # Buddy Labrador 3
print(d2.name, d2.breed, d2.age) # Milo Beagle 5
print(d3.name, d3.breed, d3.age) # Luna Poodle 2
Animated Diagram — Manual Assignment Fills Empty Objects One-By-One
Watch the setup happen. Three bare Dog objects sit there empty. Then one line
at a time from outside the class, each attribute gets pushed into each object. It works — but
count the lines flowing in.
Every attribute is assigned manually from outside the class — nothing is automated yet.
Why This Manual Way Is Painful
The code above works. Every object ends up with the right data. But this style breaks down the moment your program has more than a handful of objects. Let's list exactly why.
d2.age = 5 and Python won't warn you. Later, code that reads
d2.age crashes with AttributeError, hundreds of lines
away from the real bug.
Dog to have the same attributes. One might get
weight, another might not. Your "Dog" class becomes a collection of
subtly different-shaped objects.
d1.nmae = "Buddy" and Python happily creates a new attribute
called nmae. Later d1.name raises AttributeError
even though "you set it."
d1.age = -50 or d1.age = "hello". Every caller
must remember to check — and they won't.
Setup is outside the class, so the class itself has no idea what data
a real Dog should carry. We need to move that setup inside the class
so it runs automatically every time an object is born. That's what
__init__ is for.
The Fix — __init__ Runs Setup Automatically
__init__ (pronounced "dunder init") is a special method Python calls
automatically the instant you create an object. You write the setup logic
once inside the class, and Python runs it for every new object. No more repetition,
no forgotten attributes, no scattered code.
class Dog:
def __init__(self, name, breed, age):
# Runs automatically every time you write Dog(...)
self.name = name
self.breed = breed
self.age = age
# Object creation is ONE LINE each — no manual attribute wiring
d1 = Dog("Buddy", "Labrador", 3)
d2 = Dog("Milo", "Beagle", 5)
d3 = Dog("Luna", "Poodle", 2)
print(d1.name, d1.breed, d1.age) # Buddy Labrador 3
print(d2.name, d2.breed, d2.age) # Milo Beagle 5
print(d3.name, d3.breed, d3.age) # Luna Poodle 2
Before & After — The Same Job
| 3 dogs → 9 setup lines |
| 100 dogs → 300 setup lines |
| Might forget an attribute — silent bug |
| Typos create ghost attributes |
| Shape of "Dog" defined nowhere |
| No place to validate incoming values |
| 3 dogs → 3 setup lines |
| 100 dogs → 100 setup lines |
Forgetting an argument → immediate TypeError |
| Attribute names live in one place — no typo drift |
Shape of "Dog" documented in __init__ |
Validation lives inside __init__ |
__init__
It eliminates repetition, guarantees every object has the same shape,
keeps the class's data definition in one central place, and gives you
one place to validate incoming values. Every real Python class you'll ever
write starts with __init__ for exactly these reasons.
Animated Diagram — __init__ Fills the Empty Object
Watch what happens on Student("Alice", 20): an empty object is created, then the
arguments fly into their self. slots automatically. No line of code from
outside the class is needed to wire them up.
Arguments fly into their self. slots — the object emerges fully populated with zero manual assignment.
Anatomy of __init__
Student("Alice", 20)Student.
Student.__init__(new_object, "Alice", 20).
__init__, self refers to that new object. Assignments like self.name = "Alice" attach data to it.
class Student:
def __init__(self, name, age, course):
# ^^^^ ^^^^^^^^^^^^^^^^^^ ← self first, then parameters
self.name = name # attach name to this object
self.age = age # attach age to this object
self.course = course # attach course to this object
s1 = Student("Alice", 20, "Physics")
s2 = Student("Bob", 22, "Chemistry")
s3 = Student("Chandra", 19, "Math")
print(s1.name, s1.age, s1.course) # Alice 20 Physics
__init__ Directly
Do not write Student.__init__("Alice", 20, "Physics"). Python calls
__init__ for you when you write Student("Alice", 20, "Physics").
Think of __init__ as internal wiring — the class name is the front door.
What Is self?
Every method inside a class takes self as its first parameter. It's just a name
for "the specific object this method is being called on." When you write
s1.greet(), Python transparently rewrites it as Student.greet(s1) and
slots s1 in as self.
self Is Just the Object Itself
self.name = "Alice" means "on this particular object, create an attribute
called name and set it to Alice." Later, self.name anywhere
in the class reaches into that same object's memory and reads it back.
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def bark(self):
# self is whichever dog we called .bark() on
print(f"{self.name} says: Woof!")
buddy = Dog("Buddy", "Labrador")
milo = Dog("Milo", "Beagle")
buddy.bark() # same as Dog.bark(buddy) → "Buddy says: Woof!"
milo.bark() # same as Dog.bark(milo) → "Milo says: Woof!"
Instance Variables — Where Each Object's Data Lives
An instance variable is a piece of data attached to one specific object.
It's created the moment you write self.something = value inside a method
(usually __init__). Every object gets its own private copy.
s1.name = "Alice" lives on s1 only. Changing one
never affects the other.
__init__. That guarantees
every fresh object has a known shape, visible at a glance.
obj.attribute. Inside a method use
self.attribute. Both are the same lookup into the object's memory.
Animated Diagram — Independent Memory Per Object
Three BankAccount objects sit side by side. Watch a deposit flow into the
middle account — its balance ticks up while the other two stay
completely untouched. That's what "each object has its own memory" really means.
Only Bob's balance moves. Alice and Chandra are in different memory — their state is safe.
Animated Diagram — Inside vs Outside the Class
The same instance variable is reached two different ways depending on where you are.
Inside a method you say self.balance. Outside the
class you say acc.balance. Both arrows land on the same memory box.
Inside a method you write self.balance; outside you write acc.balance — both address the same slot.
class BankAccount:
def __init__(self, holder, balance):
self.holder = holder
self.balance = balance
def deposit(self, amount):
# INSIDE the class → use self.
self.balance += amount
acc = BankAccount("Alice", 1000)
# OUTSIDE the class → use the object name
print(acc.balance) # 1000 (read from outside)
acc.deposit(200) # calls the method → uses self inside
print(acc.balance) # 1200
Instance Variables vs Local Variables
Inside a method, self.balance and balance are
completely different. One survives after the method ends; the other is
thrown away instantly.
balance = 100 |
| Lives only inside this method call |
| Not attached to the object |
| Vanishes the moment the method returns |
self.balance = 100 |
| Survives across every method call |
| Attached to this specific object |
| Lives as long as the object lives |
class Counter:
def __init__(self):
self.count = 0 # instance variable — persists
def bad_increment(self):
count = self.count + 1 # local variable — gone at return
# self.count is NEVER updated → bug!
def good_increment(self):
self.count += 1 # updates the object's memory
c = Counter()
c.bad_increment()
c.bad_increment()
print(c.count) # 0 ← nothing changed, silent bug
c.good_increment()
c.good_increment()
print(c.count) # 2 ← works correctly
Practical Example 1 — A Rectangle
class Rectangle:
def __init__(self, width, height):
self.width = width # instance variable
self.height = height # instance variable
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
def describe(self):
print(f"Rectangle {self.width}x{self.height}: "
f"area={self.area()}, perimeter={self.perimeter()}")
r1 = Rectangle(4, 5)
r2 = Rectangle(10, 3)
r1.describe() # uses r1's width/height
r2.describe() # uses r2's width/height
r1.width = 100
r1.describe() # r1 changed
r2.describe() # r2 identical to before
Practical Example 2 — A Student Record System
class Student:
def __init__(self, roll_no, name, marks):
self.roll_no = roll_no
self.name = name
self.marks = marks
def average(self):
return sum(self.marks) / len(self.marks)
def grade(self):
avg = self.average()
if avg >= 90: return "A"
if avg >= 75: return "B"
if avg >= 60: return "C"
return "F"
def report(self):
print(f"{self.roll_no:3d} {self.name:15s} "
f"Avg: {self.average():5.1f} Grade: {self.grade()}")
classroom = [
Student(1, "Alice", [92, 88, 95, 90]),
Student(2, "Bob", [70, 75, 80, 72]),
Student(3, "Chandra", [55, 62, 58, 65]),
Student(4, "Deepa", [40, 45, 50, 42]),
]
for student in classroom:
student.report()
Four completely separate Student objects each carried their own marks list,
each computed their own average, each printed their own report — all sharing the same class.
With __init__, creating 4 students took 4 lines. Without it, you'd have written
16 manual assignments.
Practical Example 3 — A Bank Account with Behaviour
class BankAccount:
def __init__(self, holder, opening_balance=0):
if opening_balance < 0:
raise ValueError("Opening balance cannot be negative")
self.holder = holder
self.balance = opening_balance
self.history = [] # each account has its own history
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
self.history.append(("deposit", amount))
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
self.history.append(("withdraw", amount))
def statement(self):
print(f"\n--- {self.holder} ---")
for action, amount in self.history:
print(f" {action:9s} ${amount}")
print(f" Current balance: ${self.balance}")
alice = BankAccount("Alice", 1000)
bob = BankAccount("Bob")
alice.deposit(500)
alice.withdraw(200)
bob.deposit(100)
bob.deposit(300)
bob.withdraw(50)
alice.statement()
bob.statement()
Notice how __init__ can reject a negative opening balance, and
deposit/withdraw can enforce their own rules. Manual assignment
from outside gives you none of this — anyone could write
alice.balance = -1_000_000. Bundling data and rules together is called
encapsulation, and it's the whole reason we prefer __init__.
Common Mistakes (and Fixes)
def __init__(name): instead of def __init__(self, name):. Python needs the object as the first parameter — always.balance = 100 inside a method instead of self.balance = 100. The data is silently discarded.def __init__(self, tags=[]). All objects share the same list. Use tags=None and build a fresh list inside.None or []. Readers instantly see what data the class holds.raise ValueError in __init__. Fail loudly at creation, not deep in your logic.def __repr__(self) so print(obj) shows something useful instead of <Object at 0x...>.class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def __repr__(self):
return f"Student(name={self.name!r}, age={self.age})"
s = Student("Alice", 20)
print(s) # Student(name='Alice', age=20) ← much better than a memory address
Quick Reference Table
| Concept | Syntax | What It Does |
|---|---|---|
| Define a class | class Student: | Registers the blueprint |
| Create an object | s = Student("Alice", 20) | Builds a new instance, runs __init__ |
| Constructor | def __init__(self, ...): | Runs automatically at creation time |
| Instance variable (inside) | self.x = value | Attach data to this object |
| Instance variable (outside) | obj.x = value | Works — but scales badly, no validation |
| Local variable | x = value | Discarded when the method returns |
| Read from outside | obj.attribute | Fetch value from the object's memory |
| Read from inside | self.attribute | Same lookup, from within a method |
| Inspect state | obj.__dict__ | Shows all instance variables as a dict |
| Check type | isinstance(obj, Class) | Preferred over comparing type(obj) |
Golden Rules
obj.x = value). But don't — it's repetitive, error-prone, unvalidated,
and gives every object a different shape. Use __init__.
BankAccount), variables and
methods in snake_case (opening_balance). Following PEP 8
makes your code instantly readable.
__init__ runs automatically when you create an
object. Never call Student.__init__(...) directly — use
Student(...) and Python does it for you.
self — the object the
method was called on. It's not magic; Python passes the object in for you.
self. creates an
instance variable on that specific object. Without self.,
it's a local variable that dies when the method returns.
obj1.balance never affects obj2.balance — different objects,
different memory.
__init__, even ones you'll
set later (initialise with None or []). This documents the object's
shape in one place.
tags=[]) in __init__.
Use tags=None and build a fresh list inside. Otherwise every object shares the
same list — a classic Python trap.
__init__. Raise ValueError or
TypeError on bad data at construction, not five methods later.
Fail fast, fail loud.