Intermediate Python 📂 Class and Object · 3 of 10 49 min read

Python OOP — Inheritance, super(), and Method Overriding Explained

Learn Python inheritance from the ground up: how a child class inherits every attribute and method of its parent, how to override behaviour selectively, and how super() chains constructors so no code is duplicated. Uses a Vehicle → Car → ElectricCar hierarchy, three animated SVG diagrams (inheritance tree, method resolution order, super() chain), a family-recipe analogy, isinstance/issubclass, and 10 golden rules that keep inheritance clean.

Section 01

The Story That Explains Inheritance

The Family Recipe — Bread, Then Rosemary Bread, Then Garlic Bread
Grandma wrote the base Bread recipe: flour, water, salt, yeast, bake at 200°C. Mum took Grandma's recipe, kept every step, and added her own touch — rosemary. You took Mum's recipe, kept everything (Grandma's steps and Mum's rosemary), and added your own — garlic and cheese.

You didn't rewrite the base bread recipe. You didn't rewrite Mum's changes. You inherited everything above you and only added what's new.

That's exactly what inheritance does in Python. A child class automatically gets every attribute and method of its parent class, and can add its own — or replace ("override") specific pieces. No copy-paste. No duplication. Change the parent once, every child gets the fix.
🌐
The Core Insight

A child class is not a copy of its parent — it's a reference to it. When you call a method on the child that the child doesn't define, Python automatically walks up the chain and finds it in the parent. This is what "IS-A" thinking looks like in code: an ElectricCar is a Car, which is a Vehicle.


Section 02

Why We Need Inheritance — The DRY Argument

Suppose you're modelling three types of vehicles. Without inheritance, you'd write the same brand, wheels, speed, start() logic three times. Change something? Update three places. Add a fourth vehicle type? Copy-paste again. Bugs drift in. Behaviour diverges.

❌ Without Inheritance — Copy-Paste
3 vehicle classes → 3 copies of the same setup
Fix a bug → update every copy
Behaviour drifts between classes over time
Reader can't tell they're conceptually related
✅ With Inheritance — Write Once
Common logic lives once in the parent
Fix a bug → update one place, everyone inherits it
Adding a new subclass is tiny — just the differences
Reader sees the relationship immediately: class Car(Vehicle)

Basic Syntax

# ── Parent class (also called base class or superclass) ──
class Vehicle:
    def __init__(self, brand, wheels):
        self.brand  = brand
        self.wheels = wheels

    def start(self):
        print(f"{self.brand} engine starting...")

# ── Child class inherits by putting parent in parentheses ──
class Car(Vehicle):     # "Car IS-A Vehicle"
    pass                # nothing new yet — but it already has __init__ and start()

# ── Use the child — it inherits everything from the parent ──
my_car = Car("Honda", 4)   # uses Vehicle's __init__
my_car.start()                # uses Vehicle's start() → "Honda engine starting..."
print(my_car.brand)          # Honda
print(my_car.wheels)         # 4
OUTPUT
Honda engine starting... Honda 4

Section 03

Animated Diagram — The Inheritance Tree

Watch a three-level hierarchy come together: Vehicle at the top, Car in the middle, ElectricCar at the bottom. Each child inherits everything above it and adds its own. On the right, one tesla instance shows the full set of attributes it can reach — from every level of the chain.

INHERITANCE TREE  ·  CHILD GETS EVERYTHING ABOVE IT + ITS OWN
PARENT  ·  VEHICLE brand wheels start() accelerate() inherits CHILD  ·  CAR (Vehicle) + doors + fuel_type + open_doors() inherits GRANDCHILD  ·  ELECTRICCAR (Car) + battery_kwh + charge() * start()  (overrides parent) INSTANCE  ·  tesla tesla = ElectricCar(...) ─── everything it can reach ─── tesla.brand tesla.wheels tesla.accelerate(60) from Vehicle tesla.doors tesla.fuel_type tesla.open_doors() from Car tesla.battery_kwh tesla.charge(10) tesla.start() from ElectricCar * override wins one instance, three levels of inherited API

