Home Python Polymorphism in Python Explained — Types, Examples and Real-World Patterns

Polymorphism in Python Explained — Types, Examples and Real-World Patterns

In Plain English 🔥
Imagine a universal TV remote. You press 'Volume Up' and it works whether the TV is a Samsung, Sony, or LG — you don't care what's inside the box, you just press the button and it responds correctly. Polymorphism is the same idea in code: one interface, many behaviours. The same function call or operator can produce different results depending on the object you hand it, without you needing to know what type that object is.
⚡ Quick Answer
Imagine a universal TV remote. You press 'Volume Up' and it works whether the TV is a Samsung, Sony, or LG — you don't care what's inside the box, you just press the button and it responds correctly. Polymorphism is the same idea in code: one interface, many behaviours. The same function call or operator can produce different results depending on the object you hand it, without you needing to know what type that object is.

Most Python developers learn about classes and objects fairly quickly. But polymorphism is where OOP stops being a theoretical exercise and starts being genuinely useful in production code. It's the reason Django can swap database backends, why unittest works with any test class you throw at it, and how Python's built-in functions like len() and sorted() work seamlessly across dozens of different data types. Without polymorphism, you'd be writing a brittle forest of if/elif blocks just to handle different object types.

The problem polymorphism solves is coupling. When your code has to inspect an object's type before deciding what to do with it — isinstance() checks everywhere, type-specific branches all over the place — it becomes fragile. Add a new type and you have to hunt down every single branch. Polymorphism flips this: you define a contract (an interface, or simply a method name), and every object that honours that contract can be used interchangeably. Your calling code stays clean and stable even as the ecosystem of objects around it grows.

By the end of this article you'll understand Python's three main flavours of polymorphism — duck typing, method overriding through inheritance, and operator overloading — know exactly when to reach for each one, and have working code patterns you can drop into real projects today. You'll also know the gotchas that trip up intermediate developers and how to talk about polymorphism confidently in an interview.

Duck Typing — Python's Most Powerful (and Most Misunderstood) Form of Polymorphism

Duck typing comes from the phrase 'if it walks like a duck and quacks like a duck, it's a duck.' Python doesn't care about an object's class or inheritance chain. It cares whether the object has the method or attribute you're trying to call. That's it.

This is fundamentally different from Java or C#, where polymorphism typically requires a shared base class or interface declaration. In Python, the contract is implicit. Any object that implements the expected behaviour qualifies — no registration, no declaration needed.

This is why len() works on strings, lists, tuples, dicts, and any custom class that defines __len__. Python doesn't check types — it just calls the method. This design makes Python incredibly flexible for writing generic utilities that work across unrelated types.

The real-world payoff: you can write a function that processes any object with a .render() method — whether it's an HTML widget, a PDF template, or a console output formatter — and your function never needs to change as new types are added. That's open/closed principle in practice, powered by duck typing.

duck_typing_renderer.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# Three completely unrelated classes — no shared base class
class HtmlWidget:
    def __init__(self, content):
        self.content = content

    def render(self):
        # Returns an HTML string representation
        return f"<div>{self.content}</div>"


class PdfSection:
    def __init__(self, text):
        self.text = text

    def render(self):
        # Returns a simulated PDF text block
        return f"[PDF BLOCK] {self.text}"


class ConsoleMessage:
    def __init__(self, message):
        self.message = message

    def render(self):
        # Returns a plain terminal string
        return f">>> {self.message}"


def display_all(renderable_items):
    """
    This function doesn't know or care what TYPE each item is.
    It only requires that each item has a .render() method.
    That's duck typing — the contract is the method, not the class.
    """
    for item in renderable_items:
        print(item.render())  # Calls whichever .render() belongs to this object


# Mix completely different types in the same list — Python handles it gracefully
page_components = [
    HtmlWidget("Welcome to TheCodeForge"),
    PdfSection("Chapter 1: Polymorphism"),
    ConsoleMessage("Build complete — 0 errors"),
]

display_all(page_components)
▶ Output
<div>Welcome to TheCodeForge</div>
[PDF BLOCK] Chapter 1: Polymorphism
>>> Build complete — 0 errors
⚠️
Pro Tip:Use duck typing when writing utility functions and libraries. Instead of checking isinstance(obj, SomeClass), just try calling the method and let Python's natural AttributeError tell you when something genuinely doesn't qualify. This keeps your utilities extensible without any changes.

