Skip to main content

Core OOP Principles in Python: Inheritance, Polymorphism, Encapsulation, and Abstraction

Calculating read time…

Imagine you're building a house. You wouldn't start by laying bricks randomly. You'd first design a blueprint — a plan that defines every room, door, and window.

Object-Oriented Programming (OOP) in Python works the same way. A class is your blueprint, and an object is the actual house you build from it.

Let's leave behind confusing jargon. In this guide, you'll learn OOP by building real things, step-by-step. No prior knowledge needed. Let's begin.

What is Object-Oriented Programming (OOP)?

OOP is a way of writing code that mimics how we think about the real world. Instead of just a list of instructions, we organize our code around "objects."

An object is a single unit that contains both data (what it knows) and actions (what it can do).

The Core Idea: Bundle related data and functions into one neat package. This makes your code easier to manage, reuse, and understand as it grows.

Real-World Analogy: A Car

  • Data (Attributes/Properties): color, model, speed, fuel level.
  • Actions (Methods/Functions): start(), accelerate(), brake(), honk().

In OOP, we'd create a single Car "blueprint" (class) that holds all this together. Every individual car you create from this blueprint is an object.

Your First Class: Defining a Blueprint

A class is defined using the class keyword. By convention, class names start with a capital letter.

What this code does: This is your very first class — literally an empty blueprint. It doesn't do anything yet, but it teaches Python that "Dog" now exists as a data type you can build objects from. The keyword pass is a placeholder telling Python "nothing to execute here, but this block is syntactically valid."
# This is a blueprint. It doesn't create anything yet.
class Dog:
    pass  # 'pass' means "do nothing for now"

This Dog class is an empty blueprint. It's like a form that hasn't been filled out.

The __init__ Method: The Object's Birth Certificate

The most important method in a class is __init__() (that's two underscores before and after "init"). This is the initializer or constructor.

It's automatically called when you create a new object from the class. Its job is to set up the initial data for the object.

What this code does: This defines the constructor — the block of instructions Python runs automatically the moment you create a new Dog object. It grabs the name and age you pass in and stores them onto the object using self.name and self.age, so each dog remembers its own details. It also prints a birth announcement so you can literally watch the constructor fire.
class Dog:
    # This runs every time we create a new Dog
    def __init__(self, name, age):
        self.name = name  # Store the name
        self.age = age    # Store the age
        print(f"A new dog named {name} is born!")

Good practice: Think of self as the object itself. Inside the class, self.name means "the name of *this specific* dog object." It's how the object refers to itself.

Avoid: Forget to include self as the first parameter in every method you define inside a class. Python uses it to know which object is calling the method.

Creating Objects: Building from the Blueprint

Creating an object from a class is called instantiation. You call the class name like a function.

What this code does: Here we actually instantiate two separate Dog objects from the same class. Watch closely: Python automatically triggers __init__ for each one, which is why you see two "birth" announcements print immediately. Each object — my_dog and friends_dog — keeps its own independent copy of name and age, proven by accessing .name and .age separately below.
# Let's build two dogs from our Dog blueprint
my_dog = Dog("Rex", 3)       # Output: A new dog named Rex is born!
friends_dog = Dog("Bella", 5) # Output: A new dog named Bella is born!

print(my_dog.name)   # Output: Rex
print(friends_dog.age) # Output: 5

You've just created two independent objects (my_dog and friends_dog) from the single class (Dog). Each has its own separate data.

Adding Methods: Teaching Your Objects Actions

Methods are just functions defined inside a class. They define what an object can do.

What this code does: This block shows methods in action — functions that live inside a class and operate on that specific object's data. bark() reads self.name to personalize the woof, while have_birthday() actually mutates the object's state by incrementing self.age. This is your first real look at objects doing something, not just holding data.
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    # Method 1: Bark
    def bark(self):
        print(f"{self.name} says: Woof! Woof!")

    # Method 2: Have a birthday
    def have_birthday(self):
        self.age += 1
        print(f"Happy Birthday, {self.name}! You are now {self.age} years old.")

# Let's use our enhanced Dog class
my_dog = Dog("Rex", 3)
my_dog.bark()           # Output: Rex says: Woof! Woof!
my_dog.have_birthday()  # Output: Happy Birthday, Rex! You are now 4 years old.

Notice how methods can use the object's data (self.name, self.age) and even change it (self.age += 1).

A Practical Example: Building a Bank Account

Let's use a practical example to see how the pieces fit together. We'll create a BankAccount class.

Step 1: The Blueprint with Core Data

What this code does: We're kicking off a full BankAccount class. This constructor accepts an account holder's name and an optional starting balance (defaulting to 0 if not provided), then stores both as instance attributes. The immediate print statement confirms account creation — handy for debugging and instant user feedback.
class BankAccount:
    def __init__(self, account_holder, initial_balance=0):
        self.holder = account_holder
        self.balance = initial_balance
        print(f"Account created for {self.holder}. Balance: ${self.balance}")

Step 2: Add Essential Actions (Methods)

What this code does: Now we add the real business logic. deposit() validates that the amount is positive before adding it to the balance — never trust unvalidated input. withdraw() runs a two-sided check (0 < amount <= self.balance) to block both negative withdrawals and overdrafts. display_balance() is a simple read-only reporter. Together, these three methods control every way this account's balance can change — nothing touches self.balance from outside directly.
class BankAccount:
    def __init__(self, account_holder, initial_balance=0):
        self.holder = account_holder
        self.balance = initial_balance

    def deposit(self, amount):
        """Add money to the account."""
        if amount > 0:
            self.balance += amount
            print(f"Deposited ${amount}. New balance: ${self.balance}")
        else:
            print("Deposit amount must be positive.")

    def withdraw(self, amount):
        """Take money from the account."""
        if 0 < amount <= self.balance:
            self.balance -= amount
            print(f"Withdrew ${amount}. New balance: ${self.balance}")
        else:
            print("Invalid withdrawal amount or insufficient funds.")

    def display_balance(self):
        """Show the current balance."""
        print(f"Account holder: {self.holder}")
        print(f"Current balance: ${self.balance}")

Step 3: Use the Bank Account

What this code does: Now we can use the account and see how its rules behave. We create Alice's account with an opening balance of $100, then deposit, withdraw a valid amount, attempt an invalid over-withdrawal (which safely fails thanks to our validation), and finally print a full balance summary. Follow the comments to match each line to its output.
# Create an account for Alice
alice_account = BankAccount("Alice", 100)
alice_account.deposit(50)    # Deposited $50. New balance: $150
alice_account.withdraw(30)   # Withdrew $30. New balance: $120
alice_account.withdraw(200)  # Invalid withdrawal amount or insufficient funds.
alice_account.display_balance()
# Output:
# Account holder: Alice
# Current balance: $120

Important Tip: The data (self.balance) is protected inside the object. The only way to change it is through the object's own methods (deposit(), withdraw()). This is a key OOP concept called encapsulation.

Leveling Up: Advanced Class Concepts

With the basic model in place, we can look at a few concepts that become useful in larger programs.

1. Class Attributes vs. Instance Attributes

  • Instance Attribute: Unique to each object (like self.balance). My bank balance is different from yours.
  • Class Attribute: Shared by ALL objects of the class. Defined directly under the class, not inside __init__.
What this code does: This example introduces class attributes — data shared across every object of a class, not just one. bank_name and interest_rate live directly under the class, not inside __init__, meaning both acc1 and acc2 read the exact same value without either instance storing its own separate copy. Compare this to self.holder and self.balance, which stay unique per account.
class BankAccount:
    # Class Attribute - shared by all accounts
    bank_name = "Python Trust Bank"
    interest_rate = 0.02  # 2% interest for everyone

    def __init__(self, holder, balance=0):
        # Instance Attributes - unique to each account
        self.holder = holder
        self.balance = balance

    def show_bank_info(self):
        # Access class attribute using the class name
        print(f"This account is with {BankAccount.bank_name}.")
        print(f"The current interest rate is {BankAccount.interest_rate*100}%.")

# Using it
acc1 = BankAccount("Alice", 500)
acc2 = BankAccount("Bob", 1000)

print(acc1.bank_name)  # Output: Python Trust Bank
print(acc2.bank_name)  # Output: Python Trust Bank
acc1.show_bank_info()
# Output: This account is with Python Trust Bank.
# Output: The current interest rate is 2.0%.

Changing a class attribute affects all objects instantly.

2. The __str__ Method: Making Objects Readable

The __str__ method defines what happens when you print your object. It should return a nice, readable string.

What this code does: This defines what happens when you print() an object directly. Without __str__, Python would show you something cryptic like <__main__.BankAccount object at 0x...>. By overriding __str__, we control exactly what gets displayed — here, a clean, human-readable summary of the account.
class BankAccount:
    def __init__(self, holder, balance=0):
        self.holder = holder
        self.balance = balance

    def __str__(self):
        # This is what print() will show
        return f"BankAccount(holder='{self.holder}', balance=${self.balance})"

my_acc = BankAccount("Charlie", 750)
print(my_acc)  # Output: BankAccount(holder='Charlie', balance=$750)

Without __str__, print(my_acc) would show a confusing memory address.

Putting It All Together: A Complete Student Management System

The next example puts the pieces together in a small system.

What this code does: This is the main example — a complete, working system combining everything you've learned: instance attributes, class attributes (school_name, total_students), lists and dictionaries as object data, multiple methods working together, and a custom __str__. Pay close attention to Student.total_students += 1 inside __init__ — it's a shared counter that increments every time any new student is created, which is exactly why each student automatically gets a unique, auto-incrementing student_id. Read it line by line to see the whole OOP picture click into place.
class Student:
    # Class Attribute
    school_name = "Digital Horizons High"
    total_students = 0  # A counter shared by all

    def __init__(self, name, grade_level):
        # Instance Attributes
        self.name = name
        self.grade_level = grade_level
        self.courses = []  # Start with an empty course list
        self.grades = {}   # Dictionary: course -> grade

        # Update the shared counter
        Student.total_students += 1
        self.student_id = Student.total_students

        print(f"Welcome, {self.name}! Your ID is {self.student_id}.")

    def enroll(self, course_name):
        """Enroll student in a new course."""
        if course_name not in self.courses:
            self.courses.append(course_name)
            self.grades[course_name] = None  # No grade yet
            print(f"{self.name} enrolled in {course_name}.")
        else:
            print(f"{self.name} is already in {course_name}.")

    def assign_grade(self, course_name, grade):
        """Assign a grade for a course."""
        if course_name in self.courses:
            self.grades[course_name] = grade
            print(f"Grade {grade} recorded for {self.name} in {course_name}.")
        else:
            print(f"Error: {self.name} is not enrolled in {course_name}.")

    def calculate_gpa(self):
        """Calculate the student's average grade (GPA)."""
        valid_grades = [g for g in self.grades.values() if g is not None]
        if valid_grades:
            average = sum(valid_grades) / len(valid_grades)
            return round(average, 2)
        return 0.0

    def get_report_card(self):
        """Generate a formatted report card."""
        report = f"\n--- Report Card for {self.name} ---\n"
        report += f"School: {Student.school_name}\n"
        report += f"Grade Level: {self.grade_level}\n"
        report += "Courses & Grades:\n"
        for course in self.courses:
            grade = self.grades.get(course, "No Grade")
            report += f"  - {course}: {grade}\n"
        report += f"Current GPA: {self.calculate_gpa()}\n"
        return report

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

# ==== Let's Run the System ====
print(f"School: {Student.school_name}")

# Create students
student1 = Student("Emma", 10)
student2 = Student("Liam", 11)

# Enroll them in courses
student1.enroll("Math")
student1.enroll("Science")
student2.enroll("Math")
student2.enroll("History")

# Assign grades
student1.assign_grade("Math", 88)
student1.assign_grade("Science", 92)
student2.assign_grade("Math", 95)
student2.assign_grade("History", 85)

# Get report cards
print(student1.get_report_card())
print(student2.get_report_card())

# Print students using __str__
print(student1)  # Output: Student(ID:1, Name:Emma, GPA:90.0)
print(student2)  # Output: Student(ID:2, Name:Liam, GPA:90.0)

print(f"\nTotal students in system: {Student.total_students}")

Good practice: Use OOP when you have multiple items that share the same kind of data and behavior. It keeps your code organized like a well-structured filing cabinet, not a pile of papers.

Why OOP? The Big Benefits

  • Organization: Code is grouped logically around objects (like Student, BankAccount).
  • Reusability: Write a class once (like Dog), and create countless objects from it.
  • Maintenance: Fixing a bug in a class fixes it for all objects using that class.
  • Modeling Reality: It's intuitive. We naturally think in terms of objects and their interactions.

Frequently Asked Questions

Why does Python require self explicitly, instead of handling it implicitly like some other languages?

Python follows the principle "explicit is better than implicit." Under the hood, my_dog.bark() is actually translated to Dog.bark(my_dog) — Python passes the calling object as the first argument automatically, but it still requires the parameter to be named in the method signature so there's no hidden magic. This makes it crystal clear, even to someone reading the class in isolation, which object each method operates on.

What actually happens in memory when you mutate a class attribute versus an instance attribute?

A class attribute is stored once, on the class object itself, and every instance looks it up through a reference chain if it doesn't have its own copy. The moment you do acc1.interest_rate = 0.05, though, Python creates a brand-new instance attribute on acc1 that shadows the class attribute — it does not modify the shared value. Only assigning through the class name (BankAccount.interest_rate = 0.05) changes it for every object at once. This distinction trips up even experienced developers.

In the BankAccount example, what could go wrong if you allowed direct access to self.balance instead of routing changes through methods?

Without validation logic gatekeeping the balance, any part of your codebase could set account.balance = -500 or assign a string by mistake, silently corrupting your data with no audit trail. Routing all mutations through deposit() and withdraw() means every change is validated and logged in one place — this is the practical value of encapsulation, not just a theoretical OOP buzzword.

What's the danger of using a mutable default argument in __init__, like def __init__(self, courses=[])?

Default argument values in Python are evaluated only once, when the function is defined — not each time it's called. If you used a mutable default like a list, every object created without passing courses explicitly would share the exact same underlying list, so appending to one student's courses would silently appear on every other student too. That's precisely why the Student class initializes self.courses = [] fresh inside the constructor body instead of using it as a default parameter value — a classic gotcha worth remembering for interviews.

How does __str__ differ from __repr__, and which one should production logging code rely on?

__str__ is meant for a readable, end-user-facing description (what print() uses), while __repr__ is meant to be an unambiguous, developer-facing representation — ideally one that could recreate the object if pasted back into Python. If you only define __str__, Python falls back to it for repr() too in simple cases, but for production logging and debugging, it's best practice to implement both, since log aggregators often call repr() on objects internally.

If two accounts share a class attribute like interest_rate, how would you give just one account a different rate without breaking the others?

You'd assign the new value directly on that specific instance, e.g. acc1.interest_rate = 0.045. This creates an instance attribute that shadows the class-level one only for acc1; acc2 and every future account continue reading the original shared class value. This pattern is commonly used for one-off overrides — like a promotional rate for a single customer — without touching the shared default.

How would you explain encapsulation in an interview using the BankAccount example, without reciting textbook definitions?

A strong answer frames it around risk control: encapsulation means the balance can only change through methods that enforce rules, so a caller can never accidentally set a negative balance or skip validation. It's less about "hiding data" as an abstract concept, and more about guaranteeing that every state change goes through the same trusted gatekeeper — which is exactly what interviewers are listening for.

What real-world signal tells you it's time to refactor procedural code into a class-based (OOP) design?

The clearest signal is when you notice the same group of variables (like name, balance, holder) being passed together between multiple functions repeatedly, or when several functions all operate on the same dictionary structure. That repetition of "these things always travel together" is the exact shape a class is built to solve — bundling the data and the functions that act on it into one unit, as this article's Student and BankAccount examples demonstrate.

Does Python's dynamic typing change how you should design class methods compared to a statically typed language like Java?
Yes — in Python, there's no compiler enforcing that deposit(amount) receives a number, so defensive validation inside the method body (like the if amount > 0 check) isn't optional polish, it's a structural requirement to prevent silent bugs. In statically typed languages, some of that safety is enforced at compile time; in Python, the discipline has to live inside the method logic itself, which is why the BankAccount class validates every input before touching self.balance.

Comments