Skip to content

Polymorphism#

Polymorphism — "many forms" — means code can operate on objects of different types through a common interface. Python achieves this primarily through duck typing (behavior-based) and secondarily through inheritance-based subtype polymorphism. No interface keyword required — if it quacks, it works.

This page builds on Introdicion to OOPs, Inheritance and Multiple Inheritance, and Abstract Base Classes.

How to use this page

Understand duck typing first — it's Python's default. Then study ABC/Protocol for explicit contracts. Related: Dunder or Magic Methods, Method Resolution Order.

At a glance
Track Python Advanced → Object Oriented Programming
Sections 13 major topics
Outline Use the right-hand TOC to jump

Topics: Two flavors in Python · Duck typing — the Python default · Inheritance-based polymorphism · Polymorphism via dunder methods · ABC-driven polymorphism — explicit contracts · typing.Protocol — structural polymorphism · Polymorphism in the standard library · Strategy pattern — runtime polymorphism · … (+5 more)

  1. Two flavors in Python
  2. Duck typing — the Python default
  3. Inheritance-based polymorphism
  4. Polymorphism via dunder methods
  5. ABC-driven polymorphism — explicit contracts
  6. typing.Protocol — structural polymorphism
  7. Polymorphism in the standard library
  8. Strategy pattern — runtime polymorphism
  9. Multiple approaches to the same problem
  10. Polymorphism in interviews
  11. isinstance vs duck typing — when to check types
  12. Overriding and dynamic dispatch … and 1 more sections

Two flavors in Python#

Flavor Mechanism Example
Duck typing Object supports required methods file_like.read()
Subtype polymorphism Inheritance / ABC / isinstance Animal.speak() overridden in Dog
Ad hoc polymorphism Operator overloading via dunders a + b for custom types

Python favors duck typing; use ABCs when you need explicit contracts.


Duck typing — the Python default#

def process(source) -> str:
    return source.read().upper()

# Works with any object that has .read() -> str
process(open("file.txt"))
process(io.StringIO("hello"))

No common base class required. The function declares its expected behavior, not its expected type.

EAFP vs LBYL#

Python prefers Easier to Ask Forgiveness than Permission:

# EAFP — Pythonic
def process(source) -> str:
    try:
        return source.read().upper()
    except AttributeError:
        raise TypeError("source must have read()") from None

# LBYL — less Pythonic
def process(source) -> str:
    if not hasattr(source, "read"):
        raise TypeError("source must have read()")
    return source.read().upper()

Use hasattr sparingly — it can lie if __getattr__ is involved.


Inheritance-based polymorphism#

class Animal:
    def __init__(self, name: str):
        self.name = name

    def speak(self) -> str:
        return "..."

class Dog(Animal):
    def speak(self) -> str:
        return "woof"

class Cat(Animal):
    def speak(self) -> str:
        return "meow"

def announce(pet: Animal) -> None:
    print(f"{pet.name} says {pet.speak()}")

announce(Dog("Rex"))    # Rex says woof
announce(Cat("Mia"))    # Mia says meow

announce works with any Animal subclass — substitutability (Liskov Substitution Principle).

Liskov Substitution Principle (LSP)#

Subtypes must be usable wherever the base type is expected without breaking behavior:

# Violation example — classic rectangle/square debate
class Rectangle:
    def __init__(self, w: float, h: float):
        self.width = w
        self.height = h

    def area(self) -> float:
        return self.width * self.height

class Square(Rectangle):
    def __init__(self, side: float):
        super().__init__(side, side)

    def set_width(self, w: float):
        self.width = self.height = w   # breaks Rectangle expectations

If client code sets width independently of height, Square violates LSP. Prefer composition or separate types.


Polymorphism via dunder methods#

Operator and built-in function polymorphism:

class Vector:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar: float) -> "Vector":
        return Vector(self.x * scalar, self.y * scalar)

    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

Vector(1, 2) + Vector(3, 4)   # Vector(4, 6)
Vector(1, 2) * 3              # Vector(3, 6)

Same + syntax, different behavior per type — operator overloading.

See Dunder or Magic Methods.


ABC-driven polymorphism — explicit contracts#

When duck typing isn't enough (frameworks, plugins):

from abc import ABC, abstractmethod

class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount: float) -> bool: ...

class StripeGateway(PaymentGateway):
    def charge(self, amount: float) -> bool:
        print(f"Stripe: ${amount}")
        return True

class PayPalGateway(PaymentGateway):
    def charge(self, amount: float) -> bool:
        print(f"PayPal: ${amount}")
        return True

def checkout(gateway: PaymentGateway, amount: float) -> None:
    if gateway.charge(amount):
        print("Success")

checkout(StripeGateway(), 99.99)
checkout(PayPalGateway(), 49.99)

ABCs add instantiation-time enforcement — see Abstract Base Classes.


typing.Protocol — structural polymorphism#

Static type checking without inheritance:

from typing import Protocol

class SupportsWrite(Protocol):
    def write(self, data: str) -> int: ...

def save(output: SupportsWrite, data: str) -> None:
    output.write(data)

class FileWriter:
    def write(self, data: str) -> int:
        print(data)
        return len(data)

save(FileWriter(), "hello")   # OK — structural match
Approach Runtime check Static check Inheritance required
Duck typing Implicit (try/except) Optional hints No
ABC isinstance Yes Yes (or register)
Protocol With @runtime_checkable Yes No

Polymorphism in the standard library#

collections.abc — iterable polymorphism#

from collections.abc import Iterable

def consume(items: Iterable[int]) -> int:
    return sum(items)

consume([1, 2, 3])
consume(range(10))
consume({1, 2, 3})   # sets are iterable

File-like objects#

def load_json(source) -> dict:
    import json
    return json.load(source)

load_json(open("data.json"))
load_json(io.StringIO('{"key": "value"}'))

Sorting polymorphism via __lt__#

@dataclass(order=True)
class Task:
    priority: int
    name: str

sorted([Task(2, "b"), Task(1, "a")])   # sorts by priority

Strategy pattern — runtime polymorphism#

from abc import ABC, abstractmethod

class CompressionStrategy(ABC):
    @abstractmethod
    def compress(self, data: bytes) -> bytes: ...

class GzipStrategy(CompressionStrategy):
    import gzip
    def compress(self, data: bytes) -> bytes:
        return gzip.compress(data)

class RawStrategy(CompressionStrategy):
    def compress(self, data: bytes) -> bytes:
        return data

class Archiver:
    def __init__(self, strategy: CompressionStrategy):
        self._strategy = strategy

    def archive(self, data: bytes) -> bytes:
        return self._strategy.compress(data)

Archiver(GzipStrategy()).archive(b"hello")

Swap strategies at runtime without changing Archiver — composition + polymorphism.


Multiple approaches to the same problem#

Problem: format any object as JSON#

Approach 1 — duck typing

def to_json(obj) -> str:
    import json
    if hasattr(obj, "to_json"):
        return obj.to_json()
    return json.dumps(obj.__dict__)

Approach 2 — ABC

class JSONSerializable(ABC):
    @abstractmethod
    def to_json(self) -> str: ...

Approach 3 — singledispatch (functools)

from functools import singledispatch
import json

@singledispatch
def serialize(obj) -> str:
    return json.dumps(obj)

@serialize.register
def _(obj: JSONSerializable) -> str:
    return obj.to_json()
Approach Best when
Duck typing Small codebase, flexible APIs
ABC Plugin systems, team contracts
singledispatch Type-based dispatch on functions

Polymorphism in interviews#

Design: notification system#

from abc import ABC, abstractmethod

class Notifier(ABC):
    @abstractmethod
    def send(self, message: str) -> bool: ...

class EmailNotifier(Notifier):
    def send(self, message: str) -> bool:
        print(f"Email: {message}")
        return True

class SMSNotifier(Notifier):
    def send(self, message: str) -> bool:
        print(f"SMS: {message}")
        return True

class NotificationService:
    def __init__(self, notifiers: list[Notifier]):
        self._notifiers = notifiers

    def notify_all(self, message: str) -> None:
        for n in self._notifiers:
            n.send(message)

DSA: polymorphic compare for priority queue#

class Task:
    def __init__(self, priority: int, name: str):
        self.priority = priority
        self.name = name

    def __lt__(self, other: "Task") -> bool:
        return self.priority < other.priority

import heapq
heap = []
heapq.heappush(heap, Task(2, "low"))
heapq.heappush(heap, Task(1, "high"))
heapq.heappop(heap)   # Task(1, "high")

isinstance vs duck typing — when to check types#

def handle(obj) -> None:
    if isinstance(obj, str):
        process_string(obj)
    elif isinstance(obj, int):
        process_int(obj)
    else:
        process_generic(obj)
Use isinstance Use duck typing
Specific type branches needed Generic algorithms
Exception hierarchies File-like, iterable-like
Framework dispatch Most library code

Avoid type(obj) == SomeClass — breaks subclass polymorphism.


Overriding and dynamic dispatch#

Method lookup is dynamic (runtime) — based on object's actual type:

class Base:
    def action(self):
        return "base"

class Derived(Base):
    def action(self):
        return "derived"

def run(obj: Base):
    return obj.action()

run(Derived())   # "derived" — not "base"

This is virtual dispatch — no virtual keyword needed.

MRO determines which override wins in multiple inheritance: Method Resolution Order.


Parametric polymorphism (generics)#

Type hints express polymorphism over types:

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T:
    return items[0]

first([1, 2, 3])       # int
first(["a", "b"])      # str

Generics are static — erased at runtime in standard CPython.


Interview traps (quick reference)#

Trap What goes wrong Safe approach
Over-using isinstance Breaks extensibility Duck typing or ABC at boundaries
LSP violation in inheritance Subclass breaks callers Favor composition
Assuming Java-style interfaces Python doesn't require them Duck type or Protocol
type(x) == list vs isinstance Rejects subclasses Use isinstance(x, list)
Checking types before EAFP Race conditions, verbose try/except AttributeError
Polymorphism without common interface docs Unclear contract Docstring expected methods or ABC

Mental model checklist#

  1. What is duck typing, and why is it Python's default?
  2. How does inheritance-based polymorphism differ from duck typing?
  3. When would you use an ABC instead of duck typing?
  4. What is the Liskov Substitution Principle?
  5. How do dunder methods enable operator polymorphism?

What's next#

Topic Page
ABC contracts Abstract Base Classes
Inheritance design Inheritance and Multiple Inheritance
Dunder methods Dunder or Magic Methods
Intermediate OOP Classes and OOP Basics