All 57 lessons

Lesson 26 of 57 · Files, Errors and Modules

Python Inheritance, Polymorphism and Special Methods

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

In the last lesson you built single classes. Real systems contain families of related things. FreshBasket has standard orders, express orders and subscription orders; they share most behaviour (an id, a list of items, a total) but differ in a few details (the delivery fee, the promised time). Writing three nearly identical classes would repeat code, and fixing a bug would mean fixing it three times.

Inheritance solves this: a child class automatically receives everything from a parent class and then adds or changes only what is different. Closely related is polymorphism: different classes offering the same method name, so that code can treat them all alike. Finally, special methods (also called dunder methods, from "double underscore") let your objects work with Python's built-in syntax: +, ==, len(), for loops and sorted. This lesson ties them together and ends with a note on a design choice that experienced programmers care about: composition versus inheritance.

Worked examples

Example 1: A parent and a child

Question: Create an express order that behaves like a normal order with a higher fee.

class Order:
    BASE_FEE = 25

    def __init__(self, order_id, subtotal):
        self.order_id = order_id
        self.subtotal = subtotal

    def delivery_fee(self):
        return 0 if self.subtotal >= 499 else self.BASE_FEE

    def total(self):
        return self.subtotal + self.delivery_fee()

class ExpressOrder(Order):
    EXPRESS_SURCHARGE = 30

    def delivery_fee(self):
        return super().delivery_fee() + self.EXPRESS_SURCHARGE

a = Order("O1", 300)
b = ExpressOrder("O2", 300)
c = ExpressOrder("O3", 800)
print(a.total(), b.total(), c.total())
print(isinstance(b, Order), isinstance(a, ExpressOrder))
print(issubclass(ExpressOrder, Order))

Output

325 355 830
True False
True

How it works: class ExpressOrder(Order) makes Order the parent. The child inherits __init__ and total unchanged, and overrides delivery_fee. Inside the override, super().delivery_fee() calls the parent's version, so the child extends the rule instead of copying it. Notice that total() in the parent automatically uses the child's delivery_fee when called on an express order.

Reading the result: The standard order costs ₹325, the express one ₹355 (fee plus surcharge), and a large express order ₹830 because the base fee is waived but the surcharge remains. Every ExpressOrder is an Order (True), but not the other way round.

Example 2: Extending __init__ with super

Question: Give the child extra data and keep the parent's setup.

class SubscriptionOrder(Order):
    def __init__(self, order_id, subtotal, every_days):
        super().__init__(order_id, subtotal)
        self.every_days = every_days

    def delivery_fee(self):
        return 0

    def next_due(self, last_date):
        from datetime import timedelta
        return last_date + timedelta(days=self.every_days)

from datetime import date
s = SubscriptionOrder("S1", 250, 7)
print(s.order_id, s.subtotal, s.every_days)
print(s.total())
print(s.next_due(date(2026, 6, 1)))
print(SubscriptionOrder.__mro__[0].__name__, [k.__name__ for k in SubscriptionOrder.__mro__])

Output

S1 250 7
250
2026-06-08
SubscriptionOrder ['SubscriptionOrder', 'Order', 'object']

How it works: When a child defines its own __init__, it must call super().__init__(...) to run the parent's setup, otherwise the inherited attributes never get created. The __mro__ (method resolution order) lists the classes Python searches for a method: the child first, then its parents, ending with object.

Reading the result: The subscription order has a total of ₹250 with no fee, and the next delivery after 1 June is on 2026-06-08. The search order is ['SubscriptionOrder', 'Order', 'object'].

Example 3: Polymorphism

Question: Process different order types with one piece of code.

orders = [Order("O1", 300), ExpressOrder("O2", 300), SubscriptionOrder("S1", 250, 7), ExpressOrder("O3", 900)]

for o in orders:
    print(f"{o.order_id:<4}{type(o).__name__:<18}fee={o.delivery_fee():>3}  total={o.total():>5}")

revenue = sum(o.total() for o in orders)
print(revenue)

Output

O1  Order             fee= 25  total=  325
O2  ExpressOrder      fee= 55  total=  355
S1  SubscriptionOrder fee=  0  total=  250
O3  ExpressOrder      fee= 30  total=  930
1860

How it works: The loop calls o.delivery_fee() and o.total() without caring which class o belongs to; each object responds in its own way. This is polymorphism ("many forms"). It lets you add a new order type later without changing the loop at all.

Reading the result: The four orders brought in ₹1860 in total. The loop never needed an if type(o) == ... check, which is the sign of a good object-oriented design.

