Inheritance and Polymorphism in Python: Extending Classes Cleanly
- A subclass is created by naming a parent class in parentheses after the class name; it inherits all its attributes and methods.
super()calls the parent's version of a method so you can extend behaviour instead of duplicating it.- Overriding a method replaces the parent's implementation for objects of the subclass.
- Polymorphism means different classes respond to the same method call each in their own way, without the caller checking type first.
Creating a subclass
A subclass is created by naming a parent class in parentheses after the class name. It inherits every attribute and method the parent defines:
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound."
class Dog(Animal):
pass
d = Dog("Rex")
print(d.speak()) # Rex makes a sound.
Dog defines no methods of its own here, so calling speak() falls back to Animal.speak. Python searches the subclass first, then walks up through its parents until it finds a matching method — this search path is the method resolution order (MRO), inspectable via Dog.__mro__.
Extending __init__ with super()
When a subclass needs its own __init__ but still wants the parent's setup logic to run, super().__init__(...) calls the parent's version explicitly:
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
d = Dog("Rex", "Labrador")
print(d.name, d.breed) # Rex Labrador
Without the super().__init__(name) call, Dog would need to duplicate self.name = name itself, and any future change to Animal.__init__ would not automatically apply to Dog.
Overriding methods
A subclass overrides a method simply by defining a method with the same name. The subclass's version is used for its instances; the parent's version is unaffected and still used by parent instances or other subclasses:
class Dog(Animal):
def speak(self):
return f"{self.name} barks."
class Cat(Animal):
def speak(self):
return f"{self.name} meows."
for pet in (Dog("Rex"), Cat("Milo"), Animal("Generic")):
print(pet.speak())
An overriding method can still call the parent's implementation via super() if it wants to extend rather than fully replace the behaviour, the same pattern used for __init__.
Polymorphism and duck typing
The loop above is polymorphism in practice: the calling code treats every object the same way — call .speak() — without checking what specific class each one is. Python favours duck typing: if an object has the method you need, you can call it, regardless of its class or inheritance chain. There is no requirement that Dog and Cat share a common parent at all for this pattern to work — any object with a compatible speak() method fits.
isinstance() and multiple inheritance
isinstance(obj, Class) checks whether an object is an instance of a class or any of its subclasses, and is generally preferred over comparing type(obj) == Class directly, since the latter rejects legitimate subclass instances:
print(isinstance(Dog("Rex"), Animal)) # True
print(isinstance(Dog("Rex"), Dog)) # True
print(isinstance(Cat("Milo"), Dog)) # False
Python also supports multiple inheritance — a class can list more than one parent — with the MRO determining lookup order when parents define overlapping method names. Multiple inheritance is powerful but easy to overuse; a shallow hierarchy with clear single-purpose parent classes is usually easier to reason about than a deep or tangled one.
Composition as an alternative
Inheritance is not the only way to reuse behaviour. Composition — giving an object an instance of another class as an attribute, rather than inheriting from it — is often a cleaner fit when the relationship is "has a" rather than "is a":
class Engine:
def start(self):
return "Engine running"
class Car:
def __init__(self):
self.engine = Engine() # Car "has an" Engine
def start(self):
return self.engine.start()
print(Car().start()) # Engine running
A Car is not a kind of Engine, so inheriting from it would misrepresent the relationship and would also expose every unrelated Engine method directly on Car. Composition keeps each class focused on one responsibility and makes it easy to swap in a different engine implementation later without touching Car at all. A widely quoted rule of thumb is to reach for inheritance only when subclass and parent genuinely share an "is a" relationship, and to default to composition otherwise.
Abstract base classes with abc
Sometimes a parent class exists purely to define a required interface, with no sensible default implementation of its own. The abc module's ABC base class and @abstractmethod decorator enforce that subclasses must implement specific methods, or they cannot be instantiated at all:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
...
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
Shape() # TypeError: Can't instantiate abstract class Shape
print(Circle(2).area()) # 12.56636
Trying to create a plain Shape() fails immediately, and any subclass that forgets to implement area() fails the same way the moment it is instantiated, rather than only when area() is eventually called. This turns a missing-method mistake into an error caught early, instead of a silent bug that only surfaces much later at runtime.