Method Overriding — Making Inheritance Actually Useful

Inheritance without polymorphism is just code reuse. Inheritance WITH polymorphism — method overriding — is where the real design power lives. When a subclass provides its own version of a method defined in a parent class, Python always calls the most specific version. This is method overriding, and it's the backbone of the Template Method and Strategy patterns.

The critical insight: the calling code doesn't need to change when you add a new subclass. You write code against the base class interface, and subclasses plug in seamlessly. This is the Open/Closed Principle — open for extension, closed for modification.

Python also gives you super() to call the parent's version when you want to extend rather than completely replace the parent's behaviour. Knowing when to extend vs. replace is a mark of an experienced developer.

A practical example: imagine a notification system. You have an abstract Notification base class with a .send() method. Email, SMS, and Slack subclasses each override .send() differently. The code that triggers notifications just calls .send() on whatever object it receives — it never needs to know which channel it's talking to.

notification_system.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
from abc import ABC, abstractmethod
import datetime


class Notification(ABC):
    """
    Abstract base class — defines the CONTRACT every notification must honour.
    You cannot instantiate this directly; it's a blueprint only.
    """

    def __init__(self, recipient, message):
        self.recipient = recipient
        self.message = message
        self.timestamp = datetime.datetime.now().strftime("%H:%M:%S")

    @abstractmethod
    def send(self):
        """
        Every subclass MUST implement this method.
        The @abstractmethod decorator enforces the contract at class creation time.
        """
        pass

    def log(self):
        # Shared behaviour — all notifications log themselves the same way
        # Subclasses inherit this without overriding it
        print(f"[{self.timestamp}] Notification queued for {self.recipient}")


class EmailNotification(Notification):
    def __init__(self, recipient, message, subject):
        super().__init__(recipient, message)  # Reuse parent's __init__ logic
        self.subject = subject

    def send(self):
        # Overrides the abstract send() with email-specific behaviour
        self.log()  # Calls the shared parent method
        print(f"  EMAIL to {self.recipient}")
        print(f"  Subject: {self.subject}")
        print(f"  Body: {self.message}")


class SmsNotification(Notification):
    def send(self):
        # Completely different implementation — same method name, different behaviour
        self.log()
        print(f"  SMS to {self.recipient}: {self.message[:160]}")  # SMS character limit


class SlackNotification(Notification):
    def __init__(self, recipient, message, channel):
        super().__init__(recipient, message)
        self.channel = channel

    def send(self):
        self.log()
        print(f"  SLACK -> #{self.channel} @{self.recipient}: {self.message}")


def dispatch_notifications(notifications):
    """
    This function is completely decoupled from the specific notification types.
    Add a new channel (PushNotification, WhatsApp...) and this function
    needs ZERO changes — that's polymorphism delivering real value.
    """
    for notification in notifications:
        notification.send()  # Python resolves the correct .send() at runtime
        print()  # Blank line for readability


# Build a mixed list of notification types
notification_queue = [
    EmailNotification("alice@example.com", "Your order has shipped!", "Order Update"),
    SmsNotification("+447911123456", "Your OTP is 847291. Valid for 5 minutes."),
    SlackNotification("deployment-bot", "Production deploy successful v2.4.1", "engineering"),
]

dispatch_notifications(notification_queue)
▶ Output
[14:32:07] Notification queued for alice@example.com
EMAIL to alice@example.com
Subject: Order Update
Body: Your order has shipped!

[14:32:07] Notification queued for +447911123456
SMS to +447911123456: Your OTP is 847291. Valid for 5 minutes.

[14:32:07] Notification queued for deployment-bot
SLACK -> #engineering @deployment-bot: Production deploy successful v2.4.1
⚠️
Watch Out:If you forget to call super().__init__() in a subclass that adds its own __init__, you silently skip the parent's setup code. The object gets created without error, but self.recipient, self.timestamp and any other parent-set attributes won't exist — leading to confusing AttributeErrors later, not at the point of the mistake.