Example 4: Duck typing

Question: Does polymorphism need inheritance in Python?

class Rider:
    def __init__(self, name):
        self.name = name
    def describe(self):
        return f"Rider {self.name}"

class DarkStore:
    def __init__(self, city):
        self.city = city
    def describe(self):
        return f"Dark store in {self.city}"

things = [Rider("Karan"), DarkStore("Pune"), Rider("Diya")]
print([t.describe() for t in things])

print(len("abc"), len([1, 2]), len({"a": 1}), len(range(5)))
print(hasattr(things[0], "describe"), hasattr(things[0], "fly"))

Output

['Rider Karan', 'Dark store in Pune', 'Rider Diya']
3 2 1 5
True False

How it works: Python follows duck typing: "if it walks like a duck and quacks like a duck, it is a duck". The two classes are unrelated, but both have describe(), so the list comprehension works for both. Built-ins behave the same way: len works on anything that knows its length. Inheritance is one way to share code, not a requirement for polymorphism.

Reading the result: The unrelated classes responded to the same call: ['Rider Karan', 'Dark store in Pune', 'Rider Diya']. Use hasattr or a try/except AttributeError to check capabilities when needed.

Example 5: Abstract base classes

Question: Force subclasses to implement a method.

from abc import ABC, abstractmethod

class Discount(ABC):
    @abstractmethod
    def apply(self, amount):
        """Return the discounted amount."""

class PercentOff(Discount):
    def __init__(self, pct):
        self.pct = pct
    def apply(self, amount):
        return amount * (1 - self.pct / 100)

class FlatOff(Discount):
    def __init__(self, rupees):
        self.rupees = rupees
    def apply(self, amount):
        return max(0, amount - self.rupees)

try:
    Discount()
except TypeError as e:
    print("TypeError:", e)

class Broken(Discount):
    pass

try:
    Broken()
except TypeError as e:
    print("TypeError:", str(e)[:44])

for d in [PercentOff(10), FlatOff(50), FlatOff(500)]:
    print(type(d).__name__, d.apply(300))

Output

TypeError: Can't instantiate abstract class Discount without an implementation for abstract method 'apply'
TypeError: Can't instantiate abstract class Broken with
PercentOff 270.0
FlatOff 250
FlatOff 0

How it works: A class inheriting from ABC can declare @abstractmethods. You cannot create objects from it, nor from a child that forgets to implement every abstract method. This documents the contract ("every discount must have apply") and fails early if a subclass is incomplete.

Reading the result: A 10% discount turned ₹300 into 270.0, a flat ₹50 into 250, and a flat ₹500 off cannot go below 0.

Example 6: Comparison and equality methods

Question: Make objects comparable and sortable.

from functools import total_ordering

@total_ordering
class Money:
    def __init__(self, rupees):
        self.rupees = rupees

    def __eq__(self, other):
        return self.rupees == other.rupees

    def __lt__(self, other):
        return self.rupees < other.rupees

    def __repr__(self):
        return f"Money({self.rupees})"

    def __hash__(self):
        return hash(self.rupees)

a, b, c = Money(500), Money(250), Money(500)
print(a == c, a is c, a > b, b <= c)
print(sorted([a, b, Money(900), Money(100)]))
print(max(a, b, Money(75)))
print(len({a, c, b}))

Output

True False True True
[Money(100), Money(250), Money(500), Money(900)]
Money(500)
2

How it works: __eq__ defines == and __lt__ defines <. The @total_ordering decorator derives >, <= and >= from them, so you write only two. Because sorted and max use <, your objects now work with them. Defining __eq__ removes the default hash, so we also define __hash__ to allow use in sets and as dictionary keys.

Reading the result: The two ₹500 objects are equal but distinct objects (a == c is True, a is c is False). Sorting worked, and a set of three objects contains only 2 distinct values.

Example 7: Arithmetic operators

Question: Add two money values with +.

class Money:
    def __init__(self, rupees):
        self.rupees = rupees

    def __add__(self, other):
        return Money(self.rupees + other.rupees)

    def __mul__(self, k):
        return Money(self.rupees * k)

    __rmul__ = __mul__

    def __bool__(self):
        return self.rupees != 0

    def __repr__(self):
        return f"Money({self.rupees})"

total = Money(250) + Money(300) + Money(50)
print(total)
print(Money(100) * 3, 3 * Money(100))
print(sum([Money(10), Money(20)], start=Money(0)))
print(bool(Money(0)), bool(Money(5)))