A three-level chain, one instance. Every dot in the "tesla" panel came free through inheritance — only battery_kwh, charge(), and start() were defined in ElectricCar itself.


Section 04

Extending — Adding New Attributes and Methods to the Child

Inheriting is only half the story. The real power of a child class is to add its own attributes and methods without touching the parent. The child has everything the parent has, plus whatever new stuff you define.

class Vehicle:
    def __init__(self, brand, wheels):
        self.brand  = brand
        self.wheels = wheels
        self.speed  = 0

    def accelerate(self, amount):
        self.speed += amount
        print(f"{self.brand} now at {self.speed} km/h")


class Car(Vehicle):
    def __init__(self, brand, doors, fuel_type):
        # Call the parent's __init__ first — we'll cover super() next
        super().__init__(brand, wheels=4)
        # Now add child-specific instance variables
        self.doors     = doors
        self.fuel_type = fuel_type

    # A brand-new method that only Car has
    def open_doors(self):
        print(f"Opening all {self.doors} doors of the {self.brand}")


honda = Car("Honda Civic", doors=4, fuel_type="petrol")
honda.accelerate(50)         # inherited from Vehicle
honda.open_doors()            # brand-new on Car
print(honda.wheels)         # 4  (set by parent's __init__)
print(honda.fuel_type)      # petrol  (added by child)
OUTPUT
Honda Civic now at 50 km/h Opening all 4 doors of the Honda Civic 4 petrol

Section 05

Method Overriding — Replacing the Parent's Behaviour

Sometimes the parent's method isn't quite right for the child. An electric car doesn't start with a roar — it starts silently. Instead of adding a new method, the child redefines a method with the same name. This is called overriding.

class Car:
    def __init__(self, brand):
        self.brand = brand

    def start(self):
        print(f"{self.brand} engine starts with a ROAR!")


class ElectricCar(Car):
    # Same method name → this REPLACES the parent's start()
    def start(self):
        print(f"{self.brand} powers on silently. Ready to drive.")


petrol   = Car("Honda")
electric = ElectricCar("Tesla")

petrol.start()              # Honda engine starts with a ROAR!
electric.start()            # Tesla powers on silently. Ready to drive.
OUTPUT
Honda engine starts with a ROAR! Tesla powers on silently. Ready to drive.

Animated Diagram — Method Resolution Order (Where Python Looks)

When you call tesla.accelerate(60), Python doesn't guess — it follows a strict rule: start at the object's own class, then walk up the chain. The first match wins. This is the Method Resolution Order (MRO).

METHOD LOOKUP  ·  PYTHON WALKS UP UNTIL IT FINDS A MATCH
tesla.accelerate(60) Python starts at the object's class → walks up 1. ELECTRICCAR defines: start(), charge(), battery_kwh no accelerate() here — go up ✗ not here 2. CAR defines: open_doors(), doors, fuel_type no accelerate() here — go up ✗ not here 3. VEHICLE defines: brand, wheels, speed, accelerate() FOUND! use this one ✓ FOUND

Python checks ElectricCar first, then Car, then Vehicle. The first match wins — that's why overrides in a child always beat the parent version.

💡
See the MRO Yourself

Every class has an .mro() method that shows Python's exact search order: ElectricCar.mro() returns [ElectricCar, Car, Vehicle, object]. That last object is Python's ultimate base class — every class inherits from it whether you write it or not.


Section 06

super() — Reaching Back to the Parent

When you override a method but still want the parent's behaviour plus your own, use super(). It gives you a reference to the parent so you can call its version first, then add.

class Vehicle:
    def __init__(self, brand, wheels):
        self.brand  = brand
        self.wheels = wheels
        print(f"  [Vehicle] set brand={brand}, wheels={wheels}")

    def describe(self):
        return f"{self.brand} with {self.wheels} wheels"


