W3docs

Python Polymorphism

Learn Python polymorphism: method overriding, duck typing, operator overloading, and abstract interfaces — with clear examples.

Polymorphism (from the Greek "many forms") lets a single piece of code work with objects of different types, as long as those objects support the expected interface. You call the same method name on each object, and each object responds according to its own implementation.

Polymorphism is one of the four pillars of object-oriented programming, alongside encapsulation, inheritance, and abstraction. It is what makes functions that accept a base-class type automatically work with any subclass.

This chapter covers:

  • What polymorphism is and why it matters
  • Polymorphism through method overriding
  • Polymorphism through duck typing
  • Operator overloading — a form of polymorphism built into Python
  • Abstract base classes as a formal way to define polymorphic interfaces
  • Practical patterns and gotchas

Before reading this chapter, make sure you are comfortable with Python classes and objects and Python inheritance.

Why Polymorphism Matters

Without polymorphism, a function that works with animals would need an explicit if/elif chain for every animal type:

def make_sound(animal):
    if type(animal).__name__ == "Dog":
        print("Woof!")
    elif type(animal).__name__ == "Cat":
        print("Meow!")
    elif type(animal).__name__ == "Bird":
        print("Tweet!")
    # ... add a new branch every time you add a new animal type

That is fragile. Every new animal type requires changing this function. With polymorphism you write:

def make_sound(animal):
    animal.speak()   # works for any object that has a speak() method

Adding a new animal type requires only defining its speak() method — the function itself never changes. This is the open/closed principle: open for extension, closed for modification.

Polymorphism Through Method Overriding

The most common form of polymorphism in Python is method overriding: a subclass provides its own version of a method that was defined by the parent class.

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

    def speak(self):
        return f"{self.name} makes a sound."


class Dog(Animal):
    def speak(self):
        return f"{self.name} says woof!"


class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow!"


class Bird(Animal):
    def speak(self):
        return f"{self.name} says tweet!"

Now a single loop works across all three types:

animals = [Dog("Rex"), Cat("Whiskers"), Bird("Tweety")]

for animal in animals:
    print(animal.speak())

# Rex says woof!
# Whiskers says meow!
# Tweety says tweet!

animal.speak() dispatches to the correct version at runtime based on the actual type of the object. This runtime dispatch is called dynamic dispatch or late binding.

Extending Versus Replacing the Parent Method

When you override, you can either replace the parent's behaviour entirely or extend it using super():

class Animal:
    def speak(self):
        print("[Animal vocalization]")


class Dog(Animal):
    def speak(self):
        super().speak()           # keep the parent's output
        print("Woof! (Dog override adds this)")


Dog().speak()
# [Animal vocalization]
# Woof! (Dog override adds this)

Use super() when the parent's version does useful setup or logging that should still run. Omit it when you want to replace the behaviour entirely.

Polymorphism Through Duck Typing

Python's type system is structural rather than nominal. An object does not have to belong to a particular class hierarchy; it just needs to have the right methods. This is called duck typing — named after the saying "if it walks like a duck and quacks like a duck, it is a duck."

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


class Robot:
    def speak(self):
        return "Beep boop."


class Human:
    def speak(self):
        return "Hello!"


def introduce(entity):
    print(entity.speak())


introduce(Dog())    # Woof!
introduce(Robot())  # Beep boop.
introduce(Human())  # Hello!

Dog, Robot, and Human share no common parent class (other than the built-in object). Yet introduce() works with all three because each has a speak() method. The function does not check what type entity is — it simply calls the method and trusts that the object will respond correctly.

When to Use Duck Typing vs. Inheritance

SituationPreferred approach
Objects are logically related (all animals)Inheritance hierarchy
Objects are unrelated but share a behaviourDuck typing
You want to enforce the interface at definition timeAbstract base classes
Working with built-in types or third-party classes you cannot changeDuck typing

Duck typing is idiomatic Python and is used extensively in the standard library — for example, len() works on any object that defines __len__, regardless of its class.

Polymorphism with Functions and Loops

You can write a single function that treats different types uniformly through polymorphism. Consider a drawing application:

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

    def area(self):
        import math
        return math.pi * self.radius ** 2

    def describe(self):
        return f"Circle with radius {self.radius}"


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

    def area(self):
        return self.width * self.height

    def describe(self):
        return f"Rectangle {self.width}x{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

    def describe(self):
        return f"Triangle base={self.base} height={self.height}"


shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]

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

# Circle with radius 5: area = 78.54
# Rectangle 4x6: area = 24.00
# Triangle base=3 height=8: area = 12.00

The loop calls area() and describe() on each shape without caring which class the object belongs to. Adding a Pentagon class later requires only writing the new class — the loop does not change.

Operator Overloading

Python's arithmetic and comparison operators are polymorphic too. + on integers adds numbers; + on strings concatenates them; + on lists merges them. Python achieves this through special (dunder) methods.

You can make your own classes respond to operators by defining these methods:

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 __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

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


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

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

The same + operator now behaves differently depending on whether the operands are integers, strings, or Vector objects. This is polymorphism at the operator level.

For a deep dive into Python's special methods, see Python Magic Methods.

