Ad – 728Γ—90
πŸ—οΈ OOP

Python Classes & Objects – The Complete OOP Guide

Object-Oriented Programming (OOP) is a paradigm that organizes code around objects β€” self-contained units that combine data (attributes) and behavior (methods). Python is a fully object-oriented language, and understanding classes is essential for writing clean, scalable, real-world programs. This lesson covers the class keyword, the __init__ constructor, self, instance vs class attributes, instance methods, the __str__ and __repr__ magic methods, and multiple complete real-world class examples.

⏱️ 35 min read 🎯 Intermediate πŸ“… Updated 2026

What is Object-Oriented Programming?

In procedural programming you write functions that operate on data passed to them. In OOP you bundle the data and the functions that operate on it into a single unit called an object. Objects are created from a blueprint called a class.

Think of it this way:

  • A class is like an architectural blueprint for a house.
  • An object (instance) is an actual house built from that blueprint.
  • You can build many houses from one blueprint, each with different colours, sizes, and occupants.
πŸ“¦

Encapsulation

Bundle data and methods together. Hide internal details; expose a clean interface.

♻️

Reusability

Define a class once; create as many objects as you need. Subclasses inherit and extend behavior.

πŸ”§

Maintainability

Fix a bug in a class and every object of that class benefits. Changes are localized.

🌍

Modelling Reality

Code mirrors real-world entities: BankAccount, Car, Student β€” making intent clear.

Defining a Class

Use the class keyword followed by the class name (by convention in PascalCase) and a colon. The class body is indented.

Python
# Minimal class
class Dog:
    pass  # 'pass' is a placeholder for an empty body

# Creating an object (instance) of the class
my_dog = Dog()
print(type(my_dog))    # 
print(my_dog)          # <__main__.Dog object at 0x...>
β–Ά Output
<class '__main__.Dog'> <__main__.Dog object at 0x7f3a1b2c3d40>

The __init__ Method (Constructor)

__init__ is a special magic method (also called a dunder method β€” short for "double underscore"). It is automatically called whenever a new object is created from the class. Use it to set up the initial state of each object.

Python
class Dog:
    def __init__(self, name, breed, age):
        # These are INSTANCE ATTRIBUTES
        self.name = name
        self.breed = breed
        self.age = age

# Create instances β€” __init__ is called automatically
dog1 = Dog("Rex", "Labrador", 3)
dog2 = Dog("Bella", "Poodle", 5)

# Access attributes using dot notation
print(dog1.name)    # Rex
print(dog2.breed)   # Poodle
print(dog1.age)     # 3

# Each object has its OWN copy of instance attributes
print(dog1.name == dog2.name)  # False - different objects
β–Ά Output
Rex Poodle 3 False
πŸ’‘
What is self?

self is a reference to the current object (the instance being created or used). When you call dog1.bark(), Python automatically passes dog1 as the first argument β€” that's self. The name "self" is a convention, not a keyword, but you should always use it.

Instance Methods

Methods are functions defined inside a class. The first parameter is always self, giving them access to the object's attributes.

Python
class Dog:
    def __init__(self, name, breed, age):
        self.name = name
        self.breed = breed
        self.age = age

    def bark(self):
        return f"{self.name} says: Woof!"

    def describe(self):
        return f"{self.name} is a {self.age}-year-old {self.breed}."

    def have_birthday(self):
        self.age += 1
        print(f"Happy Birthday {self.name}! Now {self.age} years old.")

# Create objects and call methods
rex = Dog("Rex", "Labrador", 3)
print(rex.bark())
print(rex.describe())

rex.have_birthday()
print(rex.describe())   # age is now 4
β–Ά Output
Rex says: Woof! Rex is a 3-year-old Labrador. Happy Birthday Rex! Now 4 years old. Rex is a 4-year-old Labrador.
Ad – 336Γ—280

Instance Attributes vs Class Attributes

Instance attributes are unique to each object (set via self in __init__). Class attributes are shared among all instances β€” they're defined directly inside the class body, outside any method.

