Polymorphism in Python

python polymorphism — literally "many forms" — is what lets the same method name or operation behave completely differently depending on the object it's actually called on. This article covers all three of Python's main flavors: duck typing python developers rely on constantly, method overriding through inheritance, and operator overloading, along with the more formal tools (ABCs and typing.Protocol) available when you need to enforce a stricter contract.

What Is Polymorphism?

Polymorphism means the same method name or operation can behave differently depending on the specific object it's applied to.

A real-world framing

Picture a payment system supporting several providers — Stripe, PayPal, a bank transfer. Every provider exposes a process_payment() method with the same name and the same basic purpose, but each implements the actual mechanics completely differently under the hood. Code that calls provider.process_payment() doesn't need to know or care which specific provider it's dealing with — it just calls the method, and the right behavior happens automatically, based on the object's actual type.

Python's three main flavors

This article covers three distinct ways polymorphism shows up in Python: duck typing (Python's most native, idiomatic style), method overriding through inheritance, and operator overloading.

Duck Typing: Python's Native Style of Polymorphism

The core idea

"If it walks like a duck and quacks like a duck, it's a duck." In Python terms: what matters is whether an object has the method or attribute you're trying to use, not what class it officially belongs to, or whether it inherits from any particular base class at all.

class Duck:
    def sound(self):
        return "Quack!"

class Dog:
    def sound(self):
        return "Woof!"

def make_it_speak(animal):
    print(animal.sound())

make_it_speak(Duck())   # Quack!
make_it_speak(Dog())    # Woof!

Duck and Dog share absolutely no inheritance relationship whatsoever — they're two completely unrelated classes. But make_it_speak() works identically with both, because it only cares that whatever object it receives has a .sound() method — it never checks the object's actual type at all.

Contrast with statically typed languages

In a language like Java or C#, achieving this same kind of flexibility typically requires both classes to formally implement a shared interface, or inherit from a common base class — the compiler needs some declared, explicit guarantee that both types support the method being called, checked before the program even runs. Python doesn't require any of that upfront machinery — it simply attempts the call, and if the object happens to support it, the call succeeds. This is a meaningfully different philosophy: "ask forgiveness, not permission" — try the operation, and only worry about whether it was actually valid if it fails.

A practical example
class Book:
    def read(self):
        print("Reading the book...")

class WebPage:
    def read(self):
        print("Reading the web page...")

class Sensor:
    def read(self):
        print("Reading sensor data...")

def consume(item):
    item.read()

for thing in [Book(), WebPage(), Sensor()]:
    consume(thing)
# Reading the book...
# Reading the web page...
# Reading sensor data...

Three entirely unrelated classes, none inheriting from a shared parent, all working seamlessly with the same consume() function — purely because each one happens to define a .read() method.

Method Overriding: Polymorphism Through Inheritance

How it works

As covered in the earlier inheritance article, a subclass can provide its own specific implementation of a method already defined in its parent class — and when that method is called on an instance of the subclass, Python automatically runs the subclass's version instead of the parent's.

A practical example
class Animal:
    def sound(self):
        return "Some generic animal sound"

class Dog(Animal):
    def sound(self):
        return "Woof!"

class Cat(Animal):
    def sound(self):
        return "Meow!"

animals = [Animal(), Dog(), Cat()]

for animal in animals:
    print(animal.sound())
# Some generic animal sound
# Woof!
# Meow!

Every object in animals is treated identically by the loop — animal.sound() is called the exact same way, regardless of type — but the actual behavior that runs differs based on each object's real class, selected correctly and automatically at runtime.

Why this is the backbone of the Open/Closed Principle

This pattern directly enables a well-known software design principle: code should be open for extension, but closed for modification. The for animal in animals: print(animal.sound()) loop above never needs to change, no matter how many new Animal subclasses get added later:

class Bird(Animal):
    def sound(self):
        return "Tweet!"

animals.append(Bird())
# The original loop still works, completely unmodified,
# correctly picking up Bird's sound() automatically

Calling code written against the general Animal interface continues working correctly as new subclasses are introduced, without ever needing to be rewritten to explicitly handle each new type — this is precisely what makes polymorphism such a powerful tool for building code that scales well as a project grows.

Operator Overloading

Operators already behave polymorphically in Python

You've actually been using operator overloading throughout this entire series, likely without naming it explicitly: the same + operator behaves completely differently depending on what types it's operating on — adding numbers, concatenating strings, merging lists — all built directly into Python's own types already.

print(3 + 5)              # 8 — numeric addition
print("a" + "b")          # ab — string concatenation
print([1, 2] + [3, 4])    # [1, 2, 3, 4] — list concatenation

Same operator, three genuinely different behaviors, entirely dependent on the operand types involved.

Extending this to your own classes

You can give your own custom classes this same capability by implementing the appropriate dunder methods, as briefly introduced in the earlier classes and objects article. __add__ is what + actually calls behind the scenes:

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 __str__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(v1 + v2)   # Vector(4, 6)

Writing v1 + v2 triggers Python to call v1.__add__(v2) automatically behind the scenes — + is really just convenient, readable syntax for that underlying method call, exactly the same way str(x) triggers x.__str__().

Guidance: only overload where the meaning is obvious

Operator overloading is genuinely powerful, but it comes with a real readability risk if misused: + should only be overloaded on a class where addition has an obvious, unsurprising meaning within that domain — combining two vectors, concatenating two custom sequence-like objects, merging two configuration objects. Overloading + to do something genuinely unrelated to addition (say, triggering a network request as a side effect) would violate the reasonable expectations anyone reading a + b would naturally bring to that code, and produce genuinely confusing, hard-to-debug behavior. As a rule of thumb: if a reasonable Python developer, seeing a + b for the first time on your custom class, would correctly guess what it does — it's a good candidate for overloading. If they'd have no way to guess, don't overload it.

Formalizing the Contract: Abstract Base Classes and Protocols

Abstract Base Classes (ABC)

Duck typing is flexible, but it offers no enforcement — nothing stops you from passing an object that's missing the expected method until the exact moment your code tries to call it and fails. Python's abc module lets you define an Abstract Base Class that explicitly requires subclasses to implement specific methods, enforced at the moment a subclass is instantiated:

from abc import ABC, abstractmethod

class PaymentProvider(ABC):
    @abstractmethod
    def process_payment(self, amount):
        pass

class Stripe(PaymentProvider):
    def process_payment(self, amount):
        print(f"Processing  via Stripe")

class Incomplete(PaymentProvider):
    pass   # forgot to implement process_payment()

stripe = Stripe()
stripe.process_payment(100)   # Processing $100 via Stripe

broken = Incomplete()
# TypeError: Can't instantiate abstract class Incomplete with abstract method process_payment

Incomplete fails to even be instantiated, because it inherits from PaymentProvider but never actually implements the required process_payment() method — Python catches this mistake immediately, at object-creation time, rather than allowing it to silently fail much later, whenever the missing method finally gets called.

typing.Protocol: formalizing duck typing without inheritance

typing.Protocol (introduced via PEP 544) offers a more modern, more lightweight alternative — it lets you formally define the shape an object needs to have (which methods, with which signatures), for the benefit of static type checkers like Mypy, without requiring any actual inheritance relationship at all:

from typing import Protocol

class Speaker(Protocol):
    def sound(self) -> str:
        ...

class Duck:
    def sound(self) -> str:
        return "Quack!"

def make_it_speak(speaker: Speaker) -> None:
    print(speaker.sound())

make_it_speak(Duck())   # Quack! — works, even though Duck never inherits from Speaker at all

This is genuinely elegant: Duck never explicitly declares any relationship to Speaker whatsoever — a static type checker recognizes that Duck satisfies the Speaker protocol purely because it happens to have a matching sound() -> str method. This is essentially duck typing formalized for tooling — the type checker can verify the contract ahead of time, the same way an ABC would, but without forcing every compatible class into an actual inheritance hierarchy.

When to reach for each

As practical guidance: plain duck typing (no formal enforcement at all) is perfectly fine for small scripts and internal code, where the cost of a mistake is low and immediately visible. ABCs are worth the extra structure for larger codebases, public libraries, and plugin-style architectures, where you genuinely want Python itself to actively enforce that anyone implementing your interface hasn't forgotten a required method — catching that mistake immediately, rather than letting it surface as a confusing runtime error somewhere downstream. typing.Protocol sits in between — genuinely useful when you want the documentation and tooling benefits of a formal contract (autocomplete, Mypy checking) without requiring every implementing class to explicitly inherit from anything, which is particularly valuable when you're formalizing an interface against classes you don't control and can't modify to add inheritance to.