Polymorphism with Abstract Base Classes

Abstract base classes (ABCs) take duck typing one step further by enforcing the interface at class-definition time. If a subclass fails to implement a required method, Python raises a TypeError the moment you try to instantiate it.

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        """Return the area of the shape."""

    @abstractmethod
    def perimeter(self) -> float:
        """Return the perimeter of the shape."""


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

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

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


class Square(Shape):
    def __init__(self, side: float):
        self.side = side

    def area(self) -> float:
        return self.side ** 2

    def perimeter(self) -> float:
        return 4 * self.side


def print_info(shape: Shape) -> None:
    print(f"Area:      {shape.area():.2f}")
    print(f"Perimeter: {shape.perimeter():.2f}")


print_info(Circle(5))
# Area:      78.54
# Perimeter: 31.42

print_info(Square(4))
# Area:      16.00
# Perimeter: 16.00

If you try to instantiate a subclass that does not implement area():

class Blob(Shape):
    pass   # forgot to implement area() and perimeter()

b = Blob()
# TypeError: Can't instantiate abstract class Blob with abstract methods area, perimeter

ABCs give you the safety net of a formal contract while still allowing runtime polymorphism.

ABCs vs. Duck Typing — Which Should You Use?

  • Duck typing is simpler and more flexible. Prefer it for small internal codebases and scripts.
  • ABCs make the expected interface explicit, catch missing-method bugs early, and show up in IDE auto-complete and type checkers. Prefer them in larger codebases, public libraries, and anywhere you want to enforce a contract.

A Real-World Example: Payment Processing

Polymorphism shines in plugin-style designs. Consider a payment system that must support multiple providers:

from abc import ABC, abstractmethod


class PaymentProvider(ABC):
    @abstractmethod
    def charge(self, amount: float, currency: str) -> bool:
        """Attempt to charge the given amount. Return True on success."""

    @abstractmethod
    def refund(self, transaction_id: str) -> bool:
        """Refund a previous transaction. Return True on success."""


class StripeProvider(PaymentProvider):
    def charge(self, amount: float, currency: str) -> bool:
        print(f"[Stripe] Charged {amount} {currency}")
        return True

    def refund(self, transaction_id: str) -> bool:
        print(f"[Stripe] Refunded transaction {transaction_id}")
        return True


class PayPalProvider(PaymentProvider):
    def charge(self, amount: float, currency: str) -> bool:
        print(f"[PayPal] Charged {amount} {currency}")
        return True

    def refund(self, transaction_id: str) -> bool:
        print(f"[PayPal] Refunded transaction {transaction_id}")
        return True


def process_order(provider: PaymentProvider, amount: float) -> None:
    success = provider.charge(amount, "USD")
    if success:
        print("Order complete.")


process_order(StripeProvider(), 99.99)
# [Stripe] Charged 99.99 USD
# Order complete.

process_order(PayPalProvider(), 49.5)
# [PayPal] Charged 49.5 USD
# Order complete.

process_order does not know or care whether it receives a StripeProvider or a PayPalProvider. Adding a new CryptoProvider requires only writing the new class — nothing else changes. This is polymorphism delivering real-world extensibility.

Common Gotchas

Gotcha 1: Checking Types with type() Instead of isinstance()

Comparing type(obj) == Dog defeats polymorphism because it returns False for subclasses. Prefer isinstance(obj, Animal), which returns True for both Dog and any future subclass:

class Animal:
    pass

class Dog(Animal):
    pass

d = Dog()

# Fragile — breaks for subclasses:
print(type(d) == Animal)   # False

# Correct — subclass-aware:
print(isinstance(d, Animal))  # True

Gotcha 2: Inconsistent Method Signatures

Polymorphism assumes that all implementations of a method accept the same arguments. If Dog.speak() requires an argument that Cat.speak() does not, callers that treat them uniformly will break:

# Inconsistent — will cause errors in a loop
class Dog:
    def speak(self, volume):   # extra argument!
        return f"Woof at volume {volume}"

class Cat:
    def speak(self):
        return "Meow!"

Keep method signatures consistent across polymorphic classes.

Gotcha 3: Mutable Default Arguments in Overridden Methods

This is a broader Python gotcha, but it trips people up in class hierarchies: never use a mutable default (list, dict) as a default argument value — it is created once and shared across all calls.

# Bug: the list is shared across all instances
class Item:
    def __init__(self, tags=[]):   # BAD
        self.tags = tags

# Fix:
class Item:
    def __init__(self, tags=None):
        self.tags = tags if tags is not None else []

Summary

ConceptWhat it means
Method overridingA subclass provides its own version of a parent method
Dynamic dispatchPython picks the right method version at runtime
Duck typingAny object with the right methods works, regardless of its class
Operator overloadingDunder methods (__add__, __len__, …) make operators polymorphic
Abstract base classesFormally enforce the interface that subclasses must implement

Polymorphism is what makes code extensible without modification. Write functions that depend on behaviour (method names), not on concrete types, and your code naturally accommodates new classes without changes.

Practice

Practice
Which of the following statements about Python polymorphism are correct?
Which of the following statements about Python polymorphism are correct?
Was this page helpful?