class Car(Vehicle):
    def __init__(self, brand, doors):
        super().__init__(brand, wheels=4)   # call parent __init__ first
        self.doors = doors
        print(f"  [Car] added doors={doors}")

    def describe(self):
        # EXTEND the parent's describe() instead of replacing it entirely
        base = super().describe()
        return f"{base}, {self.doors} doors"


class ElectricCar(Car):
    def __init__(self, brand, doors, battery_kwh):
        super().__init__(brand, doors)            # chain up to Car → Vehicle
        self.battery_kwh = battery_kwh
        print(f"  [ElectricCar] added battery_kwh={battery_kwh}")

    def describe(self):
        base = super().describe()                # uses Car's describe → uses Vehicle's describe
        return f"{base}, {self.battery_kwh} kWh battery"


print("Creating Tesla...")
tesla = ElectricCar("Tesla Model 3", doors=4, battery_kwh=75)
print()
print(tesla.describe())
OUTPUT
Creating Tesla... [Vehicle] set brand=Tesla Model 3, wheels=4 [Car] added doors=4 [ElectricCar] added battery_kwh=75 Tesla Model 3 with 4 wheels, 4 doors, 75 kWh battery
🏆
The super() Pattern

Every level's __init__ called its parent's __init__ first, then added its own attributes. Every level's describe() called the parent's version and appended its own detail. That's the whole "extend, don't replace" pattern in one example.

Animated Diagram — The super() Chain in Action

Watch what happens when ElectricCar("Tesla", 4, 75) runs. Each level's __init__ jumps up to its parent via super().__init__(...) — the chain climbs all the way to Vehicle, then unwinds back down, filling in one layer of attributes at each return.

SUPER() CHAIN  ·  CLIMB UP, RUN, THEN UNWIND
ElectricCar("Tesla", 4, 75) ELECTRICCAR.__init__ super().__init__(brand, doors) self.battery_kwh = 75 super() CAR.__init__ super().__init__(brand, wheels=4) self.doors = 4 super() VEHICLE.__init__  (base) self.brand = "Tesla" self.wheels = 4 tesla object  ·  filling up FROM VEHICLE brand = "Tesla" wheels = 4 FROM CAR doors = 4 FROM ELECTRICCAR battery_kwh = 75 chain climbs up → runs top-first → unwinds down every level contributes its own attributes

The chain climbs to Vehicle first, then unwinds. Vehicle's attributes land first, Car's on top, ElectricCar's last — one object, three layers, all wired by super().


Section 07

Types of Inheritance

📈
Single Inheritance
One child, one parent. The most common form.
class Car(Vehicle):
1 parent → 1 child
🏟️
Multi-Level Inheritance
A chain: grandparent → parent → child. Each layer inherits from the one above.
Vehicle → Car → ElectricCar
chain of 3+ levels
🌱
Hierarchical Inheritance
One parent, several children — siblings.
Vehicle → {Car, Bike, Truck}
1 parent → N children
🔌
Multiple Inheritance
One child, multiple parents. Powerful but tricky — use sparingly and prefer small "mixin" classes.
class HybridCar(Petrol, Electric):
use with caution
⚠️
Multiple Inheritance — Handle With Care

Multiple inheritance can cause the classic "diamond problem" where two parents provide different versions of the same method. Python resolves it with a deterministic MRO algorithm (C3 linearisation), but the readability cost is real. In practice, prefer single inheritance plus small mixins — classes that add one specific capability (LoggingMixin, SerialisableMixin).


Section 08

Complete Practical Example — Vehicle Hierarchy

A full three-level inheritance chain using everything from the tutorial: inheritance, extension, overriding, and super() chaining. Ready to paste and run.

class Vehicle:
    """Base class — everything on wheels shares this."""
    def __init__(self, brand, wheels):
        self.brand  = brand
        self.wheels = wheels
        self.speed  = 0

    def start(self):
        print(f"{self.brand} engine starts with a ROAR!")

    def accelerate(self, amount):
        self.speed += amount
        print(f"{self.brand} → {self.speed} km/h")

    def info(self):
        return f"{self.brand} · {self.wheels} wheels · {self.speed} km/h"


