Polymorphism in Python: Duck Typing, Method Overriding, and the Magic Behind It
Polymorphism means “many forms”. In programming, it describes the ability to call the same operation on different types and get type-appropriate results. Python achieves this more naturally than most languages — primarily through duck typing — but also through method overriding, operator overloading, and abstract protocols.
The Core Idea: One Interface, Many Behaviours
class Circle: def __init__(self, radius): self.radius = radius
def area(self): import math return math.pi * self.radius ** 2
class Rectangle: def __init__(self, width, height): self.width = width self.height = height
def area(self): return self.width * 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
# All three have an area() method — we can treat them uniformlyshapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]for shape in shapes: print(f"{type(shape).__name__}: {shape.area():.2f}")
# Circle: 78.54# Rectangle: 24.00# Triangle: 12.00The loop does not care what kind of shape it has. It just calls area(). Python resolves at runtime which class’s area() to call. This is runtime polymorphism.
Duck Typing: Python’s Native Polymorphism
Python does not require classes to share a common parent or implement a formal interface. If an object has the method you need, you can call it. This is known as duck typing — “if it walks like a duck and quacks like a duck, treat it as a duck.”
class PDFReport: def render(self): return "Rendering PDF..."
class HTMLReport: def render(self): return "Rendering HTML..."
class SpreadsheetReport: def render(self): return "Rendering spreadsheet..."
def publish(report): # No type check — just call render() print(report.render())
publish(PDFReport()) # Rendering PDF...publish(HTMLReport()) # Rendering HTML...publish(SpreadsheetReport()) # Rendering spreadsheet...publish does not know or care what kind of report it receives. Any object with a render() method works. Adding a new report type requires no changes to publish.
Polymorphism Through Inheritance
When subclasses override a parent method, each subclass provides its own version while the calling code stays the same.
class PaymentMethod: def charge(self, amount): raise NotImplementedError("Subclasses must implement charge()")
class CreditCard(PaymentMethod): def __init__(self, card_number): self.card_number = card_number
def charge(self, amount): return f"Charged £{amount:.2f} to card ending {self.card_number[-4:]}"
class PayPal(PaymentMethod): def __init__(self, email): self.email = email
def charge(self, amount): return f"Charged £{amount:.2f} via PayPal ({self.email})"
class BankTransfer(PaymentMethod): def charge(self, amount): return f"Initiated bank transfer of £{amount:.2f}"
def process_payment(method: PaymentMethod, amount: float): result = method.charge(amount) print(result)
process_payment(CreditCard("4111111111111234"), 99.99)process_payment(PayPal("alice@example.com"), 49.50)process_payment(BankTransfer(), 250.00)Output:
Charged £99.99 to card ending 1234Charged £49.50 via PayPal (alice@example.com)Initiated bank transfer of £250.00Built-in Polymorphism
Python’s built-in functions rely on polymorphism through special (dunder) methods. The len() function calls __len__(), + calls __add__(), str() calls __str__(). This is why len() works on lists, strings, tuples, dicts, and any class that implements __len__().
class Playlist: def __init__(self, songs): self.songs = songs
def __len__(self): return len(self.songs)
def __str__(self): return f"Playlist with {len(self)} tracks"
pl = Playlist(["Track 1", "Track 2", "Track 3"])print(len(pl)) # 3print(str(pl)) # Playlist with 3 tracksThis is why custom objects can participate in sorted(), max(), min(), and other built-ins — they implement the right dunder methods.
isinstance() vs Duck Typing
You will sometimes see code that checks object type before calling a method. This is usually unnecessary and makes code less flexible.
# Type-checking approach — rigiddef render(report): if isinstance(report, PDFReport): report.render() elif isinstance(report, HTMLReport): report.render() else: raise TypeError("Unknown report type")
# Duck typing approach — flexibledef render(report): report.render() # works for any object with render()The duck typing version handles future report types automatically. The isinstance version needs updating every time you add a new type.
isinstance() is appropriate when you genuinely need to distinguish between types — for example, when the logic differs based on the type, or when you want to give a helpful error message for unexpected types.
Protocol-Based Polymorphism (Python 3.8+)
Python 3.8 introduced typing.Protocol for structural subtyping — a way to define what methods an object must have without requiring inheritance.
from typing import Protocol
class Renderable(Protocol): def render(self) -> str: ...
class MarkdownReport: def render(self) -> str: return "# Report\nContent here..."
class JSONReport: def render(self) -> str: return '{"title": "Report", "content": "..."}'
def export(report: Renderable) -> None: print(report.render())
# Both work even though neither inherits from Renderableexport(MarkdownReport())export(JSONReport())Protocol enables static type checkers (like mypy) to verify polymorphism without runtime isinstance checks, while keeping the flexibility of duck typing.
When Polymorphism Goes Wrong
Liskov Substitution Principle violation. A subclass should be usable wherever the parent is expected without breaking the program. If a Square overrides Rectangle.set_width() to also set the height (because a square has equal sides), code that holds a Rectangle and adjusts width independently will behave incorrectly when it receives a Square. The subclass changes the semantics of the parent’s interface.
Overriding a method and changing its signature. If parent.process(data) returns a list, child.process(data) should also return a list. Returning a dict or raising where the parent would return normally breaks polymorphism.
Relying on type checks instead of the interface. Code littered with if isinstance(obj, SomeClass) is a sign that polymorphism is not being used effectively. Consider whether those branches should be handled by different process() implementations on each class.