The Story That Explains Inheritance
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.
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.
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.
| 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 |
| 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
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.
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.
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)
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.
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).
Python checks ElectricCar first, then Car, then Vehicle. The first match wins — that's why overrides in a child always beat the parent version.
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.
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())
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.
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().
Types of Inheritance
class Car(Vehicle):Vehicle → Car → ElectricCarVehicle → {Car, Bike, Truck}class HybridCar(Petrol, Electric):
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).
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
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"))
What Just Happened — Step by Step
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.
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.
super().auth() already returned False. Cheap and correct.
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.
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
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.
Common Mistakes (and Fixes)
| Mistake | What Goes Wrong | Fix |
|---|---|---|
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() |
Quick Reference
| Task | Syntax | Notes |
|---|---|---|
| Declare inheritance | class Car(Vehicle): | Parent in parentheses |
| Call parent's method | super().method(...) | No need to name the parent |
| Call parent's __init__ | super().__init__(...) | Usually the first line of child __init__ |
| Override a method | Redefine with the same name in the child | Child's version replaces parent's |
| Extend a method | Override + call super().method(...) | Parent runs, then your addition |
| Check the family | isinstance(obj, Class) | Accepts subclasses too |
| Check class ancestry | issubclass(A, B) | True if A inherits from B |
| Inspect lookup order | ClassName.mro() | List of classes Python searches |
| Multi-level chain | class C(B): ... class B(A): | Automatic — C inherits from both |
| Multiple inheritance | class D(A, B): | Use sparingly — prefer mixins |
Golden Rules
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.
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.
super(), not super(ClassName, self).
Cleaner, and it survives class renames without breaking.
super(). Extending = call
super().method(...) and add your own logic before or after. Never accidentally lose
the parent's behaviour.
isinstance(obj, Class), not type(obj) == Class.
The former respects inheritance; the latter breaks polymorphism the moment someone adds a
subclass.
YourClass.mro() and read the list. The
first class in the list that defines the method wins.
LoggingMixin, SerialisableMixin)
that add one capability without becoming another "IS-A" parent.