Skip to content

Inheritance and Polymorphism

💡
Before you start

You need Python installed, and to know how to run a .py file. If python3 --version in a terminal prints a version number, you are ready. If it does not — or if you have never opened a terminal — do Getting Started with Python first; it installs Python and runs your first program, and takes about fifteen minutes. Nothing else is needed: no account, no payment, no extra software.

What is Inheritance?

Inheritance lets you create a new class based on an existing one. The new class (child) inherits all attributes and methods from the existing class (parent), and can add or override them.

# Parent class (base class)
class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

    def speak(self):
        print(f"{self.name} says: {self.sound}!")

    def info(self):
        print(f"I'm {self.name}, an animal.")

# Child class (inherits from Animal)
class Dog(Animal):
    def fetch(self, item):
        print(f"{self.name} fetches the {item}!")

# Dog inherits __init__, speak, and info from Animal
rex = Dog("Rex", "Woof")
rex.speak()       # Rex says: Woof!
rex.info()        # I'm Rex, an animal.
rex.fetch("ball") # Rex fetches the ball!

Overriding Methods

Child classes can replace parent methods with their own version:

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        print(f"{self.name} makes a sound.")

    def info(self):
        return f"{self.name} (Animal)"

class Cat(Animal):
    def speak(self):                      # Override parent method
        print(f"{self.name} says: Meow!")

    def info(self):                       # Override parent method
        return f"{self.name} (Cat)"

cat = Cat("Whiskers")
cat.speak()      # Whiskers says: Meow! (uses Cat's version)
print(cat.info())  # Whiskers (Cat)

Using super()

super() calls the parent class's method, letting you extend rather than completely replace it:

class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

    def info(self):
        return f"{self.year} {self.make} {self.model}"

class ElectricCar(Vehicle):
    def __init__(self, make, model, year, battery_kwh):
        super().__init__(make, model, year)    # Call parent's __init__
        self.battery_kwh = battery_kwh         # Add new attribute

    def info(self):
        base = super().info()                  # Get parent's info
        return f"{base} (Electric, {self.battery_kwh}kWh)"

tesla = ElectricCar("Tesla", "Model 3", 2024, 75)
print(tesla.info())   # 2024 Tesla Model 3 (Electric, 75kWh)
💡
Always use super() in __init__

When your child class has its own __init__, always call super().__init__(...) first to ensure the parent is properly initialized. Forgetting this is a common source of bugs.

Polymorphism

Polymorphism means different classes can be used through the same interface. If multiple classes have the same method name, you can call it without knowing which class the object belongs to:

class Shape:
    def area(self):
        raise NotImplementedError("Subclasses must implement area()")

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        import math
        return math.pi * self.radius ** 2

class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height

# Polymorphism in action — same method, different behavior
shapes = [
    Rectangle(10, 5),
    Circle(7),
    Triangle(8, 6)
]

for shape in shapes:
    print(f"{type(shape).__name__}: area = {shape.area():.2f}")

Checking Inheritance

class Animal: pass
class Dog(Animal): pass
class Cat(Animal): pass

rex = Dog()

print(isinstance(rex, Dog))       # True
print(isinstance(rex, Animal))    # True  (Dog inherits from Animal)
print(isinstance(rex, Cat))       # False

print(issubclass(Dog, Animal))    # True
print(issubclass(Cat, Dog))       # False

Composition vs Inheritance

Not everything should use inheritance. Sometimes it's better for a class to contain another class (composition) rather than inherit from it:

# Inheritance: "is a" relationship
# A Dog IS an Animal — inheritance makes sense
class Dog(Animal): pass

# Composition: "has a" relationship
# A Car HAS an Engine — composition makes sense
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

    def start(self):
        print(f"Engine ({self.horsepower}hp) started!")

class Car:
    def __init__(self, make, engine):
        self.make = make
        self.engine = engine     # Car HAS an Engine

    def start(self):
        print(f"Starting {self.make}...")
        self.engine.start()

engine = Engine(200)
car = Car("Toyota", engine)
car.start()
# Starting Toyota...
# Engine (200hp) started!
⚠️
Favor composition over deep inheritance

Deep inheritance chains (3+ levels) become hard to understand and maintain. Ask yourself: "Is this an 'is-a' relationship?" If not, use composition.

Practical Example: Notification System

class Notification:
    def __init__(self, recipient, message):
        self.recipient = recipient
        self.message = message

    def send(self):
        raise NotImplementedError

    def __str__(self):
        return f"To: {self.recipient} — {self.message}"

class EmailNotification(Notification):
    def __init__(self, recipient, message, subject):
        super().__init__(recipient, message)
        self.subject = subject

    def send(self):
        print(f"Sending email to {self.recipient}")
        print(f"  Subject: {self.subject}")
        print(f"  Body: {self.message}")

class SMSNotification(Notification):
    def send(self):
        # SMS messages are limited to 160 chars
        msg = self.message[:160]
        print(f"Sending SMS to {self.recipient}: {msg}")

class PushNotification(Notification):
    def __init__(self, recipient, message, app_name):
        super().__init__(recipient, message)
        self.app_name = app_name

    def send(self):
        print(f"Push [{self.app_name}] to {self.recipient}: {self.message}")

# Polymorphism — send all notifications the same way
notifications = [
    EmailNotification("alice@example.com", "Your order shipped!", "Order Update"),
    SMSNotification("+1234567890", "Your code is 482910"),
    PushNotification("alice_device", "New message received", "ChatApp")
]

for notification in notifications:
    notification.send()
    print()

Now Do It Yourself: Five Steps

Inheritance lets one class build on another instead of repeating it. You will make a general class, a specialised one, and see exactly what each contributes. Every output below is exactly what Python printed.

1
Make the general class first

Go: open a terminal, run cd ~, and create a file called animals.py.

Do: type these six lines and run python3 animals.py.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + " makes a sound"

print(Animal("Generic").speak())

You should see: Generic makes a sound. Nothing new yet — this is an ordinary class, and it is the thing the next step will build on.

If not: if you get IndentationError, both methods must be indented four spaces inside the class. If NameError: name 'Animal' is not defined, the print is above the class instead of below it — Python reads a file top to bottom, so a class must be defined before it is used.

2
Inherit, and change only what differs

Go: same file, add to the bottom.

Do: add these four lines and run it.

class Dog(Animal):
    def speak(self):
        return self.name + " says woof"

print(Dog("Rex").speak())

You should see: Generic makes a sound then Rex says woof. Dog(Animal) means "a Dog is an Animal". You never wrote __init__ for Dog, yet Dog("Rex") worked — it was inherited. Only speak was replaced, and replacing an inherited method is called overriding.

If not: TypeError: Dog() takes no arguments means you wrote class Dog: without (Animal), so it inherited nothing and has no __init__ at all. The brackets after the class name are the entire mechanism.

3
Prove the relationship is real

Go: same file, add one line at the bottom.

Do: add this and run it.

print(isinstance(Dog("Rex"), Animal))

You should see: True. A Dog genuinely is an Animal as far as Python is concerned, which is what lets you write code that accepts any animal and pass it a dog.

If not: False means Dog is not actually inheriting — check for the (Animal) in the class line. This test is the quickest way to confirm a hierarchy is wired the way you think it is.

4
Add data of your own with super()

Go: same file, find the Dog class.

Do: add an __init__ to it, keeping the speak method you already wrote, so the whole class reads as below. Then print both values.

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed
    def speak(self):
        return self.name + " says woof"

d = Dog("Rex", "Collie")
print(d.name, d.breed)

You should see: Rex Collie. super().__init__(name) runs the parent's setup, which is what stores name; then you add the part that is specific to a dog. Do the parent's work first, your own second.

If not: if Rex starts saying makes a sound again, you dropped the speak method while adding __init__ — a class definition is the whole class, so anything you leave out is gone. AttributeError: 'Dog' object has no attribute 'name' means the super() line is missing instead. Writing your own __init__ completely replaces the parent's — it is not added to it — so if you do not call super(), nothing the parent set up ever happens.

5
Treat different animals the same way

Go: same file, replace the bottom.

Do: add a Cat and loop over a mixed list.

class Cat(Animal):
    def speak(self):
        return self.name + " says meow"

for a in [Animal("Generic"), Dog("Rex", "Collie"), Cat("Tom")]:
    print(a.speak())

You should see: three lines — Generic makes a sound, Rex says woof, Tom says meow. One loop, one method name, three different behaviours. The loop does not know or care which kind each one is; that is the reason inheritance is worth the effort.

If not: if every line says "makes a sound", the subclasses spell the method differently from the parent — Speak is not speak. An override only overrides when the name matches exactly, and Python will not warn you, because adding a new method is perfectly legal.

🎉
Check yourself before moving on

Without scrolling up: a subclass defines its own __init__ and then self.name raises AttributeError. What is missing? Answer: the super().__init__(name) call. Your __init__ replaces the parent's rather than adding to it.

Now do it without the page: add a Bird that overrides speak and also takes a can_fly value in its own __init__. It needs both step 4's super() and step 2's override in the same class.

Summary

  • Inheritance creates child classes that inherit from parent classes: class Child(Parent)
  • Override methods by redefining them in the child class
  • super() calls the parent's version of a method
  • Polymorphism lets different classes respond to the same method call differently
  • Use isinstance() and issubclass() to check relationships
  • Prefer composition ("has-a") over inheritance ("is-a") when the relationship isn't clear
🎉
Inheritance and polymorphism mastered!

You now understand the core pillars of OOP in Python. Next up: working with JSON data — a critical skill for APIs, configs, and data exchange.