Operator Overloading — Teaching Python's Built-in Syntax to Understand Your Objects

When you write vector_a + vector_b or order_total > discount_threshold, Python is calling special dunder (double-underscore) methods behind the scenes. Operator overloading lets you define what those operators mean for your custom classes. This is polymorphism at the syntax level.

The + operator calls __add__, == calls __eq__, len() calls __len__, str() calls __str__, and so on. By implementing these, your objects participate in Python's native syntax seamlessly. They feel like built-in types.

This isn't just cosmetic. When your ShoppingCart supports len(), you can use it with Python's built-in sorted(), min(), max(), and any third-party library that expects standard Python behaviour. Your custom type becomes a first-class Python citizen.

The rule of thumb: implement dunder methods when your object represents a value or container that has a natural meaning for that operation. A Vector genuinely should support addition. A DatabaseConnection probably shouldn't define __add__ — that would be confusing, not clever.

shopping_cart_overloading.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __repr__(self):
        # Called when Python needs a developer-friendly string representation
        # e.g. inside a list, or in the REPL
        return f"Product('{self.name}', £{self.price:.2f})"


class ShoppingCart:
    def __init__(self, owner):
        self.owner = owner
        self.items = []  # Stores Product objects

    def add(self, product):
        self.items.append(product)
        return self  # Enables method chaining: cart.add(a).add(b)

    def __len__(self):
        # len(cart) now works naturally — returns number of items
        return len(self.items)

    def __contains__(self, product_name):
        # 'in' operator: 'Headphones' in cart
        return any(item.name == product_name for item in self.items)

    def __add__(self, other_cart):
        """
        Merge two carts with the + operator.
        Returns a brand-new cart — doesn't mutate either original.
        Follows the principle of least surprise: x + y shouldn't change x.
        """
        merged = ShoppingCart(f"{self.owner} & {other_cart.owner}")
        merged.items = self.items + other_cart.items  # Combine item lists
        return merged

    def __gt__(self, other_cart):
        # cart_a > cart_b compares total value — feels natural
        return self.total() > other_cart.total()

    def __str__(self):
        # str(cart) or print(cart) gives a human-readable summary
        item_lines = "\n".join(f"  - {item.name}: £{item.price:.2f}" for item in self.items)
        return f"Cart ({self.owner}):\n{item_lines}\n  TOTAL: £{self.total():.2f}"

    def total(self):
        return sum(item.price for item in self.items)


# --- Putting it all together ---

alice_cart = ShoppingCart("Alice")
alice_cart.add(Product("Keyboard", 79.99)).add(Product("Mouse", 29.99))

bob_cart = ShoppingCart("Bob")
bob_cart.add(Product("Headphones", 149.99)).add(Product("USB Hub", 24.99))

# __len__ in action
print(f"Alice has {len(alice_cart)} items in her cart")

# __contains__ in action
print(f"'Mouse' in Alice's cart: {'Mouse' in alice_cart}")
print(f"'Headphones' in Alice's cart: {'Headphones' in alice_cart}")

# __gt__ comparison
print(f"Bob's cart is more expensive: {bob_cart > alice_cart}")

# __add__ to merge carts
combined_cart = alice_cart + bob_cart
print(f"\nMerged cart has {len(combined_cart)} items")

# __str__ for a clean printout
print(f"\n{combined_cart}")
▶ Output
Alice has 2 items in her cart
'Mouse' in Alice's cart: True
'Headphones' in Alice's cart: False
Bob's cart is more expensive: True

Merged cart has 4 items

Cart (Alice & Bob):
- Keyboard: £79.99
- Mouse: £29.99
- Headphones: £149.99
- USB Hub: £24.99
TOTAL: £284.96
🔥
Interview Gold:Interviewers love asking 'how does Python know what + means for your custom class?' The answer is: Python checks for __add__ on the left operand first. If that returns NotImplemented, it tries __radd__ on the right operand. Knowing this reflection mechanism shows genuine depth.
AspectDuck TypingMethod OverridingOperator Overloading
Requires inheritance?No — any object qualifiesYes — subclass of a base classNo — any class can implement dunders
Contract enforcementRuntime only — no compile-time checkABC + @abstractmethod enforce at class creationRuntime only — Python calls dunder if it exists
Best used when...Writing generic utilities and librariesModelling is-a relationships with shared behaviourYour class represents a value, collection or quantity
Failure modeAttributeError at runtime if method is missingTypeError if instantiating abstract class directlyTypeError if dunder not defined for the operation
Real-world exampledjango.template renders any object with .render()unittest.TestCase — override setUp(), tearDown()Pandas DataFrame supports +, -, *, / natively
ExtensibilityUnlimited — just add the right methodAdd new subclasses without changing caller codeUnlimited — any operator can be redefined
Code couplingVery low — caller knows nothing about the typeLow — caller depends only on the base class APIVery low — caller just uses natural syntax

🎯 Key Takeaways

  • Duck typing means Python checks for the method, not the type — write generic functions against method names, not class hierarchies, and your code stays open to new types without modification.
  • Method overriding is only useful when combined with a clear base-class contract — use ABC and @abstractmethod to make that contract explicit and enforced at class creation, not silently broken at runtime.
  • Operator overloading via dunder methods makes your objects feel native to Python — but only implement operators that have a natural, unsurprising meaning for your domain (a Vector should support +; a DatabaseSession should not).
  • The real value of polymorphism is decoupling — when your calling code never needs to inspect types or grow new branches to handle new objects, your architecture is genuinely extensible and your tests stay stable.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using isinstance() checks instead of embracing duck typing — Symptoms: your function has multiple elif isinstance(obj, SomeClass) branches that grow every time a new type is added, and adding a new type means editing core logic — Fix: define a common method name (.process(), .render(), .execute()) and just call it. If callers need a safety net, catch AttributeError or use hasattr() at the boundary, not scattered throughout business logic.
  • Mistake 2: Forgetting to call super().__init__() in an overriding subclass — Symptom: the object is created silently but then throws AttributeError when accessing attributes the parent was supposed to set (e.g. self.timestamp, self.recipient), often several method calls later, making it hard to trace — Fix: always call super().__init__(args, *kwargs) as the first line of a subclass __init__ that extends (rather than completely replaces) the parent's initialisation.
  • Mistake 3: Implementing __eq__ without also implementing __hash__ — Symptom: your custom objects can't be used as dictionary keys or in sets — Python sets the class's __hash__ to None automatically when you define __eq__ without __hash__, causing TypeError: unhashable type — Fix: if your object needs to be hashable after defining __eq__, also define __hash__. A common pattern is __hash__ = object.__hash__ if equality doesn't affect the object's identity for hashing purposes, or define a hash based on the same fields you compare in __eq__.

Interview Questions on This Topic

  • QWhat is the difference between duck typing and traditional inheritance-based polymorphism, and when would you choose one over the other in Python?
  • QIf you define __eq__ on a custom Python class, what else do you need to be aware of, and why does Python change the class's hashing behaviour automatically?
  • QHow does Python resolve which method to call when you use the + operator between two objects of different custom types — walk me through the lookup mechanism including __radd__?

Frequently Asked Questions

What are the types of polymorphism in Python?

Python supports three main types: duck typing (calling methods on any object that defines them, regardless of class), method overriding through inheritance (subclasses provide their own implementation of a parent's method), and operator overloading via dunder methods (defining what +, ==, len(), etc. mean for your custom class). Python does not support traditional method overloading (same method name, different parameter counts) — the last definition wins, so you handle multiple signatures with default arguments or *args instead.

Is duck typing the same as polymorphism in Python?

Duck typing is one mechanism through which polymorphism is achieved in Python — arguably the most Pythonic one. Polymorphism is the broader concept: one interface, multiple behaviours. Duck typing implements it without requiring a shared class hierarchy. Method overriding and operator overloading are the other two mechanisms, each suited to different design situations.

Do you need inheritance to use polymorphism in Python?

No. This is one of Python's most important differences from Java or C#. Thanks to duck typing, any two unrelated classes that both implement a .send() method (for example) can be used interchangeably by code that calls .send() — no shared base class required. Inheritance-based polymorphism via method overriding is one tool, but it's not the only one, and it's often not the right one.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousInheritance in PythonNext →Encapsulation in Python
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged