What is it? #
Polymorphism means the same method call does the right thing depending on the type of the object it is called on.
The practical effect is that calling code stops asking "what kind of thing is this?" Each type answers for itself.
In Python it works through duck typing: if the object has the method, the call succeeds. A shared base class is optional, though one can be useful for documenting the expected interface.
The payoff is in change. Adding a new type means adding a class, not editing a conditional that is referenced in a dozen places.
Think of it like this #
A remote control has a play button. Whether it is pointed at a TV, a speaker or a projector, pressing play does the sensible thing for that device.
The remote does not contain a list of every device model with special instructions for each. It sends one instruction and lets the device decide what playing means.
Simple example #
An order can be shipped by standard post, express courier or digital delivery. Each has different costs and timelines. The checkout code should not care which one it is holding.
Code #
from abc import ABC, abstractmethod
class ShippingMethod(ABC):
"""Optional base class documenting what every shipping method must provide."""
@abstractmethod
def cost(self, weight_kg: float) -> float: ...
@abstractmethod
def eta_days(self) -> int: ...
def describe(self, weight_kg: float) -> str:
return f"{self.__class__.__name__}: {self.cost(weight_kg)} in {self.eta_days()} days"
class StandardPost(ShippingMethod):
def cost(self, weight_kg): return round(40 + 15 * weight_kg, 2)
def eta_days(self): return 5
class ExpressCourier(ShippingMethod):
def cost(self, weight_kg): return round(120 + 35 * weight_kg, 2)
def eta_days(self): return 1
class DigitalDelivery(ShippingMethod):
def cost(self, weight_kg): return 0.0
def eta_days(self): return 0
# Calling code never asks what type it has
def checkout(method, weight_kg):
return method.describe(weight_kg)
for method in (StandardPost(), ExpressCourier(), DigitalDelivery()):
print(checkout(method, 2))
# StandardPost: 70.0 in 5 days
# ExpressCourier: 190.0 in 1 days
# DigitalDelivery: 0.0 in 0 days
# The version polymorphism replaces
def checkout_with_conditionals(method_name, weight_kg):
if method_name == "standard":
cost, eta = 40 + 15 * weight_kg, 5
elif method_name == "express":
cost, eta = 120 + 35 * weight_kg, 1
elif method_name == "digital":
cost, eta = 0, 0
else:
raise ValueError("unknown method")
# ... and the same chain reappears in invoicing, tracking and emails
return cost, eta
How it works #
ShippingMethod is an abstract base class. @abstractmethod means any subclass must provide that method, and Python refuses to instantiate a subclass that does not.
describe is a concrete method on the base. It calls self.cost() and self.eta_days(), which resolve to whichever subclass is actually in use. Shared logic lives once; the varying parts are supplied by each type.
checkout(method, weight_kg) takes any object with a describe method. It has no conditionals and never changes when a new shipping method is added.
The loop passes three different classes through the same function. Python looks up describe on the actual object at call time — that is dynamic dispatch.
The bottom function shows what this replaces. The conditional chain works, but the same chain tends to get copied into invoicing, tracking and notification code. Adding a fourth method then means finding and editing every copy, and missing one is a bug.
Because Python uses duck typing, the base class is optional. A plain class with the right methods works equally well. The abstract base is worth adding when you want the contract documented and enforced at creation time.
Real-world use #
Payment gateways, storage backends, notification channels, export formats and authentication providers are all handled this way. Each provider is a class with the same methods; configuration decides which one is used.
Testing benefits enormously. A fake implementation with the same methods can stand in for a real payment gateway, so tests run without network access.
This is also how plugin systems work. The application defines the expected methods, and each plugin supplies a class. The core never knows what plugins exist.
The refactoring is well known as "replace conditional with polymorphism", and the trigger is easy to spot: the same if type == ... chain appearing in more than one place.
Common mistakes #
- Keeping a type-checking conditional inside a method that should have been overridden.
- Using
isinstancechains where a method call would do. - Making subclasses that implement the methods but behave incompatibly, breaking callers.
- Creating an abstract base for a single implementation that will never have a second.
- Forgetting that duck typing fails at runtime, not at import — so tests matter.
Practice #
Write three exporter classes — CSV, JSON and plain text — each with an export(rows) method. Write one function that takes any exporter and writes a report. Then add a fourth format and confirm you did not have to modify that function.