Technology  /  Python

🐍 Python 78 guides · updated 2026

From first variable to OOP, generators, and real projects — the language that runs everything from data pipelines to AI agents, taught the practical way.

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 uniformly
shapes = [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.00

The 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 1234
Charged £49.50 via PayPal (alice@example.com)
Initiated bank transfer of £250.00

Built-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)) # 3
print(str(pl)) # Playlist with 3 tracks

This 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 — rigid
def render(report):
if isinstance(report, PDFReport):
report.render()
elif isinstance(report, HTMLReport):
report.render()
else:
raise TypeError("Unknown report type")
# Duck typing approach — flexible
def 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 Renderable
export(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.