class Car(Vehicle):
    """A Car is a Vehicle with doors and a fuel type."""
    def __init__(self, brand, doors, fuel_type):
        super().__init__(brand, wheels=4)     # parent handles brand/wheels/speed
        self.doors     = doors
        self.fuel_type = fuel_type

    def open_doors(self):
        print(f"{self.brand}: opening {self.doors} doors")

    def info(self):
        # EXTEND parent info — don't rewrite it
        return super().info() + f" · {self.doors} doors · {self.fuel_type}"


class ElectricCar(Car):
    """An ElectricCar is a Car powered by a battery."""
    def __init__(self, brand, doors, battery_kwh):
        super().__init__(brand, doors, fuel_type="electric")
        self.battery_kwh  = battery_kwh
        self.charge_level = 100

    # OVERRIDE — electric cars start silently
    def start(self):
        print(f"{self.brand} powers on silently — battery at {self.charge_level}%")

    # New method only on ElectricCar
    def charge(self, amount):
        self.charge_level = min(100, self.charge_level + amount)
        print(f"{self.brand} charged to {self.charge_level}%")

    def info(self):
        return super().info() + f" · {self.battery_kwh} kWh · {self.charge_level}% charged"


# ── Create one of each ──────────────────────────────────────
honda = Car("Honda Civic", doors=4, fuel_type="petrol")
tesla = ElectricCar("Tesla Model 3", doors=4, battery_kwh=75)

# ── Start each — override behaviour is obvious ──────────────
honda.start()          # uses Vehicle.start()
tesla.start()          # uses ElectricCar.start() (override)

# ── Inherited methods work everywhere ───────────────────────
honda.accelerate(40)   # inherited from Vehicle
tesla.accelerate(80)   # inherited from Vehicle

# ── Level-specific methods ──────────────────────────────────
honda.open_doors()     # added in Car
tesla.charge(-10)      # added in ElectricCar (drops charge)

print()
print(honda.info())     # chained super().info() calls
print(tesla.info())     # chained super().info() calls, 3 levels deep
OUTPUT
Honda Civic engine starts with a ROAR! Tesla Model 3 powers on silently — battery at 100% Honda Civic → 40 km/h Tesla Model 3 → 80 km/h Honda Civic: opening 4 doors Tesla Model 3 charged to 90% Honda Civic · 4 wheels · 40 km/h · 4 doors · petrol Tesla Model 3 · 4 wheels · 80 km/h · 4 doors · electric · 75 kWh · 90% charged

Section 09

Practical Example 2 — Extending Auth: Developers Need an Extra OTP

A real-world pattern that shows the "extend, don't replace" principle in its cleanest form. Every Employee logs in with a username + password. But Developers need one extra step — an OTP (one-time password) — because they have access to production systems. The child class overrides auth() but reuses the parent's username/password check via super().auth(...) instead of rewriting it. Change the base auth logic later? Every subclass automatically inherits the fix.

class Employee:
    """Base auth for every employee — username + password only."""
    def __init__(self, username, password):
        self.username  = username
        self._password = password        # in real code: store a hash, not plaintext

    def auth(self, username, password):
        """Verify username + password. Returns True on success."""
        print(f"  [Employee.auth] checking username & password for {username!r}...")
        if username != self.username:
            print("    ✗ username mismatch")
            return False
        if password != self._password:
            print("    ✗ password mismatch")
            return False
        print("    ✓ base credentials OK")
        return True


class Developer(Employee):
    """Developers need everything the Employee needs — PLUS an OTP."""
    def __init__(self, username, password, otp_secret):
        super().__init__(username, password)     # let Employee handle username/password
        self._otp_secret = otp_secret            # developer-specific field

    # OVERRIDE — same method name, extra parameter (otp)
    def auth(self, username, password, otp):
        # Step 1 — DELEGATE the username/password check to the parent.
        #          If Employee.auth ever changes, we get the fix for free.
        if not super().auth(username, password):
            return False

        # Step 2 — the ADDITIONAL check that only developers need
        print(f"  [Developer.auth] checking OTP for {username!r}...")
        if otp != self._otp_secret:
            print("    ✗ OTP mismatch")
            return False
        print("    ✓ OTP verified")
        return True


