Python Course
Polymorphism
The word polymorphism comes from Greek — it means "many forms." In Python, it describes the ability of different object types to respond to the same method call, each in their own way. You got a first glimpse of this in the Inheritance lesson when different animal subclasses each had their own speak() method. This lesson goes much deeper — covering every form of polymorphism Python supports and showing you how it makes code dramatically more flexible and extensible.
Polymorphism is not a feature you bolt on — it is a natural consequence of good object design. Once you understand it, you will start seeing and using it everywhere.
What Polymorphism Means in Practice
In Python, polymorphism means you can write code that works on objects of different types — as long as those objects support the operations you are calling. You do not need to check what type something is. You just call the method and trust each object to do the right thing.
# The core idea — same call, different behaviour per type
class Dog:
def speak(self):
return "Woof!"
class Cat:
def speak(self):
return "Meow!"
class Duck:
def speak(self):
return "Quack!"
# A single function works with any of these — no isinstance() checks needed
def make_noise(animal):
print(animal.speak())
make_noise(Dog()) # Woof!
make_noise(Cat()) # Meow!
make_noise(Duck()) # Quack!Meow!
Quack!
make_noise()does not care about the type — it only cares that the object has aspeak()method- Adding a new animal type requires no changes to
make_noise()— just define the method on the new class - This is the open/closed principle — open for extension, closed for modification
1. Polymorphism Through Inheritance
The most explicit form of polymorphism in Python is overriding a parent method in each subclass. A shared interface is defined in the parent and each child implements it differently.
Real-world use: a reporting system defines a base Report class with a generate() method. Each subclass — PDFReport, CSVReport, HTMLReport — overrides generate() to produce output in its own format. The code that calls generate() never needs to know which format it is dealing with.
# Polymorphism through inheritance — shared interface, different implementations
class Shape:
def area(self):
raise NotImplementedError("Subclasses must implement area()")
def describe(self):
print(f"I am a {self.__class__.__name__} with area {self.area():.2f}")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# One loop — works for every shape type
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for shape in shapes:
shape.describe() # calls the right area() for each typeI am a Rectangle with area 24.00
I am a Triangle with area 12.00
describe()is defined once in the parent and callsself.area()— Python dispatches to the correct subclass version at runtime- Raising
NotImplementedErrorin the parent enforces that every subclass must provide its ownarea() - Adding a
Pentagonclass requires only writing the new class — nothing else changes
2. Duck Typing — Python's Natural Polymorphism
Python does not require a shared parent class for polymorphism. If an object has the method you need, you can call it — regardless of the object's type. This is called duck typing: "if it walks like a duck and quacks like a duck, it is a duck."
Why it exists: Python is dynamically typed. Rather than checking types at compile time, Python checks at runtime whether the object supports the operation. This makes code more flexible and reusable.
Real-world use: Python's built-in len() works on strings, lists, tuples, dicts, sets, and any custom class that implements __len__ — regardless of their inheritance hierarchy.
# Duck typing — no shared parent required
class PDFExporter:
def export(self, data):
print(f"Exporting to PDF: {data}")
class CSVExporter:
def export(self, data):
print(f"Exporting to CSV: {data}")
class JSONExporter:
def export(self, data):
import json
print(f"Exporting to JSON: {json.dumps(data)}")
# This function works with any object that has an export() method
def run_export(exporter, data):
exporter.export(data) # duck typing — no isinstance() check
records = {"sales": 1200, "region": "US"}
run_export(PDFExporter(), records)
run_export(CSVExporter(), records)
run_export(JSONExporter(), records)Exporting to CSV: {'sales': 1200, 'region': 'US'}
Exporting to JSON: {"sales": 1200, "region": "US"}
- None of the exporter classes share a parent — duck typing does not require it
- If an object does not have the required method, Python raises
AttributeErrorat call time — not before - Duck typing is why Python code is often more concise and composable than equivalent code in statically typed languages
3. Operator Polymorphism — Same Operator, Different Types
Python's built-in operators are polymorphic by design. The + operator adds numbers, concatenates strings, and merges lists — the behaviour depends entirely on the type of the operands.
# Built-in operator polymorphism
print(10 + 5) # 15 — integer addition
print(10.0 + 5) # 15.0 — float addition
print("Hello" + " World") # Hello World — string concatenation
print([1, 2] + [3, 4]) # [1, 2, 3, 4] — list concatenation
print(len("Python")) # 6 — string length
print(len([1, 2, 3])) # 3 — list length
print(len({"a": 1})) # 1 — dict length
# * is polymorphic too
print("ha" * 3) # hahaha
print([0] * 4) # [0, 0, 0, 0]15.0
Hello World
[1, 2, 3, 4]
6
3
1
hahaha
[0, 0, 0, 0]
- This works because each type implements the underlying magic methods (
__add__,__len__,__mul__) in its own way - You extend this to your own classes using magic methods — covered in the next lesson
4. Function and Method Polymorphism
Python's built-in functions like str(), repr(), iter(), and sorted() are all polymorphic — they work on any object that implements the corresponding interface. You can make your own functions equally flexible.
# Polymorphic functions — work with any compatible object
class Celsius:
def __init__(self, temp):
self.temp = temp
def __str__(self):
return f"{self.temp}°C"
def __lt__(self, other):
return self.temp < other.temp
class Fahrenheit:
def __init__(self, temp):
self.temp = temp
def __str__(self):
return f"{self.temp}°F"
def __lt__(self, other):
return self.temp < other.temp
temps = [Celsius(100), Celsius(0), Celsius(37), Fahrenheit(98), Fahrenheit(32)]
# str() is polymorphic — calls __str__ on each type
for t in temps:
print(str(t), end=" ")
print()
# sorted() is polymorphic — calls __lt__ on each type
celsius_only = [Celsius(100), Celsius(0), Celsius(37)]
print(sorted(celsius_only))[0°C, 37°C, 100°C]
5. Polymorphism with isinstance() — When Type Checking is Right
Pure duck typing is ideal, but occasionally you need to handle different types differently in the same function. In those cases, isinstance() is the right tool — not type() ==. Using isinstance() respects inheritance.
# isinstance() — type-aware polymorphism when needed
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def speak(self): return "Woof!"
class Cat(Animal):
def speak(self): return "Meow!"
class GoldenRetriever(Dog): # subclass of Dog
pass
def check_type(animal):
if isinstance(animal, Dog): # True for Dog AND GoldenRetriever
print(f"{animal.name} is a Dog (or subclass)")
elif isinstance(animal, Cat):
print(f"{animal.name} is a Cat")
else:
print(f"{animal.name} is some other animal")
check_type(Dog("Rex"))
check_type(Cat("Luna"))
check_type(GoldenRetriever("Buddy")) # correctly identified as Dog
# Contrast with type() == which does NOT respect inheritance
print(type(GoldenRetriever("Buddy")) == Dog) # False — too strictLuna is a Cat
Buddy is a Dog (or subclass)
False
isinstance(obj, Class)returnsTruefor the class and all its subclassestype(obj) == ClassreturnsTrueonly for the exact class — misses subclasses- Prefer duck typing over
isinstance()checks — only useisinstance()when you genuinely need to branch based on type
Summary Table
| Form | How It Works | Key Idea |
|---|---|---|
| Inheritance-based | Subclasses override a shared parent method | Runtime dispatch to correct subclass version |
| Duck typing | Any object with the required method works | No shared parent needed — just the right interface |
| Operator polymorphism | Same operator behaves differently per type | Powered by magic methods |
isinstance() |
Type-aware branching that respects inheritance | Use sparingly — prefer duck typing |
Practice Questions
Practice 1. What does polymorphism mean in the context of Python?
Practice 2. What is duck typing?
Practice 3. Why is isinstance() preferred over type() == when type checking is needed?
Practice 4. What design principle does polymorphism support — open for extension, closed for modification?
Practice 5. What happens if you call a method on an object that does not have it in duck-typed code?
Quiz
Quiz 1. Which of the following best describes polymorphism?
Quiz 2. In duck typing, what is the only requirement for an object to be used in a function?
Quiz 3. Why does len() work on strings, lists, dicts, and custom classes equally?
Quiz 4. When a parent class raises NotImplementedError in a method, what is it communicating?
Quiz 5. Which of the following demonstrates duck typing correctly?
Next up — Encapsulation: controlling access to data with private attributes, getters, setters, and the @property decorator.