Output

Money(600)
Money(300) Money(300)
Money(30)
False True

How it works: a + b calls a.__add__(b), and a * 3 calls a.__mul__(3). For 3 * a, Python first tries int.__mul__, fails, and falls back to a.__rmul__(3), so we alias it. __bool__ decides truthiness. sum needs a starting value because the default of 0 cannot be added to a Money.

Reading the result: Adding three amounts gave Money(600), and scaling gave Money(300) either way round. Operator overloading is what makes pandas, NumPy and date arithmetic feel natural.

Example 8: Container behaviour

Question: Make a basket object that supports len, indexing, in and iteration.

class Basket:
    def __init__(self, items=None):
        self._items = list(items or [])

    def __len__(self):
        return len(self._items)

    def __getitem__(self, i):
        return self._items[i]

    def __contains__(self, item):
        return item in self._items

    def __iter__(self):
        return iter(self._items)

    def __repr__(self):
        return f"Basket({self._items})"

b = Basket(["Milk", "Eggs", "Bread"])
print(len(b), b[0], b[-1], b[0:2])
print("Eggs" in b, "Tea" in b)
for item in b:
    print(item.upper(), end=" ")
print()
print(sorted(b, reverse=True), list(b))

Output

3 Milk Bread ['Milk', 'Eggs']
True False
MILK EGGS BREAD 
['Milk', 'Eggs', 'Bread'] ['Milk', 'Eggs', 'Bread']

How it works: Implementing __len__, __getitem__, __contains__ and __iter__ makes your class act like a built-in collection. Slicing works because __getitem__ passes the slice to the list. These four methods are the "container protocol" and explain how libraries create list-like objects.

Reading the result: The basket reports 3 items, supports membership ("Eggs" in b is True), and works in a for loop and with sorted.

Example 9: Composition

Question: Build an order that has items instead of being a kind of item.

class Item:
    def __init__(self, name, price, qty):
        self.name, self.price, self.qty = name, price, qty

    @property
    def line_total(self):
        return self.price * self.qty

class BasketOrder:
    def __init__(self, order_id, items, discount=None):
        self.order_id = order_id
        self.items = items
        self.discount = discount          # an object with .apply(amount)

    def subtotal(self):
        return sum(i.line_total for i in self.items)

    def total(self):
        amount = self.subtotal()
        return self.discount.apply(amount) if self.discount else amount

items = [Item("Milk", 68, 2), Item("Paneer", 95, 1), Item("Eggs", 96, 1)]
plain = BasketOrder("O1", items)
promo = BasketOrder("O2", items, discount=PercentOff(10))
print(plain.total(), promo.total())
print(BasketOrder("O3", items, FlatOff(100)).total())

Output

327 294.3
227

How it works: Instead of inheriting from Item or Discount, BasketOrder contains them. This is composition, summarised as "has-a" instead of "is-a". The discount strategy can be swapped without touching the order class. Many designers recommend "prefer composition over inheritance" because deep inheritance trees become rigid and confusing.

Reading the result: The undiscounted order totals ₹327, the 10% promotion brings it to ₹294.3, and a flat ₹100 off gives ₹227.

Example 10: Using classes on real data

Question: Wrap delivery data in an object that sorts and summarises stores.

import csv

class StoreStats:
    def __init__(self, store_id):
        self.store_id = store_id
        self.minutes = []

    def add(self, m):
        self.minutes.append(m)

    @property
    def avg(self):
        return sum(self.minutes) / len(self.minutes)

    def __lt__(self, other):
        return self.avg < other.avg

    def __repr__(self):
        return f"StoreStats({self.store_id}, n={len(self.minutes)}, avg={self.avg:.1f})"