Python
class Dog:
    # CLASS attribute β€” shared by ALL instances
    species = "Canis lupus familiaris"
    total_dogs = 0  # count all dogs ever created

    def __init__(self, name, breed):
        # INSTANCE attributes β€” unique to each object
        self.name = name
        self.breed = breed
        Dog.total_dogs += 1  # update class attribute

    def info(self):
        return f"{self.name} ({self.breed}) | Species: {self.species}"

d1 = Dog("Rex", "Labrador")
d2 = Dog("Bella", "Poodle")
d3 = Dog("Max", "Beagle")

print(d1.info())
print(d2.info())

# Access class attribute via class or any instance
print(f"Species: {Dog.species}")
print(f"Total dogs created: {Dog.total_dogs}")  # 3

# Instance attribute shadows class attribute if set on the instance
d1.species = "Modified"        # creates a local instance attribute
print(d1.species)              # Modified (local copy)
print(d2.species)              # Canis lupus familiaris (class attribute unchanged)
β–Ά Output
Rex (Labrador) | Species: Canis lupus familiaris Bella (Poodle) | Species: Canis lupus familiaris Species: Canis lupus familiaris Total dogs created: 3 Modified Canis lupus familiaris

__str__ and __repr__ Magic Methods

__str__ controls what print(obj) displays β€” a human-friendly string. __repr__ controls the developer representation shown in the REPL and debugging. If only __repr__ is defined, print() falls back to it.

Python
class Book:
    def __init__(self, title, author, year, pages):
        self.title = title
        self.author = author
        self.year = year
        self.pages = pages

    def __str__(self):
        """Human-friendly string: used by print()"""
        return f'"{self.title}" by {self.author} ({self.year})'

    def __repr__(self):
        """Developer/debug representation"""
        return (f"Book(title={self.title!r}, author={self.author!r}, "
                f"year={self.year}, pages={self.pages})")

book = Book("Clean Code", "Robert Martin", 2008, 431)

print(book)        # uses __str__
print(repr(book))  # uses __repr__

# In a list, __repr__ is used:
library = [book, Book("The Pragmatic Programmer", "Hunt & Thomas", 1999, 352)]
print(library)
β–Ά Output
"Clean Code" by Robert Martin (2008) Book(title='Clean Code', author='Robert Martin', year=2008, pages=431) [Book(title='Clean Code', author='Robert Martin', year=2008, pages=431), Book(title='The Pragmatic Programmer', author='Hunt & Thomas', year=1999, pages=352)]

Real-World Example: BankAccount

Python
class BankAccount:
    """A simple bank account with deposit and withdrawal support."""

    bank_name = "PyBank"   # class attribute

    def __init__(self, owner, account_number, initial_balance=0):
        self.owner = owner
        self.account_number = account_number
        self._balance = initial_balance   # _ = convention for "private"
        self._transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self._balance += amount
        self._transactions.append(("deposit", amount))
        print(f"  Deposited ${amount:.2f}. New balance: ${self._balance:.2f}")

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive.")
        if amount > self._balance:
            print(f"  Insufficient funds. Balance: ${self._balance:.2f}")
            return False
        self._balance -= amount
        self._transactions.append(("withdrawal", amount))
        print(f"  Withdrew ${amount:.2f}. New balance: ${self._balance:.2f}")
        return True

    def get_balance(self):
        return self._balance

    def statement(self):
        print(f"\n  {self.bank_name} Statement β€” {self.owner} ({self.account_number})")
        print(f"  {'='*45}")
        for t_type, amount in self._transactions:
            symbol = "+" if t_type == "deposit" else "-"
            print(f"  {symbol} ${amount:.2f}  ({t_type})")
        print(f"  {'='*45}")
        print(f"  Current Balance: ${self._balance:.2f}\n")

    def __str__(self):
        return f"BankAccount({self.owner}, ${self._balance:.2f})"


# Use the class
acc = BankAccount("Alice", "ACC-1001", initial_balance=1000)
acc.deposit(500)
acc.withdraw(200)
acc.withdraw(2000)   # should fail
acc.deposit(100)
acc.statement()
β–Ά Output
Deposited $500.00. New balance: $1500.00 Withdrew $200.00. New balance: $1300.00 Insufficient funds. Balance: $1300.00 Deposited $100.00. New balance: $1400.00 PyBank Statement β€” Alice (ACC-1001) ============================================= + $500.00 (deposit) - $200.00 (withdrawal) + $100.00 (deposit) ============================================= Current Balance: $1400.00

Real-World Example: Car

Python
class Car:
    """Represents a car with fuel management."""

    def __init__(self, make, model, year, fuel_capacity=60):
        self.make = make
        self.model = model
        self.year = year
        self.fuel_capacity = fuel_capacity  # litres
        self._fuel_level = 0
        self._odometer = 0

    def fuel_up(self, litres=None):
        """Fill to specified amount, or top up completely."""
        if litres is None:
            litres = self.fuel_capacity - self._fuel_level
        added = min(litres, self.fuel_capacity - self._fuel_level)
        self._fuel_level += added
        print(f"  Added {added:.1f}L. Fuel: {self._fuel_level:.1f}/{self.fuel_capacity}L")

    def drive(self, km, fuel_per_100km=8):
        """Drive a number of km, consuming fuel."""
        fuel_needed = (km / 100) * fuel_per_100km
        if fuel_needed > self._fuel_level:
            possible_km = (self._fuel_level / fuel_per_100km) * 100
            print(f"  Not enough fuel! Can drive ~{possible_km:.0f} km.")
            return
        self._fuel_level -= fuel_needed
        self._odometer += km
        print(f"  Drove {km}km. Odometer: {self._odometer}km. Fuel left: {self._fuel_level:.1f}L")

    def __str__(self):
        return f"{self.year} {self.make} {self.model}"

    def __repr__(self):
        return f"Car('{self.make}', '{self.model}', {self.year})"


my_car = Car("Toyota", "Camry", 2022)
print(my_car)
my_car.fuel_up(50)
my_car.drive(200)
my_car.drive(400)   # not enough fuel
β–Ά Output
2022 Toyota Camry Added 50.0L. Fuel: 50.0/60L Drove 200km. Odometer: 200km. Fuel left: 34.0L Not enough fuel! Can drive ~425 km.

Real-World Example: Student

Python
class Student:
    """Represents a university student with grade tracking."""

    def __init__(self, name, student_id, major):
        self.name = name
        self.student_id = student_id
        self.major = major
        self._grades = {}  # {subject: grade}

    def add_grade(self, subject, grade):
        if not (0 <= grade <= 100):
            raise ValueError("Grade must be between 0 and 100.")
        self._grades[subject] = grade
        print(f"  Added {subject}: {grade}")

    def gpa(self):
        if not self._grades:
            return 0.0
        return sum(self._grades.values()) / len(self._grades)

    def letter_grade(self, score):
        if score >= 90: return "A"
        if score >= 80: return "B"
        if score >= 70: return "C"
        if score >= 60: return "D"
        return "F"

    def transcript(self):
        print(f"\n  Transcript β€” {self.name} ({self.student_id})")
        print(f"  Major: {self.major}")
        print(f"  {'-'*35}")
        for subject, grade in self._grades.items():
            print(f"  {subject:<20}: {grade:>3}  ({self.letter_grade(grade)})")
        print(f"  {'-'*35}")
        print(f"  GPA: {self.gpa():.1f}  ({self.letter_grade(self.gpa())})\n")

    def __str__(self):
        return f"Student({self.name}, {self.student_id})"


alice = Student("Alice Nguyen", "S20230042", "Computer Science")
alice.add_grade("Python Programming", 95)
alice.add_grade("Data Structures", 88)
alice.add_grade("Mathematics", 92)
alice.add_grade("English", 79)
alice.transcript()
β–Ά Output
Added Python Programming: 95 Added Data Structures: 88 Added Mathematics: 92 Added English: 79 Transcript β€” Alice Nguyen (S20230042) Major: Computer Science ----------------------------------- Python Programming : 95 (A) Data Structures : 88 (B) Mathematics : 92 (A) English : 79 (C) ----------------------------------- GPA: 88.5 (B)
πŸ’‘
Single Underscore Convention

Attributes prefixed with a single underscore (_balance, _grades) signal "internal/private β€” don't access directly from outside." Python doesn't enforce this, but it's a widely respected convention. Double underscore (__attr) triggers name mangling and provides slightly stronger protection.

πŸ‹οΈ Practical Exercise

  1. Create a Rectangle class with width and height attributes. Add methods area(), perimeter(), and is_square(). Implement __str__ to return something like "Rectangle(4 x 6)".
  2. Create a Counter class with a class attribute instances_created = 0 that tracks how many Counter objects have ever been instantiated. Each Counter has an instance attribute count = 0, and methods increment(), decrement(), reset(), and value().
  3. Build a Temperature class that stores temperature in Celsius. Add methods to_fahrenheit(), to_kelvin(), and a __str__ that shows the value in all three units.

πŸ”₯ Challenge

Design a Library Management System using two classes: Book (title, author, isbn, copies_available) and Library (name, collection of books). The Library class should have methods: add_book(book), checkout(isbn) (reduce copies by 1; raise error if unavailable), return_book(isbn) (increase copies by 1), search(query) (return books where title or author contains the query), and catalog() (print all books with availability). Demonstrate checking out and returning books, and searching the catalog.

Interview Questions on Python Classes & OOP

  • What is the difference between a class and an object (instance)?
  • What is __init__ and when is it called?
  • What is self? Why must it be the first parameter of instance methods?
  • What is the difference between instance attributes and class attributes?
  • What are __str__ and __repr__? How do they differ?
  • What do the four pillars of OOP stand for: Encapsulation, Abstraction, Inheritance, Polymorphism?
  • What does the single underscore prefix (_attr) signal? What about double underscore (__attr)?
  • How does Python's class-based OOP differ from classical OOP languages like Java?

πŸ“‹ Summary

  • A class is a blueprint; an object (instance) is a concrete entity built from that blueprint.
  • Define classes with class ClassName: using PascalCase naming.
  • __init__(self, ...) is the constructor β€” it initialises instance attributes when an object is created.
  • self refers to the current object; it must be the first parameter of every instance method.
  • Instance attributes (self.x) are unique to each object; class attributes are shared by all instances.
  • Methods are functions defined inside a class that operate on self.
  • __str__ controls print(obj) output (human-readable); __repr__ provides a developer/debug representation.
  • OOP enables encapsulation, reusability, and modelling of real-world entities β€” making large programs easier to manage.

Frequently Asked Questions

Can you create a class without __init__? +

Yes. If you don't define __init__, Python uses the default inherited from object (every class implicitly inherits from object). The resulting instances have no instance attributes until you set them manually with obj.attr = value. In practice, almost all real classes define __init__ to set up a predictable starting state.

What is the difference between a method and a function? +

A function is defined with def at the module level. A method is a function defined inside a class. The key difference is that an instance method automatically receives the object (self) as its first argument. Technically in Python, methods are just functions that belong to a class and are called via an object.

What happens if you forget to include self as the first parameter? +

Python raises a TypeError when you call the method. For example, if you define def bark(): inside a class and call dog.bark(), Python passes the dog instance as the first positional argument, but the function has no parameter to receive it β€” hence the error: "bark() takes 0 positional arguments but 1 was given."

How do class methods and static methods differ from instance methods? +

Instance methods receive self (the object). Class methods (decorated with @classmethod) receive cls (the class itself) β€” useful for alternative constructors. Static methods (decorated with @staticmethod) receive neither self nor cls β€” they're utility functions logically grouped inside the class but not needing access to the class or instance.

What is name mangling with double underscore? +

If you name an attribute with two leading underscores (e.g., self.__secret), Python renames it internally to _ClassName__secret. This makes it harder (but not impossible) to accidentally access or override from outside or from subclasses. It's a stronger hint than a single underscore that the attribute is truly internal. Access it externally with obj._ClassName__secret if you must.