Python Polymorphism
Learn Python polymorphism: method overriding, duck typing, operator overloading, and abstract interfaces — with clear examples.
Polymorphism (from the Greek "many forms") lets a single piece of code work with objects of different types, as long as those objects support the expected interface. You call the same method name on each object, and each object responds according to its own implementation.
Polymorphism is one of the four pillars of object-oriented programming, alongside encapsulation, inheritance, and abstraction. It is what makes functions that accept a base-class type automatically work with any subclass.
This chapter covers:
- What polymorphism is and why it matters
- Polymorphism through method overriding
- Polymorphism through duck typing
- Operator overloading — a form of polymorphism built into Python
- Abstract base classes as a formal way to define polymorphic interfaces
- Practical patterns and gotchas
Before reading this chapter, make sure you are comfortable with Python classes and objects and Python inheritance.
Why Polymorphism Matters
Without polymorphism, a function that works with animals would need an explicit if/elif chain for every animal type:
def make_sound(animal):
if type(animal).__name__ == "Dog":
print("Woof!")
elif type(animal).__name__ == "Cat":
print("Meow!")
elif type(animal).__name__ == "Bird":
print("Tweet!")
# ... add a new branch every time you add a new animal typeThat is fragile. Every new animal type requires changing this function. With polymorphism you write:
def make_sound(animal):
animal.speak() # works for any object that has a speak() methodAdding a new animal type requires only defining its speak() method — the function itself never changes. This is the open/closed principle: open for extension, closed for modification.
Polymorphism Through Method Overriding
The most common form of polymorphism in Python is method overriding: a subclass provides its own version of a method that was defined by the parent class.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound."
class Dog(Animal):
def speak(self):
return f"{self.name} says woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says meow!"
class Bird(Animal):
def speak(self):
return f"{self.name} says tweet!"Now a single loop works across all three types:
animals = [Dog("Rex"), Cat("Whiskers"), Bird("Tweety")]
for animal in animals:
print(animal.speak())
# Rex says woof!
# Whiskers says meow!
# Tweety says tweet!animal.speak() dispatches to the correct version at runtime based on the actual type of the object. This runtime dispatch is called dynamic dispatch or late binding.
Extending Versus Replacing the Parent Method
When you override, you can either replace the parent's behaviour entirely or extend it using super():
class Animal:
def speak(self):
print("[Animal vocalization]")
class Dog(Animal):
def speak(self):
super().speak() # keep the parent's output
print("Woof! (Dog override adds this)")
Dog().speak()
# [Animal vocalization]
# Woof! (Dog override adds this)Use super() when the parent's version does useful setup or logging that should still run. Omit it when you want to replace the behaviour entirely.
Polymorphism Through Duck Typing
Python's type system is structural rather than nominal. An object does not have to belong to a particular class hierarchy; it just needs to have the right methods. This is called duck typing — named after the saying "if it walks like a duck and quacks like a duck, it is a duck."
class Dog:
def speak(self):
return "Woof!"
class Robot:
def speak(self):
return "Beep boop."
class Human:
def speak(self):
return "Hello!"
def introduce(entity):
print(entity.speak())
introduce(Dog()) # Woof!
introduce(Robot()) # Beep boop.
introduce(Human()) # Hello!Dog, Robot, and Human share no common parent class (other than the built-in object). Yet introduce() works with all three because each has a speak() method. The function does not check what type entity is — it simply calls the method and trusts that the object will respond correctly.
When to Use Duck Typing vs. Inheritance
| Situation | Preferred approach |
|---|---|
| Objects are logically related (all animals) | Inheritance hierarchy |
| Objects are unrelated but share a behaviour | Duck typing |
| You want to enforce the interface at definition time | Abstract base classes |
| Working with built-in types or third-party classes you cannot change | Duck typing |
Duck typing is idiomatic Python and is used extensively in the standard library — for example, len() works on any object that defines __len__, regardless of its class.
Polymorphism with Functions and Loops
You can write a single function that treats different types uniformly through polymorphism. Consider a drawing application:
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
import math
return math.pi * self.radius ** 2
def describe(self):
return f"Circle with radius {self.radius}"
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def describe(self):
return f"Rectangle {self.width}x{self.height}"
class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
def describe(self):
return f"Triangle base={self.base} height={self.height}"
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for shape in shapes:
print(f"{shape.describe()}: area = {shape.area():.2f}")
# Circle with radius 5: area = 78.54
# Rectangle 4x6: area = 24.00
# Triangle base=3 height=8: area = 12.00The loop calls area() and describe() on each shape without caring which class the object belongs to. Adding a Pentagon class later requires only writing the new class — the loop does not change.
Operator Overloading
Python's arithmetic and comparison operators are polymorphic too. + on integers adds numbers; + on strings concatenates them; + on lists merges them. Python achieves this through special (dunder) methods.
You can make your own classes respond to operators by defining these methods:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 * 3) # Vector(3, 6)The same + operator now behaves differently depending on whether the operands are integers, strings, or Vector objects. This is polymorphism at the operator level.
For a deep dive into Python's special methods, see Python Magic Methods.
Polymorphism with Abstract Base Classes
Abstract base classes (ABCs) take duck typing one step further by enforcing the interface at class-definition time. If a subclass fails to implement a required method, Python raises a TypeError the moment you try to instantiate it.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
"""Return the area of the shape."""
@abstractmethod
def perimeter(self) -> float:
"""Return the perimeter of the shape."""
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
import math
return math.pi * self.radius ** 2
def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius
class Square(Shape):
def __init__(self, side: float):
self.side = side
def area(self) -> float:
return self.side ** 2
def perimeter(self) -> float:
return 4 * self.side
def print_info(shape: Shape) -> None:
print(f"Area: {shape.area():.2f}")
print(f"Perimeter: {shape.perimeter():.2f}")
print_info(Circle(5))
# Area: 78.54
# Perimeter: 31.42
print_info(Square(4))
# Area: 16.00
# Perimeter: 16.00If you try to instantiate a subclass that does not implement area():
class Blob(Shape):
pass # forgot to implement area() and perimeter()
b = Blob()
# TypeError: Can't instantiate abstract class Blob with abstract methods area, perimeterABCs give you the safety net of a formal contract while still allowing runtime polymorphism.
ABCs vs. Duck Typing — Which Should You Use?
- Duck typing is simpler and more flexible. Prefer it for small internal codebases and scripts.
- ABCs make the expected interface explicit, catch missing-method bugs early, and show up in IDE auto-complete and type checkers. Prefer them in larger codebases, public libraries, and anywhere you want to enforce a contract.
A Real-World Example: Payment Processing
Polymorphism shines in plugin-style designs. Consider a payment system that must support multiple providers:
from abc import ABC, abstractmethod
class PaymentProvider(ABC):
@abstractmethod
def charge(self, amount: float, currency: str) -> bool:
"""Attempt to charge the given amount. Return True on success."""
@abstractmethod
def refund(self, transaction_id: str) -> bool:
"""Refund a previous transaction. Return True on success."""
class StripeProvider(PaymentProvider):
def charge(self, amount: float, currency: str) -> bool:
print(f"[Stripe] Charged {amount} {currency}")
return True
def refund(self, transaction_id: str) -> bool:
print(f"[Stripe] Refunded transaction {transaction_id}")
return True
class PayPalProvider(PaymentProvider):
def charge(self, amount: float, currency: str) -> bool:
print(f"[PayPal] Charged {amount} {currency}")
return True
def refund(self, transaction_id: str) -> bool:
print(f"[PayPal] Refunded transaction {transaction_id}")
return True
def process_order(provider: PaymentProvider, amount: float) -> None:
success = provider.charge(amount, "USD")
if success:
print("Order complete.")
process_order(StripeProvider(), 99.99)
# [Stripe] Charged 99.99 USD
# Order complete.
process_order(PayPalProvider(), 49.5)
# [PayPal] Charged 49.5 USD
# Order complete.process_order does not know or care whether it receives a StripeProvider or a PayPalProvider. Adding a new CryptoProvider requires only writing the new class — nothing else changes. This is polymorphism delivering real-world extensibility.
Common Gotchas
Gotcha 1: Checking Types with type() Instead of isinstance()
Comparing type(obj) == Dog defeats polymorphism because it returns False for subclasses. Prefer isinstance(obj, Animal), which returns True for both Dog and any future subclass:
class Animal:
pass
class Dog(Animal):
pass
d = Dog()
# Fragile — breaks for subclasses:
print(type(d) == Animal) # False
# Correct — subclass-aware:
print(isinstance(d, Animal)) # TrueGotcha 2: Inconsistent Method Signatures
Polymorphism assumes that all implementations of a method accept the same arguments. If Dog.speak() requires an argument that Cat.speak() does not, callers that treat them uniformly will break:
# Inconsistent — will cause errors in a loop
class Dog:
def speak(self, volume): # extra argument!
return f"Woof at volume {volume}"
class Cat:
def speak(self):
return "Meow!"Keep method signatures consistent across polymorphic classes.
Gotcha 3: Mutable Default Arguments in Overridden Methods
This is a broader Python gotcha, but it trips people up in class hierarchies: never use a mutable default (list, dict) as a default argument value — it is created once and shared across all calls.
# Bug: the list is shared across all instances
class Item:
def __init__(self, tags=[]): # BAD
self.tags = tags
# Fix:
class Item:
def __init__(self, tags=None):
self.tags = tags if tags is not None else []Summary
| Concept | What it means |
|---|---|
| Method overriding | A subclass provides its own version of a parent method |
| Dynamic dispatch | Python picks the right method version at runtime |
| Duck typing | Any object with the right methods works, regardless of its class |
| Operator overloading | Dunder methods (__add__, __len__, …) make operators polymorphic |
| Abstract base classes | Formally enforce the interface that subclasses must implement |
Polymorphism is what makes code extensible without modification. Write functions that depend on behaviour (method names), not on concrete types, and your code naturally accommodates new classes without changes.