Inheritance and Polymorphism in Python: super() and ABCs
Reusing Code Without Copying It
A school system has students, lecturers and administrative staff. They all have a name, an email address and an ID, but each also has something special: students have a level, lecturers have courses, and staff have a department. Inheritance and polymorphism in Python handle this neatly. Copying the same code into three separate classes would be wasteful and error-prone. Object-oriented programming solves this with inheritance: shared code goes in a parent class, and each child class adds or changes only what is different. Closely linked is polymorphism: different classes responding to the same method call in their own way. This tutorial covers both ideas: parent and child classes, method overriding, super(), isinstance() and issubclass(), polymorphism and duck typing, abstract base classes, multiple inheritance and the method resolution order, and when to prefer composition. It assumes you have read the tutorial on Python classes and objects. All outputs shown come from running the code on Python 3.13.
A Parent Class and a Child Class
To inherit from a class, put the parent's name in brackets after the child's name. The child automatically gets all the parent's attributes and methods.
class Person:
def __init__(self, name, email):
self.name = name
self.email = email
def greet(self):
return f"Hello, I am {self.name}."
def contact(self):
return f"{self.name} ({self.email})"
class Student(Person):
pass
s = Student("Ada Obi", "ada@uni.edu.ng")
print(s.greet())
print(s.contact())
Output:
Hello, I am Ada Obi. Ada Obi (ada@uni.edu.ng)
Student has no code of its own, yet it can greet() and give a contact() line, because it inherited them from Person. We say Person is the parent (base class or superclass), and Student is the child (derived class or subclass). Inheritance models an "is a" relationship: a student is a person.
Extending the Parent with super()
Usually a child needs extra attributes. It defines its own __init__, and calls the parent's __init__ with super() so the shared setup is not repeated:
class Student(Person):
def __init__(self, name, email, level):
super().__init__(name, email) # let Person set name and email
self.level = level
def describe(self):
return f"{self.name} is a {self.level}-level student."
class Lecturer(Person):
def __init__(self, name, email, courses):
super().__init__(name, email)
self.courses = courses
def describe(self):
return f"{self.name} teaches {', '.join(self.courses)}."
ada = Student("Ada Obi", "ada@uni.edu.ng", 200)
dr_bello = Lecturer("Dr Bello", "bello@uni.edu.ng", ["CSC 201", "CSC 305"])
print(ada.describe())
print(dr_bello.describe())
print(dr_bello.greet())
Output:
Ada Obi is a 200-level student. Dr Bello teaches CSC 201, CSC 305. Hello, I am Dr Bello.
If you forget to call super().__init__(), the parent's setup never runs, and using self.name later raises an AttributeError.
Overriding Methods
A child can override a parent method by defining a method with the same name. Python always looks in the child class first. The child can still call the parent's version through super() and build on it:
class Lecturer(Person):
def __init__(self, name, email, courses):
super().__init__(name, email)
self.courses = courses
def greet(self):
basic = super().greet()
return basic + f" I lecture {len(self.courses)} courses."
dr_bello = Lecturer("Dr Bello", "bello@uni.edu.ng", ["CSC 201", "CSC 305"])
print(dr_bello.greet())
print(Person("Musa", "musa@uni.edu.ng").greet())
Output:
Hello, I am Dr Bello. I lecture 2 courses. Hello, I am Musa.
Checking Types: isinstance() and issubclass()
print(isinstance(ada, Student)) print(isinstance(ada, Person)) print(isinstance(ada, Lecturer)) print(issubclass(Lecturer, Person)) print(type(ada).__name__) print([cls.__name__ for cls in Student.__mro__])
Output:
True True False True Student ['Student', 'Person', 'object']
A student is also a person, so isinstance(ada, Person) is True. Every class ultimately inherits from Python's built-in object. The __mro__ attribute lists the order in which Python searches classes for a method, explained further below.
Polymorphism
Polymorphism ("many forms") means that code can call the same method on different kinds of objects, and each object responds in its own way. The calling code does not need if statements to check the type.
class Shape:
def area(self):
return 0
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
class Circle(Shape):
def __init__(self, r):
self.r = r
def area(self):
return round(3.14159 * self.r ** 2, 2)
class Triangle(Shape):
def __init__(self, b, h):
self.b, self.h = b, h
def area(self):
return 0.5 * self.b * self.h
shapes = [Rectangle(4, 5), Circle(3), Triangle(6, 2)]
for shape in shapes:
print(f"{type(shape).__name__:10} area = {shape.area()}")
print("Total:", round(sum(s.area() for s in shapes), 2))
Output:
Rectangle area = 20 Circle area = 28.27 Triangle area = 6.0 Total: 54.27
The loop simply calls shape.area(). To add a new shape, you write a new class, and the loop keeps working without a single change. This is the real power of polymorphism: code that is open for extension without being edited.
Duck Typing
Python is relaxed about types. Polymorphism does not even require a shared parent class; an object only needs the right method. This is called duck typing: "if it walks like a duck and quacks like a duck, it is a duck."
class Square: # not a subclass of Shape
def __init__(self, s):
self.s = s
def area(self):
return self.s ** 2
print(sum(s.area() for s in [Rectangle(2, 3), Square(4)]))
print(len("hello"), len([1, 2, 3]), len({"a": 1}))
Output:
22 5 3 1
Built-in functions like len() are polymorphic in the same way: they work with any object that defines the matching special method, as covered in the tutorial on magic methods.
Abstract Base Classes
In the Shape example, nothing stops someone from forgetting to write area() in a new subclass; it would silently return 0. An abstract base class (ABC) makes a method compulsory. You cannot create an object of the abstract class itself, or of a subclass that has not implemented every abstract method.
from abc import ABC, abstractmethod
class Payment(ABC):
def __init__(self, amount):
self.amount = amount
@abstractmethod
def fee(self):
"""Every payment method must say what it charges."""
def total(self):
return self.amount + self.fee()
class Card(Payment):
def fee(self):
return round(self.amount * 0.015, 2)
class Transfer(Payment):
def fee(self):
return 50 if self.amount > 10_000 else 10
for p in [Card(20_000), Transfer(20_000), Transfer(5_000)]:
print(type(p).__name__, p.total())
try:
Payment(1000)
except TypeError as e:
print("Error:", e)
Output:
Card 20300.0 Transfer 20050 Transfer 5010 Error: Can't instantiate abstract class Payment without an implementation for abstract method 'fee'
The abstract class also provides shared behaviour: total() is written once in Payment, yet it calls each subclass's own fee(). This pattern, where a parent defines the steps and children fill in the details, is called the template method pattern.
Multiple Inheritance and the MRO
A Python class can inherit from more than one parent. A common use is a small mixin class that adds one feature:
class JSONMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)
class TeachingAssistant(JSONMixin, Student):
def __init__(self, name, email, level, course):
super().__init__(name, email, level)
self.course = course
ta = TeachingAssistant("Chidi Eze", "chidi@uni.edu.ng", 400, "CSC 101")
print(ta.describe())
print(ta.to_json())
print([cls.__name__ for cls in TeachingAssistant.__mro__])
Output:
Chidi Eze is a 400-level student.
{"name": "Chidi Eze", "email": "chidi@uni.edu.ng", "level": 400, "course": "CSC 101"}
['TeachingAssistant', 'JSONMixin', 'Student', 'Person', 'object']
When several parents are involved, Python searches for methods in the method resolution order (MRO): the class itself, then its parents from left to right, and so on, each class appearing only once. super() follows the MRO, which is why it is preferred over calling a parent class by name. Keep multiple inheritance simple: mixins that add small, independent features are fine, but deep, tangled hierarchies are hard to understand.
Inheritance or Composition?
Inheritance is not always the right tool. Use it for a genuine "is a" relationship. For a "has a" relationship, use composition: store another object as an attribute.
class Course:
def __init__(self, code, units):
self.code, self.units = code, units
class Timetable: # a timetable HAS courses
def __init__(self):
self.courses = []
def add(self, course):
self.courses.append(course)
def total_units(self):
return sum(c.units for c in self.courses)
t = Timetable()
t.add(Course("CSC 201", 3))
t.add(Course("MTH 201", 2))
print(t.total_units())
Output:
5
A timetable is not a kind of course, so inheriting from Course would make no sense. A widely quoted guideline is "favour composition over inheritance": it keeps classes small and loosely connected.
Inheritance and Polymorphism in Python: Practice Exercises
- Create a
Staffsubclass ofPersonwith adepartmentattribute and its owndescribe()method. - Add a
perimeter()method to every shape class and print the total perimeter of a list of shapes. - Make
Shapean abstract base class so thatarea()is compulsory. - Write an abstract
Accountclass with an abstractinterest_rate()method, andSavingsandCurrentsubclasses.
Conclusion
Inheritance lets a child class reuse and extend a parent class: write class Child(Parent), call super().__init__() to run the shared setup, and override methods to change behaviour. Polymorphism lets one piece of code work with many classes through a common method name, and duck typing means Python only cares that the method exists. Abstract base classes enforce that required methods are implemented, the MRO decides which method runs under multiple inheritance, and composition is often the better choice when one object simply has another.