AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Object-Oriented Programming: Classes and Objects

📚 Python Mastery⏱️ 23 min read🎓 Grade 8
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 23 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

The Problem: Your Variables Are Multiplying Out of Control

Suppose your school asks you to write a small Python program that stores marks for three students and prints a report card for each one. Before you know anything about classes, you would probably write something like this.

student1_name = "Aarav"
student1_roll = 101
student1_marks = 88

student2_name = "Diya"
student2_roll = 102
student2_marks = 95

student3_name = "Kabir"
student3_roll = 103
student3_marks = 58

print(student1_name, student1_roll, student1_marks)
print(student2_name, student2_roll, student2_marks)
print(student3_name, student3_roll, student3_marks)

This works for three students. Now imagine your class has 40 students, and each one has five pieces of data instead of three: name, roll number, marks, attendance percentage, and blood group. You would need 200 separate variables, all with names you have to keep straight by hand, and a bug like typing student2_marks where you meant student3_marks would be almost impossible to spot just by reading the code. There is no way for Python to tell that student1_name, student1_roll, and student1_marks belong together as one student's data — to Python they are just three independent variables that happen to share a naming pattern you invented.

This is exactly the problem that classes and objects solve. They let you define a data structure that bundles related pieces of information together, gives that bundle a name, and lets you stamp out as many copies of it as you need — each copy holding its own values but sharing the same shape and the same behaviour.

A Concrete Starting Point: The School ID Card

Think about your school identity card. Every student's ID card in your school has the same layout: a box for the photo, a line for the name, a line for the roll number, a line for the blood group, and a line for the class section. The printing press that made your ID cards did not design a brand-new layout for every single student — it designed one template with blank fields, and then printed 1,200 copies of that template, filling in different details on each one.

That template is what a class is. It is not itself a person's ID card — you cannot show a blank template at the school gate and expect the guard to let you in. It only becomes a usable, real ID card once someone fills in the blanks with an actual name, an actual roll number, an actual blood group. Each filled-in card is a separate, independent object: your card has your name on it, and your friend's card has your friend's name on it, but both cards were stamped out from the exact same template, so they have exactly the same fields in exactly the same positions.

In programming terms:

  • A class is the template — it defines what fields (called attributes) every object of that type will have, and what actions (called methods) it can perform. It exists once, written by you in the code.
  • An object is one filled-in instance of that template, with real values in the fields. You can create as many objects from one class as you need, and each one has its own independent copy of the data.

Creating an object from a class is often called instantiation — you are creating one instance of the class, the way printing your ID card is creating one instance of the ID card template.

Writing Your First Class in Python

Let's rebuild the student report card program properly, using a class. Here is the class definition:

class Student:
    school_name = "Kendriya Vidyalaya"

    def __init__(self, name, roll_no, marks):
        self.name = name
        self.roll_no = roll_no
        self.marks = marks

    def get_grade(self):
        if self.marks >= 90:
            return "A1"
        elif self.marks >= 75:
            return "A2"
        elif self.marks >= 60:
            return "B1"
        else:
            return "B2"

    def show_report(self):
        print(f"{self.name} (Roll {self.roll_no}) scored {self.marks} -> Grade {self.get_grade()}")

Let's take this apart piece by piece, because every line here is doing a specific job.

class Student: starts the definition of a new class named Student. By Python convention, class names start with a capital letter — this is how you can tell at a glance, in any Python code you read, whether a name refers to a class (Student) or a normal variable (student).

def __init__(self, name, roll_no, marks): defines a special method called the constructor. Its job is to run automatically, exactly once, the moment a new object is created, and to set up that object's starting data. The name __init__ is fixed — Python looks specifically for a method with this exact name (short for "initialize") and calls it for you; you never call __init__ yourself by writing student.__init__(...).

