Intermediate Python 📂 Class and Object · 1 of 10 50 min read

Python OOP Basics — Classes, Objects, init and Instance Variables

Understand the foundation of Python OOP: what a class really is, how objects are born, why init runs automatically, and how instance variables give each object its own private memory. Learn through a cookie-cutter analogy, step-by-step diagrams, and three practical builds — Rectangle, Student report card, and BankAccount with history. Ends with the 10 golden rules every Python developer follows when writing classes.

Section 01

The Story That Explains Classes and Objects

The Cookie Cutter and the Cookies
Imagine a bakery. On the counter sits a single cookie cutter shaped like a star. From that one cutter you press out cookie after cookie. Every cookie has the same shape — but each one is a separate cookie: this one has extra sprinkles, that one has chocolate chips, the third one is slightly overbaked.

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.
🏃️
The Core Insight

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.


Section 02

Class vs Object — The Fundamental Difference

📄 Class (Blueprint)
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
🍪 Object (Instance)
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?


Section 03

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
OUTPUT
Buddy Labrador 3 Milo Beagle 5 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.

MANUAL ASSIGNMENT  ·  6 LINES FOR 3 DOGS
d1 = Dog() name: 'Buddy' breed: 'Labrador' d2 = Dog() name: 'Milo' breed: 'Beagle' d3 = Dog() name: 'Luna' breed: 'Poodle' CODE FROM OUTSIDE THE CLASS d1.name = 'Buddy' d1.breed = 'Labrador' d2.name = 'Milo' d2.breed = 'Beagle' d3.name = 'Luna' d3.breed = 'Poodle' 6 lines · 3 dogs · imagine 100 dogs...

Every attribute is assigned manually from outside the class — nothing is automated yet.


Section 04

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.

📜
Repetition Explodes
3 dogs × 3 attributes = 9 lines of setup. 100 dogs = 300 lines. 1,000 users = 3,000 lines of the same pattern. You'd write nothing but assignments.
boilerplate
👀
Easy to Forget an Attribute
Miss 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.
silent inconsistency
🛑
Objects End Up With Different Shapes
Nothing forces every Dog to have the same attributes. One might get weight, another might not. Your "Dog" class becomes a collection of subtly different-shaped objects.
no guaranteed shape
🗨️
Typos Create Ghost Attributes
Write d1.nmae = "Buddy" and Python happily creates a new attribute called nmae. Later d1.name raises AttributeError even though "you set it."
typo hell
🔌
Setup Logic Is Scattered
There's no single place that says "a Dog has name, breed, age." That knowledge lives wherever someone remembered to assign it. Changing the shape means hunting through the whole codebase.
no central definition
🚫
No Validation
Nothing stops d1.age = -50 or d1.age = "hello". Every caller must remember to check — and they won't.
bad data slips in
⚠️
The Root Cause

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.


Section 05

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
OUTPUT
Buddy Labrador 3 Milo Beagle 5 Luna Poodle 2

Before & After — The Same Job

❌ Manual (Outside the Class)
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
✅ With __init__ (Inside the Class)
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__
🏆
Why Bother With __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.

CALL  →  EMPTY OBJECT  →  DATA FLOWS IN  →  FILLED OBJECT
s = Student("Alice", 20) STUDENT OBJECT (self) self.name = 'Alice' self.age = 20 'Alice' 20

Arguments fly into their self. slots — the object emerges fully populated with zero manual assignment.


Section 06

Anatomy of __init__

🎧 What Really Happens When You Write Student("Alice", 20)
Step 1
Python creates a brand new empty object of type Student.
Step 2
Python immediately calls Student.__init__(new_object, "Alice", 20).
Step 3
Inside __init__, self refers to that new object. Assignments like self.name = "Alice" attach data to it.
Step 4
The fully filled-in object is returned — that's what your variable now points to.
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
⚠️
Never Call __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.


Section 07

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!"
OUTPUT
Buddy says: Woof! Milo says: Woof!

Section 08

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.

👤
Independent per Object
self.attribute = value
s1.name = "Alice" lives on s1 only. Changing one never affects the other.
👤
Created at Init Time
inside __init__
Best practice: declare every instance variable in __init__. That guarantees every fresh object has a known shape, visible at a glance.
👤
Accessed via Dot
object.attribute
Outside a class use 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.

INDEPENDENT MEMORY  ·  ONE DEPOSIT AFFECTS ONE OBJECT ONLY
ACCOUNT #1 (alice) holder = 'Alice' balance $1000 unchanged ACCOUNT #2 (bob) holder = 'Bob' balance $500 $700 + $200 deposit ACCOUNT #3 (chandra) holder = 'Chandra' balance $2500 unchanged +$200 bob.deposit(200)

Only Bob's balance moves. Alice and Chandra are in different memory — their state is safe.


Section 09

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.

SAME MEMORY  ·  TWO ACCESS PATHS
INSIDE THE CLASS (method body) def deposit(self, x): self.balance += x # "self" is the object OBJECT MEMORY acc = BankAccount(...) balance $1000 OUTSIDE THE CLASS (user code) print( acc.balance ) acc.balance += 50 # "acc" is the object self. acc. Both paths reach the exact same memory cell. self.balance from inside  ·  acc.balance from outside — one variable, two prefixes.

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

Section 10

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.

❌ Local Variable — Dies at End of Method
balance = 100
Lives only inside this method call
Not attached to the object
Vanishes the moment the method returns
✅ Instance Variable — Lives on the Object
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
OUTPUT
0 2

Section 11

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
OUTPUT
Rectangle 4x5: area=20, perimeter=18 Rectangle 10x3: area=30, perimeter=26 Rectangle 100x5: area=500, perimeter=210 Rectangle 10x3: area=30, perimeter=26

Section 12

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()
OUTPUT
1 Alice Avg: 91.2 Grade: A 2 Bob Avg: 74.2 Grade: C 3 Chandra Avg: 60.0 Grade: C 4 Deepa Avg: 44.2 Grade: F
🏆
Notice What Just Happened

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.


Section 13

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()
OUTPUT
--- Alice --- deposit $500 withdraw $200 Current balance: $1300 --- Bob --- deposit $100 deposit $300 withdraw $50 Current balance: $350
🔑
Now the Validation Lives With the Data

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__.


Section 14

Common Mistakes (and Fixes)

Forgetting self
Writing def __init__(name): instead of def __init__(self, name):. Python needs the object as the first parameter — always.
TypeError on creation
Missing self. Prefix
Writing balance = 100 inside a method instead of self.balance = 100. The data is silently discarded.
silent bug
Mutable Default in __init__
Never write def __init__(self, tags=[]). All objects share the same list. Use tags=None and build a fresh list inside.
shared list bug
Declare All Attributes in __init__
Even ones you'll fill in later — set them to None or []. Readers instantly see what data the class holds.
predictable shape
Validate Inside __init__
If a value can be invalid, raise ValueError in __init__. Fail loudly at creation, not deep in your logic.
fail fast
Add __repr__ Early
Implement def __repr__(self) so print(obj) shows something useful instead of <Object at 0x...>.
debugger friendly
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

Section 15

Quick Reference Table

ConceptSyntaxWhat It Does
Define a classclass Student:Registers the blueprint
Create an objects = Student("Alice", 20)Builds a new instance, runs __init__
Constructordef __init__(self, ...):Runs automatically at creation time
Instance variable (inside)self.x = valueAttach data to this object
Instance variable (outside)obj.x = valueWorks — but scales badly, no validation
Local variablex = valueDiscarded when the method returns
Read from outsideobj.attributeFetch value from the object's memory
Read from insideself.attributeSame lookup, from within a method
Inspect stateobj.__dict__Shows all instance variables as a dict
Check typeisinstance(obj, Class)Preferred over comparing type(obj)

Section 16

Golden Rules

🏆 Classes, __init__ & Instance Variables — Non-Negotiable Rules
1
Yes, you can attach instance variables from outside the class (obj.x = value). But don't — it's repetitive, error-prone, unvalidated, and gives every object a different shape. Use __init__.
2
A class is a blueprint; an object is one real thing built from it. You define the class once; you can create as many objects as you like.
3
Name classes in PascalCase (BankAccount), variables and methods in snake_case (opening_balance). Following PEP 8 makes your code instantly readable.
4
__init__ runs automatically when you create an object. Never call Student.__init__(...) directly — use Student(...) and Python does it for you.
5
Every method's first parameter is self — the object the method was called on. It's not magic; Python passes the object in for you.
6
Any assignment starting with self. creates an instance variable on that specific object. Without self., it's a local variable that dies when the method returns.
7
Every object has its own copy of its instance variables. Changing obj1.balance never affects obj2.balance — different objects, different memory.
8
Declare every instance variable in __init__, even ones you'll set later (initialise with None or []). This documents the object's shape in one place.
9
Never use a mutable default (tags=[]) in __init__. Use tags=None and build a fresh list inside. Otherwise every object shares the same list — a classic Python trap.
10
Validate inputs inside __init__. Raise ValueError or TypeError on bad data at construction, not five methods later. Fail fast, fail loud.