# ── Regular employee: only username + password ──────────────
alice = Employee("alice", "MyPass123!")
print("Alice logs in:")
print("  → result:", alice.auth("alice", "MyPass123!"))
print()

# ── Developer: needs username + password + OTP ──────────────
bob = Developer("bob", "DevPass456!", otp_secret="774123")

print("Bob (dev) logs in with correct OTP:")
print("  → result:", bob.auth("bob", "DevPass456!", otp="774123"))
print()

print("Bob (dev) logs in with WRONG OTP — parent OK, child fails:")
print("  → result:", bob.auth("bob", "DevPass456!", otp="000000"))
print()

print("Bob (dev) logs in with WRONG password — parent short-circuits:")
print("  → result:", bob.auth("bob", "wrong", otp="774123"))
OUTPUT
Alice logs in: [Employee.auth] checking username & password for 'alice'... ✓ base credentials OK → result: True Bob (dev) logs in with correct OTP: [Employee.auth] checking username & password for 'bob'... ✓ base credentials OK [Developer.auth] checking OTP for 'bob'... ✓ OTP verified → result: True Bob (dev) logs in with WRONG OTP — parent OK, child fails: [Employee.auth] checking username & password for 'bob'... ✓ base credentials OK [Developer.auth] checking OTP for 'bob'... ✗ OTP mismatch → result: False Bob (dev) logs in with WRONG password — parent short-circuits: [Employee.auth] checking username & password for 'bob'... ✗ password mismatch → result: False

What Just Happened — Step by Step

🔒 Two-Factor Auth Split Between Parent and Child
Parent
Employee.auth(username, password) owns the base credential check. It has no idea OTPs exist. If we later add password hashing, rate-limiting, or logging here, every subclass benefits.
Child
Developer.auth(username, password, otp) takes one extra parameter. First line: super().auth(username, password) — reuse, don't rewrite. If that fails, we return early. If it passes, we run the developer-only OTP check.
Result
Username/password logic lives in exactly one place. Developer-specific OTP logic lives in exactly one place. No copy-paste. Change one; the other is unaffected.
Short-circuit
When Bob types the wrong password, the child's OTP check never runs — because super().auth() already returned False. Cheap and correct.
🏆
The Pattern to Remember

When a child needs everything the parent does + one extra step, override the method, call super().method(...) to reuse the parent's logic, then add your extra check. Don't re-implement what the parent already did — you'll only introduce bugs and cause the two versions to drift apart.


Section 10

isinstance() and issubclass() — Checking the Family

Once you have a hierarchy, you often want to ask: "Is this object one of the family?" Python gives you two built-in checks. Both understand inheritance — an ElectricCar counts as a Car and as a Vehicle.

tesla = ElectricCar("Tesla", 4, 75)

# ── isinstance(obj, ClassOrTuple) — is this object one of these types? ──
print(isinstance(tesla, ElectricCar))    # True — its own type
print(isinstance(tesla, Car))            # True — inherits from Car
print(isinstance(tesla, Vehicle))        # True — inherits from Vehicle
print(isinstance(tesla, object))         # True — everything is an object
print(isinstance(tesla, int))            # False — unrelated types

# ── issubclass(Cls, ParentOrTuple) — is Cls a subclass of Parent? ──
print(issubclass(ElectricCar, Car))       # True
print(issubclass(ElectricCar, Vehicle))   # True — walks the whole chain
print(issubclass(Car, ElectricCar))       # False — Car is not a subclass of its child

# ── Practical use — accept any Vehicle ─────────────────────
def describe_any_vehicle(v):
    if not isinstance(v, Vehicle):
        raise TypeError("Expected a Vehicle (or subclass)")
    return v.info()

print(describe_any_vehicle(tesla))         # works — Tesla is a Vehicle
🔑
Prefer isinstance Over type() ==