The parameter self is the part that confuses almost every beginner, so let's be precise about it. When you eventually create an object and call a method on it, Python automatically passes that specific object in as the first argument to the method — self is simply the name that parameter is given inside the method, so the method can refer back to "the particular object I am currently working on." It is not a Python keyword like if or class; it is an ordinary parameter name that the entire Python community has agreed to always call self by convention, purely so that code is instantly readable to other programmers. Every method you write inside a class (other than a few special cases you'll meet later) must list self as its first parameter, because Python will always pass the object there automatically.

Inside the constructor, the three lines self.name = name, self.roll_no = roll_no, and self.marks = marks take the values passed in when the object is created and store them as attributes on that specific object. The right-hand side name refers to the plain parameter that was passed in; the left-hand side self.name creates a new piece of data attached permanently to this one object. This distinction — plain parameter vs. attribute stored with self. — is exactly what makes each object able to remember its own values.

get_grade and show_report are ordinary methods — functions defined inside a class that describe things an object of this class can do. Notice that get_grade reads self.marks, the marks belonging to whichever object called it, and show_report calls self.get_grade() to reuse that logic rather than repeating the grading rules a second time.

Creating Objects and Tracing What Happens

Now we use the class to create three separate student objects:

s1 = Student("Aarav", 101, 88)
s2 = Student("Diya", 102, 95)
s3 = Student("Kabir", 103, 58)

s1.show_report()
s2.show_report()
s3.show_report()

Trace through the first line carefully. Student("Aarav", 101, 88) tells Python: create a brand-new, empty object of type Student, then call __init__ on it, automatically passing the new object as self and the three values you wrote as name, roll_no, and marks. Inside __init__, Python runs self.name = "Aarav", self.roll_no = 101, self.marks = 88 — this stores those three values onto that specific new object. The finished object is then handed back and stored in the variable s1. The same happens independently for s2 and s3, each getting its own separate set of attribute values, even though all three objects were built from the identical class definition.

When Python runs s1.show_report(), it looks up the show_report method defined in the Student class, and calls it with self automatically bound to s1. Inside that call, every self.name, self.marks, and so on refers to s1's own data, not s2's or s3's. Running all three lines prints:

Aarav (Roll 101) scored 88 -> Grade A2
Diya (Roll 102) scored 95 -> Grade A1
Kabir (Roll 103) scored 58 -> Grade B2

Check the grading yourself against the get_grade rules: 88 is not ≥90 but is ≥75, so it falls into A2. 95 is ≥90, so A1. 58 is below all three thresholds, so it falls through to the final else and gets B2. Notice that all three objects used the exact same get_grade method — the method itself exists only once, in the class, but it produces a different result for each object because it reads that object's own self.marks.

Diagram: One Blueprint, Many Independent Objects

class Student self.name self.roll_no self.marks get_grade() show_report() s1 = Student("Aarav", 101, 88) name="Aarav" roll_no=101 marks=88 s1.get_grade() -> "A2" separate memory from s2 and s3 s2 = Student("Diya", 102, 95) name="Diya" roll_no=102 marks=95 s2.get_grade() -> "A1" separate memory from s1 and s3 s3 = Student("Kabir", 103, 58) name="Kabir" roll_no=103 marks=58 s3.get_grade() -> "B2" separate memory from s1 and s2

The diagram shows the key idea you should take away from this section: the class is written only once and holds the shape of the data (which attributes exist) and the logic (the methods). Every object built from it carries its own independent copy of the attribute values, but all objects share the exact same method code — Python does not duplicate get_grade three times in memory; it stores it once in the class and looks it up whenever any Student object needs it.

Objects That Change Over Time: A Cricket Scoring Example

So far, our student objects were filled in once and never changed. But real objects usually need to update their own data as a program runs. Consider tracking a batter's runs and balls faced during an innings — this is a good example precisely because the object's data changes with every ball bowled, and each batter must track their own numbers independently.

class Batter:
    def __init__(self, name):
        self.name = name
        self.runs = 0
        self.balls = 0

    def play_ball(self, runs_scored):
        self.runs += runs_scored
        self.balls += 1

    def strike_rate(self):
        if self.balls == 0:
            return 0.0
        return round((self.runs / self.balls) * 100, 2)

Here __init__ only takes name from the caller — runs and balls always start at zero for a new batter, so we hardcode those starting values inside the constructor instead of asking for them as arguments. This is a completely normal and common pattern: a constructor can set some attributes from parameters and other attributes to fixed starting values.

Now trace this usage:

virat = Batter("Virat")
virat.play_ball(4)
virat.play_ball(1)
virat.play_ball(0)
virat.play_ball(6)

print(virat.runs)
print(virat.balls)
print(virat.strike_rate())

Each call to play_ball mutates the object's own attributes: self.runs += runs_scored means "take my current runs, add the new runs scored on this ball, and store the result back into my own runs attribute." After four calls with values 4, 1, 0, and 6, virat.runs is 4 + 1 + 0 + 6 = 11, and virat.balls is 4 (it increased by one on every call, regardless of how many runs were scored on that ball — even a dot ball, worth zero runs, still counts as a ball faced). The strike rate is (11 / 4) * 100 = 275.0, rounded to two decimal places, which is still 275.0. So the three print statements output 11, 4, and 275.0 in that order.

Now suppose a second batter, Rohit, comes to the crease and we create a second object:

rohit = Batter("Rohit")
rohit.play_ball(4)
print(virat.runs, rohit.runs)

This prints 11 4. Rohit's single ball adds only to rohit.runs, because inside play_ball, self is bound to whichever object the method was called on — here, rohit. Virat's total is completely untouched. This is the entire point of storing data with self. inside an object rather than in one shared variable: every object keeps its own state, and changing one object's attributes never accidentally changes another object's attributes, even though both objects were built from the exact same class and the exact same method code.

Common Misconception 1: "self" Is a Special Python Keyword

A very common misunderstanding is that self is a reserved word in Python, similar to if, for, or class. It is not. self is just a parameter name, and Python would technically accept any name you choose there — you could legally write def play_ball(this, runs_scored): and use this.runs instead of self.runs, and the program would still work exactly the same way. The reason every Python programmer uses self without exception is that it is a universal convention, not a language rule — using anything else would make your code confusing and unusual to every other Python programmer who reads it, including your examiner. On CBSE Informatics Practices and Computer Science papers, using self correctly and consistently is expected exactly because it is the established convention, not because Python enforces it.

The misconception becomes dangerous when it leads to the opposite mistake: forgetting to include self as a parameter at all, and assuming Python will somehow know which object a method belongs to without being told. It will not. Consider:

class Robot:
    def greet():
        print("Beep boop")

r = Robot()
r.greet()

It is natural to expect this to print Beep boop. Instead it crashes with a TypeError, something like greet() takes 0 positional arguments but 1 was given (the exact wording can vary slightly by Python version). Here is why: r.greet() is really shorthand that Python expands into Robot.greet(r) — calling a method on an object always means passing that object in as the first argument automatically, whether or not the method's definition has a parameter ready to catch it. Since greet was defined with zero parameters, there is nowhere for r to go, and Python reports a mismatch between how many arguments the method expects and how many it actually received. The fix is simply to always write self as the first parameter of every method you define inside a class, even if the method's body never seems to use it.

Common Misconception 2: Class Attributes Belong to Every Object Equally

Go back to the Student class and notice the line school_name = "Kendriya Vidyalaya", written directly inside the class but outside __init__, with no self. in front of it. This is a class attribute — a piece of data attached to the class itself, shared by every object of that class, rather than an instance attribute like self.name, which each object stores separately. You can access it through the class directly, Student.school_name, or through any object, s1.school_name, and both give "Kendriya Vidyalaya", since there is only one copy of this value shared by all students.

The misconception shows up when a student writes something like s2.school_name = "Delhi Public School", expecting this to update the shared value for every student. It does not. This line creates a brand-new instance attribute called school_name that belongs only to s2, which now shadows the class attribute whenever you look it up through s2 specifically — but the shared class attribute underneath is completely unchanged. Trace it:

print(Student.school_name)
s2.school_name = "Delhi Public School"
print(s1.school_name, s2.school_name, Student.school_name)

The first line prints Kendriya Vidyalaya. The second line does not modify the class attribute at all — it attaches a brand-new attribute directly onto the object s2. The third line prints Kendriya Vidyalaya Delhi Public School Kendriya Vidyalaya: s1 still reads the original shared class attribute since it never got its own override, s2 now reads its own private instance attribute which hides the class attribute, and Student.school_name — the class attribute itself — was never touched. If you actually wanted to change the shared value for every student at once, you would need to reassign it through the class itself, Student.school_name = "Delhi Public School", which would then be visible through every object that has not already created its own override.

Why This Matters: Objects Model Real Entities Precisely

The reason classes and objects are considered a major shift in how you think about programming, rather than just a syntax convenience, is that they let your code mirror how real entities actually behave. A student has a name and marks that belong to them specifically and do not leak into another student's record. A batter accumulates their own runs, ball by ball, independent of every other batter on the field. Once you group the right data and the right behaviour into a single class, creating a hundred correctly-behaving objects is as simple as calling the constructor a hundred times — you never have to worry about whether you have correctly kept 500 loose variables in sync by hand, because Python does that bookkeeping for you the moment you write self.attribute = value inside a constructor. This is the foundation that every larger object-oriented idea you will meet later — inheritance, where one class extends another, and encapsulation, where a class hides its internal details behind clean methods — is built directly on top of.

Practice: Trace These Yourself Before Checking the Answers

  1. Given the Student class from this chapter, what does Student("Meera", 104, 100).get_grade() return, and why? (Trace through the if/elif chain with marks = 100.)
  2. If you create b = Batter("Smriti") and then call only b.strike_rate() without ever calling play_ball, what value is returned? Look carefully at the first line inside strike_rate — why is that check necessary, and what error would occur without it?
  3. A classmate writes a Car class with a method def honk(self): print(f"{self.brand} says beep!") but calls it as Car.honk() instead of my_car.honk(). Will this run? If not, what is missing, and how would you fix the call?
  4. Two objects, p1 = Player("Rahul") and p2 = Player("Sara"), are both created from a class with an instance attribute self.score = 0. After running p1.score += 10, what is p2.score? Explain in terms of how instance attributes are stored.

Answers: (1) "A1", because 100 ≥ 90 is the first condition checked and it is true, so Python never even evaluates the later branches. (2) It returns 0.0. Without the if self.balls == 0 check, the method would try to compute self.runs / self.balls, which is 0 / 0, and Python would raise a ZeroDivisionError instead of returning a sensible value. (3) It will not run correctly — calling Car.honk() through the class rather than through an object means there is no object for Python to automatically bind to self, so it raises a TypeError for a missing argument; the fix is to call it on an instance, my_car.honk(). (4) p2.score is still 0, unaffected — each object's instance attributes live in that object's own separate storage, so modifying p1's copy of score has no effect on p2's independent copy.

Summary

A class is a template that defines what data (attributes) and what actions (methods) objects of that type will have; an object is one specific, independent instance created from that template, holding its own real values. You define a class with the class keyword and typically give it an __init__ constructor method, which Python calls automatically the moment you create a new object, to set up that object's starting attribute values using self.attribute = value. Every method you define inside a class must take self as its first parameter — a plain, conventionally-named variable, not a Python keyword — because Python automatically passes the calling object into that slot whenever you write object.method(...); omitting it causes a TypeError about a mismatched number of arguments. Attributes set with self. inside methods are instance attributes, private to each individual object, while values assigned directly inside the class body (outside any method) are class attributes, shared by every object unless a specific object creates its own override that shadows the shared value without changing it. Multiple objects created from the same class always share identical method code but keep entirely separate, independently-updatable attribute data — which is precisely what lets one program correctly track many students, many cricket batters, or any other collection of real-world entities at once, without their data ever getting tangled together.

← Functional Programming in PythonOOP Part 2: Inheritance and Polymorphism →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn