Inheritance and Polymorphism
From the Classes(python) curriculum
TL;DR
You'll learn how Python classes share and override behavior through inheritance, and how polymorphism lets different objects respond to the same method call in their own way. You'll see super(), method overriding, and multiple inheritance with the MRO. By the end, you'll know when to inherit and when to avoid it.
1. The Mental Model
Inheritance lets a new class reuse code from an existing class instead of copying it. Polymorphism means you can call the same method name on different objects and each one does its own version of the thing. Think of a parent class as a template and child classes as specific fillings-in of that template. The core idea: write code against the shared behavior, not against specific types.
2. The Core Material
2.1 Inheritance basics: class Child(Parent)

Photo by Gustavo Fring on Pexels
When a class inherits from another, it gets all its attributes and methods for free. You only write what's different.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
def describe(self):
return f"I am {self.name}. {self.speak()}"
class Dog(Animal):
def speak(self):
return f"{self.name} barks"
class Cat(Animal):
def speak(self):
return f"{self.name} meows"
d = Dog("Rex")
c = Cat("Mimi")
print(d.describe()) # I am Rex. Rex barks
print(c.describe()) # I am Mimi. Mimi meows
Dog and Cat don't rewrite __init__ or describe. They just override speak. describe calls self.speak(), and Python looks up speak on the actual object's class first — that's polymorphism happening right there, through a plain method call.
2.2 super() — calling the parent without hardcoding it

Photo by RDNE Stock project on Pexels
When you override __init__ (or any method) but still want the parent's version to run too, use super() instead of naming the parent class directly.
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def annual_pay(self):
return self.salary * 12
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary) # reuse parent's init
self.bonus = bonus
def annual_pay(self):
base = super().annual_pay() # reuse parent's logic
return base + self.bonus
m = Manager("Ana", 5000, 3000)
print(m.annual_pay()) # 63000
Why super() and not Employee.__init__(self, name, salary)? Two reasons. First, if you ever rename the parent class, super() doesn't break. Second — and this is the real reason — super() respects the Method Resolution Order (MRO), which matters once multiple inheritance is involved. Hardcoding the parent name ignores the MRO and can call the wrong method or call something twice.
2.3 Multiple inheritance and the MRO

Photo by cottonbro studio on Pexels
Python allows a class to inherit from more than one parent. This is powerful but it's also where bugs creep in if you don't understand the MRO — the order Python searches classes to find a method.
Python uses the C3 linearization algorithm to compute this order. You can always check it directly:
class Flyer:
def move(self):
return "flying"
class Swimmer:
def move(self):
return "swimming"
class Duck(Flyer, Swimmer):
pass
d = Duck()
print(d.move()) # flying (Flyer comes first in the MRO)
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Flyer'>, <class 'Swimmer'>, <class 'object'>)
The MRO lists Duck, then Flyer, then Swimmer, then object. Python searches left to right through that list and stops at the first match. Swap the parent order — class Duck(Swimmer, Flyer) — and move() returns "swimming" instead. Order of base classes in the class definition changes behavior.
Where this gets genuinely useful is cooperative multiple inheritance, where every class calls super() and the MRO chains them together like a relay:
class Base:
def greet(self):
return "Base"
class A(Base):
def greet(self):
return "A -> " + super().greet()
class B(Base):
def greet(self):
return "B -> " + super().greet()
class C(A, B):
def greet(self):
return "C -> " + super().greet()
print(C().greet())
# C -> A -> B -> Base
print(C.__mro__)
# (C, A, B, Base, object)
Notice A.greet calls super().greet(), but in C's MRO that super() resolves to B, not Base directly — because the MRO is C, A, B, Base, object. This is why super() isn't "my parent class" — it's "the next class in this instance's MRO." That distinction is the single most misunderstood thing about multiple inheritance in Python.
graph TD
C["class C(A, B)"] --> A["class A(Base)"]
A --> B["class B(Base)"]
B --> Base["class Base"]
Base --> Obj["object"]
2.4 Polymorphism beyond inheritance: duck typing

Photo by Markus Winkler on Pexels
Python doesn't require a shared parent class for polymorphism to work — it just needs matching method names. This is duck typing: "if it walks like a duck and quacks like a duck, treat it as a duck."
class Circle:
def __init__(self, r):
self.r = r
def area(self):
return 3.14159 * self.r ** 2
class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
shapes = [Circle(3), Square(4)]
for s in shapes:
print(round(s.area(), 2))
# 28.27
# 16
Circle and Square share no base class at all, yet the loop treats them identically because both expose .area(). For formal contracts (e.g., "every shape MUST have .area()"), use an Abstract Base Class:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
...
class Triangle(Shape):
def __init__(self, base, height):
self.base, self.height = base, height
def area(self):
return 0.5 * self.base * self.height
# Shape() # TypeError: can't instantiate abstract class
t = Triangle(6, 4)
print(t.area()) # 12.0
ABC with @abstractmethod makes Python enforce that subclasses implement area() — forgetting it raises a TypeError at instantiation time, not silently later when you call a missing method.
3. Worked Example
Let's build a small payment system that combines everything: inheritance, super(), overriding, and polymorphic dispatch over a list.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
def __init__(self, amount):
self.amount = amount
@abstractmethod
def fee(self):
"""Each payment type defines its own fee."""
def total(self):
return self.amount + self.fee()
def __repr__(self):
return f"{type(self).__name__}(amount={self.amount}, total={self.total():.2f})"
class CreditCard(PaymentMethod):
def fee(self):
return self.amount * 0.029 + 0.30 # percentage + fixed
class BankTransfer(PaymentMethod):
def fee(self):
return 0.50 # flat fee
class CryptoWallet(PaymentMethod):
FEE_RATE = 0.01
def __init__(self, amount, network_congestion=1.0):
super().__init__(amount) # reuse parent init
self.network_congestion = network_congestion
def fee(self):
return self.amount * self.FEE_RATE * self.network_congestion
payments = [
CreditCard(100),
BankTransfer(100),
CryptoWallet(100, network_congestion=1.5),
]
for p in payments:
print(p)
grand_total = sum(p.total() for p in payments)
print(f"Grand total: {grand_total:.2f}")
Output:
CreditCard(amount=100, total=103.20)
BankTransfer(amount=100, total=100.50)
CryptoWallet(amount=100, total=101.50)
Grand total: 305.20
Walk through what happened: PaymentMethod is abstract — you can never do PaymentMethod(100) directly because fee() has no body. Each subclass supplies its own fee(), and total() (defined once, in the parent) automatically uses whichever fee() belongs to the actual object — that's polymorphism inside the parent class itself. CryptoWallet needed an extra constructor argument, so it overrode __init__ but called super().__init__(amount) to avoid duplicating the assignment logic. The loop for p in payments: print(p) doesn't know or care which subclass each p is — it just works, because every subclass honors the same interface shape.
4. Production Pitfalls & Best Practices
4.1 Real-World Best Practices
- Favor composition over inheritance when the relationship isn't truly "is-a." A
Carhas anEngine; it isn't anEngine. Model that with an attribute, not a base class. - Use
ABC+@abstractmethodto define contracts when you want Python to catch missing implementations early, especially in plugin-style architectures. - Always call
super().__init__(...)in overridden__init__, even if you think you don't need parent state — future maintainers adding parent fields will thank you. - Keep override signatures compatible — same parameter names/order as the parent — so callers using the parent type can swap in any subclass without surprises (the Liskov Substitution Principle).
- Use
isinstance()sparingly — rely on duck typing / polymorphic dispatch for behavior, and reserveisinstancechecks for genuinely structural decisions (e.g., serialization). - Document the MRO-sensitive parts of any multi-inheritance class — a comment saying "this mixin expects
super().save()to exist" saves hours of debugging. - Prefer mixins with narrow, single-purpose behavior (e.g.,
LoggingMixin,JSONSerializableMixin) over deep inheritance chains.
4.2 Common Bugs & Anti-Patterns
1. Forgetting super().__init__() loses parent state silently.
# BAD
class Admin(User):
def __init__(self, name, level):
self.level = level # self.name never set!
# FIXED
class Admin(User):
def __init__(self, name, level):
super().__init__(name)
self.level = level
2. Mutable default arguments shared across subclasses.
# BAD
class Cart:
def __init__(self, items=[]): # one shared list for ALL instances!
self.items = items
# FIXED
class Cart:
def __init__(self, items=None):
self.items = items if items is not None else []
3. Deep inheritance chains that nobody can trace.
# BAD: 5 levels deep, overriding the same method 3 times
class A: ...
class B(A): ...
class C(B): ...
class D(C): ...
class E(D): ... # good luck finding where behavior actually comes from
# FIXED: flatten with composition or a single mixin
class LoggingMixin:
def log(self, msg): print(f"[{type(self).__name__}] {msg}")
class E(A, LoggingMixin):
...
4. Hardcoding the parent class name instead of super() breaks under multiple inheritance.
# BAD
class C(A, B):
def greet(self):
return A.greet(self) # skips B entirely, ignores MRO
# FIXED
class C(A, B):
def greet(self):
return super().greet() # follows the real MRO chain
4.3 When to Use What (Decision Matrix)
| Situation | Approach |
|---|---|
| Subclasses share state + behavior, true "is-a" relationship | Single inheritance with super() |
| Need to enforce "every subclass MUST implement X" | ABC + @abstractmethod |
| Objects from unrelated classes need interchangeable methods | Duck typing (no shared base needed) |
| Want to add one reusable capability (logging, serialization) to many unrelated classes | Mixin class |
| Combining 2+ independent behaviors into one class | Multiple inheritance, but check __mro__ |
| Relationship is "has-a" (Car has Engine), not "is-a" | Composition, not inheritance |
5. Now Try It
Build this right here, in a Python file or REPL:
Create an abstract class Notifier with an abstract method send(message). Create two subclasses: EmailNotifier (prints "Email: {message}") and SMSNotifier (prints "SMS: {message}", but first override __init__ to also store a phone_number, calling super().__init__() if Notifier has one). Then write a function broadcast(notifiers, message) that loops over a list of mixed notifier objects and calls .send(message) on each — polymorphically, without any isinstance checks. Test it with a list containing one of each.
Success looks like: running broadcast([EmailNotifier(), SMSNotifier("555-1234")], "Hello!") prints both "Email: Hello!" and "SMS: Hello!" using the exact same loop body, and attempting Notifier() directly raises a TypeError.
Frequently asked about Inheritance and Polymorphism
Study this next
Get the full Classes(python) curriculum
Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.
Save this course free