Never write type(obj) == Car — it rejects subclasses, silently breaking inheritance. isinstance(obj, Car) accepts Car and every subclass (like ElectricCar), which is almost always what you want.


Section 11

Common Mistakes (and Fixes)

MistakeWhat Goes WrongFix
Forgetting super().__init__() in child Parent's attributes are never set — AttributeError at first access Call super().__init__(...) as the first line of child __init__
Overriding a method but forgetting super() when you meant to extend Parent behaviour is silently lost — bugs hard to trace Decide: replace (no super()) or extend (call super().method(...))
Using type(obj) == Car Rejects ElectricCar and every other subclass Use isinstance(obj, Car) instead
Deep inheritance chains (5+ levels) Hard to trace where a method actually lives; MRO gets confusing Prefer shallow trees (2–3 levels). Consider composition ("has-a") instead of inheritance ("is-a")
Inheriting to "share code" when there's no IS-A relationship class InvoicePrinter(FileReader):an invoice printer isn't a file reader Refactor to composition: self.reader = FileReader(...)
Hard-coding the parent class name in super() Old Python 2 style: super(Car, self).__init__(...). Verbose and fragile. Use bare super().__init__(...) — Python 3 handles it
Multiple inheritance without understanding MRO Diamond problem — one parent's method silently wins over another's Prefer single inheritance + mixins. If you must, check ClassName.mro()

Section 12

Quick Reference

TaskSyntaxNotes
Declare inheritanceclass Car(Vehicle):Parent in parentheses
Call parent's methodsuper().method(...)No need to name the parent
Call parent's __init__super().__init__(...)Usually the first line of child __init__
Override a methodRedefine with the same name in the childChild's version replaces parent's
Extend a methodOverride + call super().method(...)Parent runs, then your addition
Check the familyisinstance(obj, Class)Accepts subclasses too
Check class ancestryissubclass(A, B)True if A inherits from B
Inspect lookup orderClassName.mro()List of classes Python searches
Multi-level chainclass C(B): ... class B(A):Automatic — C inherits from both
Multiple inheritanceclass D(A, B):Use sparingly — prefer mixins

Section 13

Golden Rules

🏆 Inheritance — Non-Negotiable Rules
1
Only inherit when the child is a parent — a true IS-A relationship. A Car is a Vehicle. A SavingsAccount is a BankAccount. An InvoicePrinter is not a FileReader. If IS-A doesn't fit, use composition (self.reader = FileReader()) instead.
2
Always call super().__init__(...) in the child's __init__ — usually as the very first line. Skipping it leaves parent attributes uninitialised and produces AttributeError at the first read.
3
In Python 3, use bare super(), not super(ClassName, self). Cleaner, and it survives class renames without breaking.
4
Decide consciously between replacing and extending when you override a method. Replacing = don't call super(). Extending = call super().method(...) and add your own logic before or after. Never accidentally lose the parent's behaviour.
5
Keep inheritance chains shallow — two or three levels at most. Deep hierarchies are hard to trace, hard to debug, and hard to refactor. Prefer flat trees plus composition.
6
Use isinstance(obj, Class), not type(obj) == Class. The former respects inheritance; the latter breaks polymorphism the moment someone adds a subclass.
7
Method Resolution Order (MRO) is deterministic — Python starts at the object's class and walks up. If you're ever unsure, run YourClass.mro() and read the list. The first class in the list that defines the method wins.
8
Prefer single inheritance plus small mixins over multiple inheritance. Mixins are single-purpose classes (LoggingMixin, SerialisableMixin) that add one capability without becoming another "IS-A" parent.
9
Change happens at the right layer. If a fix belongs to every subclass, put it in the parent. If it's specific to one child, put it in the child. Never patch the same thing in multiple children — that's the copy-paste bug that inheritance was invented to prevent.
10
Ask this before inheriting: "If the parent changes, do I want the child to change automatically?" If yes → inherit. If no → compose. Getting this wrong is the source of most "we tried OOP and it went wrong" stories.