by_store = {}
with open("orders.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["delivery_minutes"]:
            by_store.setdefault(row["store_id"], StoreStats(row["store_id"])).add(float(row["delivery_minutes"]))

ranked = sorted(by_store.values())
print(ranked[0])
print(ranked[-1])
print(len(ranked))

Output

StoreStats(S08, n=430, avg=23.0)
StoreStats(S02, n=581, avg=24.0)
11

How it works: StoreStats collects delivery times per store and defines __lt__ so that sorted ranks stores by average. Because the comparison is part of the class, the calling code stays tiny.

Reading the result: The fastest store is S08 at 23.0 minutes, and the slowest is S02 at 24.0 minutes, across 11 stores.

Common mistakes

1. Forgetting super().__init__() so the parent attributes are missing.

class Child(Order):
    def __init__(self):
        pass

Child().total()

Output

Traceback (most recent call last):
  File "example.py", line 5, in <module>
    Child().total()
    ~~~~~~~~~~~~~^^
  File "example.py", line 12, in total
    return self.subtotal + self.delivery_fee()
           ^^^^^^^^^^^^^
AttributeError: 'Child' object has no attribute 'subtotal'

2. Deep inheritance trees that nobody can follow; prefer composition.

3. Defining __eq__ without __hash__, making objects unhashable.

4. Using isinstance chains instead of polymorphism.

5. Overriding a method with a different signature, which breaks code written for the parent.

Practice questions

Practice 1 (Easy): Animal sounds

Create Animal with a speak() method returning "...". Create Dog and Cat subclasses that override it. Call speak() on a list of animals.

Show solution
class Animal:
    def speak(self):
        return "..."

class Dog(Animal):
    def speak(self):
        return "Woof"

class Cat(Animal):
    def speak(self):
        return "Meow"

print([a.speak() for a in [Dog(), Cat(), Animal()]])

Output

['Woof', 'Meow', '...']

Each object answers in its own way: ['Woof', 'Meow', '...'].

Practice 2 (Medium): A sortable product

Create a Product class with name and price, sortable by price using __lt__, with a readable __repr__. Sort four products and print the cheapest.

Show solution
class Product:
    def __init__(self, name, price):
        self.name, self.price = name, price
    def __lt__(self, other):
        return self.price < other.price
    def __repr__(self):
        return f"Product({self.name!r}, {self.price})"

items = [Product("Milk", 68), Product("Curd", 45), Product("Eggs", 96), Product("Paneer", 95)]
print(sorted(items)[0], min(items))

Output

Product('Curd', 45) Product('Curd', 45)

sorted and min only need __lt__. The cheapest product is Product('Curd', 45).

Practice 3 (Hard): Pricing strategies

Build a PriceRule base class with an abstract price_for(order_total) method, then Standard (no change), PrimeMember (5% off) and FestiveSale (10% off, but never below ₹100). Show the price for a ₹80 and ₹800 order under each rule.

Show solution
from abc import ABC, abstractmethod

class PriceRule(ABC):
    @abstractmethod
    def price_for(self, order_total):
        ...

class Standard(PriceRule):
    def price_for(self, order_total):
        return order_total

class PrimeMember(PriceRule):
    def price_for(self, order_total):
        return round(order_total * 0.95, 2)

class FestiveSale(PriceRule):
    def price_for(self, order_total):
        return max(100, round(order_total * 0.90, 2))

for rule in (Standard(), PrimeMember(), FestiveSale()):
    print(f"{type(rule).__name__:<12}", rule.price_for(80), rule.price_for(800))

Output

Standard     80 800
PrimeMember  76.0 760.0
FestiveSale  100 720.0

All three rules share one interface, so a checkout function can accept any of them. Under the festive sale, the small order is lifted to the ₹100 floor while the large one becomes ₹720.0.

Key takeaways

  • class Child(Parent) inherits everything; override only what differs.
  • Use super() to extend the parent's behaviour, especially in __init__.
  • Polymorphism lets one piece of code work with many classes that share method names.
  • Abstract base classes enforce an interface and fail early.
  • Special methods (__eq__, __lt__, __add__, __len__, __iter__) make objects work with Python syntax and built-ins.
  • Define __hash__ when you define __eq__ if objects need to go in sets or dict keys.
  • Prefer composition ("has-a") over deep inheritance ("is-a") when designing larger systems.

Frequently asked questions

When should I use inheritance rather than composition?

Use inheritance for a true "is-a" relationship where the child can stand in for the parent everywhere. Use composition when an object merely uses or contains another, or when behaviour should be swappable.

What does super() actually do?

It gives access to the next class in the method resolution order, usually the parent, so you can call its version of a method.

What is multiple inheritance?

A class can have more than one parent: class C(A, B). Python resolves methods using the MRO. It is powerful but easy to misuse, so keep it for simple "mixin" classes.

Why do some methods have double underscores?

They are hooks that Python calls automatically for syntax and built-ins: + calls __add__, len() calls __len__. They are called "special" or "dunder" methods, and you rarely call them directly.

Is isinstance bad?

Not at all; it is useful for validation. Long chains of isinstance checks that choose behaviour are the smell, because polymorphism would remove them.

Do I need all this to use pandas?

No, but knowing it lets you read library source code, subclass tools such as Exception or unittest.TestCase, and design cleaner pipelines.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions