Python Polymorphism

Polymorphism comes from Greek for “many forms.” In Python it means that the same operation, function, or method call can behave differently depending on the type of object it is applied to. You call .speak() on a Dog and a Cat and each answers in its own way, without the calling code needing to know or care which one it actually has.

This matters because it lets you write flexible, reusable code. Instead of writing a giant chain of if isinstance(x, Dog): ... elif isinstance(x, Cat): ..., you write one loop that calls x.speak() and trust every object to know how to respond. Polymorphism, alongside encapsulation and inheritance, is one of the three pillars of object-oriented programming, and Python’s dynamic nature makes it especially natural to use.

Overview / How it works

Python supports polymorphism in three overlapping ways:

  • Duck typing — “If it walks like a duck and quacks like a duck, it’s a duck.” Python does not check an object’s class before calling a method; it just tries to call the method. Any object with a compatible method or attribute can be used, whether or not it shares a common base class.
  • Method overriding (inheritance-based polymorphism) — A subclass redefines a method that its parent class defines. Code that calls the method through a variable of the parent’s declared “role” automatically gets the subclass’s version.
  • Operator overloading — Built-in operators and functions such as +, len(), str(), and == are themselves polymorphic: they work on many types because each class defines its own version of the dunder (double-underscore) method behind the operator, such as __add__, __len__, __str__, or __eq__.

Under the hood, Python is dynamically typed and uses late binding (also called dynamic dispatch). When you write obj.method(), Python does not decide at compile time which function will run — there is no compile-time type checking in the way statically typed languages like Java or C++ perform it. Instead, at the moment the call executes, Python looks up method starting from type(obj) and walking up the Method Resolution Order (MRO, accessible as type(obj).__mro__) until it finds a matching attribute. Because that lookup happens fresh on every call based on the object’s actual runtime class, the exact same line of source code can invoke completely different logic depending on what object flows through it. This is the mechanical reason polymorphism “just works” in Python without special syntax like virtual keywords.

Duck typing takes this one step further: since the lookup only cares whether the attribute exists on the object’s class (or its MRO), objects don’t even need to share an ancestor. Two totally unrelated classes can both be passed to the same function as long as each implements the method the function calls.

Syntax

There is no special polymorphism keyword. The general pattern for method-overriding polymorphism looks like this:

class Animal:
    def speak(self) -> str:
        raise NotImplementedError

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

class Cat(Animal):
    def speak(self) -> str:
        return "Meow"
Element Meaning
class Animal Common parent class declaring the shared interface (speak).
raise NotImplementedError Signals that subclasses are expected to override this method; calling it directly on Animal is an error.
class Dog(Animal) Subclass inheriting from Animal and providing its own speak implementation.
def speak(self) in each subclass Same method name and signature, different body — this is the override.

Examples

Example 1: Polymorphism through inheritance

from abc import ABC, abstractmethod
import math


class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        ...

    @abstractmethod
    def name(self) -> str:
        ...


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius ** 2

    def name(self) -> str:
        return "Circle"


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

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

    def name(self) -> str:
        return "Rectangle"


shapes: list[Shape] = [Circle(3), Rectangle(4, 5)]

for shape in shapes:
    print(f"{shape.name()}: area = {shape.area():.2f}")

Output:

Circle: area = 28.27
Rectangle: area = 20.00

The loop calls shape.area() and shape.name() without ever checking whether shape is a Circle or a Rectangle. Each object answers using its own overridden method. The Shape base class uses abstractmethod from the abc module to guarantee that every subclass must implement area and name, which documents the expected interface without dictating how each shape computes it.

Example 2: Polymorphism through duck typing

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


class Dog:
    def speak(self) -> str:
        return "Woof!"


class RobotDuck:
    def speak(self) -> str:
        return "Beep-quack (simulated)"


def make_it_speak(creature) -> None:
    print(creature.speak())


for creature in [Duck(), Dog(), RobotDuck()]:
    make_it_speak(creature)

Output:

Quack!
Woof!
Beep-quack (simulated)

Duck, Dog, and RobotDuck share no common base class at all. make_it_speak never checks types — it simply calls .speak() and trusts that whatever object it receives supports that call. This is duck typing in its purest form, and it is idiomatic in Python precisely because the language does not require an inheritance relationship for polymorphism to work.

Example 3: Polymorphism through operator overloading

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

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

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

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


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

print(v3)
print(v3 == Vector(6, 4))
print(len(str(v3)))

Output:

Vector(6, 4)
True
12

The + operator, the == operator, print(), and len() are all polymorphic built-ins: they work on integers, strings, lists, and now Vector objects too, because Vector defines __add__, __eq__, and __str__. When Python evaluates v1 + v2, it translates that to v1.__add__(v2) behind the scenes — the same + syntax dispatches to completely different code depending on the operands’ types.

How it works step by step / Under the hood

  • Python evaluates obj.method(args) by first resolving method as an attribute lookup on obj.
  • The lookup checks obj.__dict__ first (instance attributes), then walks type(obj).__mro__ (the class and its ancestors, in a well-defined linearized order) looking for method.
  • The first matching function found is bound to obj, producing a bound method, and then called with args.
  • Because this resolution happens at call time (not when the code was written or compiled), the exact function invoked depends entirely on the object’s actual runtime type — this is dynamic dispatch, and it’s what makes overriding and duck typing work identically under the hood.
  • For operators like +, Python first tries left.__add__(right); if that returns NotImplemented (or doesn’t exist), it tries right.__radd__(left) before finally raising TypeError.

Common Mistakes

Mistake 1: Overriding __init__ but forgetting to call the parent’s initializer. This silently skips setup the parent class relies on:

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

    def describe(self) -> str:
        return f"I am {self.name}"


class Dog(Animal):
    def __init__(self, name: str, breed: str):
        # BUG: never calls super().__init__(name), so self.name is never set
        self.breed = breed

    def describe(self) -> str:
        return f"{super().describe()}, a {self.breed}"


d = Dog("Rex", "Labrador")
print(d.describe())  # raises AttributeError: 'Dog' object has no attribute 'name'

The fix is to explicitly call super().__init__(name) so the parent class gets a chance to set up its own state:

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

    def describe(self) -> str:
        return f"I am {self.name}"


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

    def describe(self) -> str:
        return f"{super().describe()}, a {self.breed}"


d = Dog("Rex", "Labrador")
print(d.describe())

Output:

I am Rex, a Labrador

Mistake 2: Breaking the Liskov Substitution Principle by changing a method’s signature or contract incompatibly. If Animal.speak(self) takes no arguments but a subclass overrides it as speak(self, volume) requiring a new mandatory argument, then code written against the base class (animal.speak()) breaks for that subclass. A polymorphic override should accept a compatible signature and honor the same general contract (return type, exceptions raised) as the method it replaces, only specializing the internal behavior.

Mistake 3: Confusing polymorphism with method overloading. Unlike Java or C++, Python does not support defining multiple methods with the same name but different parameter lists — the later definition simply replaces the earlier one in the class namespace. To vary behavior by argument type, use default arguments, *args/**kwargs, isinstance checks, or the functools.singledispatch decorator instead of expecting Python to pick an overload automatically.

Best Practices

  • Favor duck typing and “Easier to Ask Forgiveness than Permission” (EAFP) over explicit isinstance checks — let the object’s methods do the work rather than branching on its type.
  • Use abc.ABC and @abstractmethod when you want to formally document and enforce an interface that all subclasses must implement.
  • Keep overridden methods compatible with the base method’s signature and contract (Liskov Substitution Principle) so any subclass can stand in for its parent without surprising callers.
  • Use super() to extend a parent method’s behavior rather than silently discarding it, especially inside __init__.
  • Implement relevant dunder methods (__str__, __repr__, __eq__, __lt__, __add__, etc.) so your custom classes integrate naturally with built-in functions and operators.
  • Reach for functools.singledispatch when you need behavior to vary by argument type in a standalone function rather than a method.

Practice Exercises

  • Build a small Shape hierarchy with Circle, Square, and Triangle subclasses, each implementing area(). Write a function that takes a list of shapes and returns the total area, without checking any type.
  • Write a function total_length(items) that accepts any iterable of objects with a __len__ method (strings, lists, and a custom class you define) and returns the sum of their lengths using duck typing.
  • Give a custom Money class __lt__ and __eq__ methods so a list of Money instances can be sorted directly with the built-in sorted() function. Verify the order is correct for at least three values.

Summary

  • Polymorphism lets the same call site behave differently depending on the runtime type of the object involved.
  • Python supports it through duck typing (no shared base class required), method overriding (inheritance-based), and operator overloading (dunder methods).
  • Method lookup is dynamic: Python resolves obj.method() at call time by walking type(obj).__mro__, which is why overriding and duck typing work seamlessly.
  • Always call super() when you need the parent’s behavior, and keep overridden signatures compatible with the base method.
  • Python has no built-in method overloading — use default arguments, *args, or functools.singledispatch instead.