Python: Polymorphism, Method Overriding & Duck Typing

Think about the power button on a TV remote, an AC remote, and a car key fob. You press the same button on each one, but a completely different thing happens every time. The TV turns on, the AC starts cooling, the car unlocks. Same action, different result depending on what you are pressing it on. That is Python polymorphism in one picture: one name, many behaviours.

“If it walks like a duck and it quacks like a duck, then it must be a duck.”

James Whitcomb Riley

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 15 minutes

You call shape.area() and you get the right answer whether it is a circle, a rectangle, or a triangle. You call len(x) and it just works on strings, lists, dicts, and sets. One interface, many implementations underneath. That is polymorphism in Python, and the word simply means “many forms”.

Python takes this idea further than most languages with something called duck typing. Python does not care what an object is, only what it can do. If an object has the right method, it works. No inheritance required, no type hierarchy needed. This post walks you through Python polymorphism step by step, starting from the built-in examples you already use every day and finishing with a payment system you could ship to production.

Method Overriding: Same Name, Different Behavior

attribute lookup onobject’s type atruntimeCircleRectangleTriangleshape.area()What is theactual object type?Circle.area()return pi * r ** 2radius=5 78.54Rectangle.area()return length * width4×6 24Triangle.area()return 0.5 * base * height8×3 12Duck TypingIf it has area(), call it.No inheritance required.‘If it walks like a duck…’Python Polymorphism: How One area() Call Dispatches to the Right Class at Runtime

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram shows what happens when you call shape.area(). Python looks at the actual object in front of it and runs that object’s own version of area(). A Circle uses pi times radius squared, a Rectangle uses length times width, and a Triangle uses half base times height. One method name, three different answers. The code that does the calling never has to know which shape it is holding, so you can add new shapes later without touching a single line of the old code.

Method overriding is like a family recipe for dal. The base recipe comes from the parent, but each child cooks their own version: one adds more tadka, one keeps it plain. Same dish name, different result depending on who is cooking. Here is the simplest version of that idea in code. Each shape class defines its own area(), and one tiny function prints the area of any of them.

📄 method_overriding.py: each class defines its own version

from math import pi

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

    def area(self):
        raise NotImplementedError("Subclasses must implement area()")

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

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

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

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

class Triangle(Shape):
    def __init__(self, base, height):
        super().__init__("Triangle")
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height

# One function handles ALL shapes (this is polymorphism)
def print_area(shape):
    print(f"{shape.name}: {shape.area():.2f}")

shapes = [Circle(5), Rectangle(4, 6), Triangle(8, 3)]
for s in shapes:
    print_area(s)

▶ Output

Circle: 78.54
Rectangle: 24.00
Triangle: 12.00

What happened here: print_area() calls shape.area() without knowing or caring which shape it received. At the moment the call runs, Python checks the real type of the object and runs that type’s own area(). We call this method overriding: the child class (Circle, Rectangle, Triangle) replaces, or overrides, the area() it inherited from the parent Shape class. The payoff is one small function that works with any shape you have written so far, and any shape you write next year.

Duck Typing, Python’s Superpower

The shapes above all shared a parent class, Shape. Here is the surprising part: Python does not actually need that shared parent at all. It never checks “is this a Shape?” before calling area(). It just tries the call and trusts that the method is there. This is duck typing, and the quote at the top of the post explains the name. If something walks like a duck and quacks like a duck, Python is happy to treat it as a duck. It does not ask for a birth certificate. That looseness is what makes Python polymorphism feel so much lighter than the Java or C++ version.

📄 duck_typing.py: no inheritance required

# These classes share NO common parent
class Dog:
    def speak(self):
        return "Woof!"

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

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

class Rock:
    pass  # No speak() method

# Python doesn't check the type, it just calls speak()
def make_it_speak(thing):
    return thing.speak()

for creature in [Dog(), Cat(), Robot()]:
    print(make_it_speak(creature))

# This fails because Rock has no speak()
try:
    make_it_speak(Rock())
except AttributeError as e:
    print(f"Rock can't speak: {e}")

▶ Output

Woof!
Meow!
Beep boop!
Rock can't speak: 'Rock' object has no attribute 'speak'

What happened here: Dog, Cat, and Robot have no shared parent class. They are three unrelated classes that happen to each have a speak() method, so make_it_speak() works with all three. Python does not check types before the call. It just tries .speak(). If the method is there, you get your answer. If it is not, you get an AttributeError, which is exactly what Rock got. Notice the error message is precise: 'Rock' object has no attribute 'speak'. Python tried, the method was missing, and it told you which object and which attribute. That is duck typing in one line: if it has a speak() method, you can call speak() on it.

Built-in Polymorphism: len(), str(), iter()

You have been using polymorphism since your very first day with Python, probably without noticing. It works like a hotel receptionist asking every guest the same question, “your name, please?”: one fixed question, a different answer from every person. Every time you call len() on a string and then on a list, you are using one function name that behaves differently depending on what you hand it. Under the hood, len(x) just asks x “how long are you?” by calling a special method named __len__. The best part: you can teach your own classes to answer that same question.

📄 builtin_poly.py: Python’s built-in functions use polymorphism

# len() works on ANYTHING with __len__
print(len("Rahul"))         # str.__len__
print(len([1, 2, 3]))       # list.__len__
print(len({"a": 1, "b": 2}))  # dict.__len__

# Custom class with __len__
class Playlist:
    def __init__(self, name, songs):
        self.name = name
        self.songs = songs

    def __len__(self):
        return len(self.songs)

playlist = Playlist("Coding Jams", ["Track 1", "Track 2", "Track 3"])
print(len(playlist))  # Our class works with len() too!

# str() and print() use __str__
class Team:
    def __init__(self, name, members):
        self.name = name
        self.members = members

    def __str__(self):
        return f"Team {self.name} ({len(self.members)} members)"

team = Team("Backend", ["Anvi", "Vinay", "Aditi"])
print(team)  # Calls __str__ automatically

▶ Output

5
3
2
3
Team Backend (3 members)

What happened here: len() calls __len__() on whatever you pass it, so our Playlist reported 3 (the number of songs) without us writing any special logic. print() and str() call __str__() the same way, which is why printing the Team object produced a clean, readable line instead of something like <Team object at 0x...>. These special methods (the ones with double underscores) are called Python’s protocols. Define the right one, and your own objects plug straight into the built-in functions. The magic methods tutorial covers the full set in detail.

Real-World Python Polymorphism: Payment Processing

Time for something you would actually build at work. Picture the checkout page of any Indian shopping app. The customer can pay by credit card, by UPI (Unified Payments Interface), or from their in-app wallet. Three very different things happen behind the scenes, but the checkout button does one thing: charge the amount. That is a textbook job for polymorphism. Each payment type knows how to charge itself, and the checkout code stays blissfully simple.

📄 payment_polymorphism.py: one interface, multiple payment methods

class PaymentMethod:
    def charge(self, amount):
        raise NotImplementedError

class CreditCard(PaymentMethod):
    def __init__(self, card_number):
        self.card_number = card_number

    def charge(self, amount):
        last4 = self.card_number[-4:]
        return f"Charged Rs.{amount} to card ending {last4}"

class UPI(PaymentMethod):
    def __init__(self, upi_id):
        self.upi_id = upi_id

    def charge(self, amount):
        return f"Charged Rs.{amount} via UPI ({self.upi_id})"

class Wallet(PaymentMethod):
    def __init__(self, balance):
        self.balance = balance

    def charge(self, amount):
        if amount > self.balance:
            return f"Insufficient wallet balance (Rs.{self.balance})"
        self.balance -= amount
        return f"Charged Rs.{amount} from wallet (remaining: Rs.{self.balance})"

# Process any payment method, with zero if/else checks
def process_payment(method, amount):
    result = method.charge(amount)
    print(f"  {result}")

methods = [
    CreditCard("4111222233334444"),
    UPI("rahul@paytm"),
    Wallet(500),
]

for m in methods:
    process_payment(m, 299)

▶ Output

  Charged Rs.299 to card ending 4444
  Charged Rs.299 via UPI (rahul@paytm)
  Charged Rs.299 from wallet (remaining: Rs.201)

What happened here: process_payment() has no if isinstance() checks anywhere. It just calls method.charge(amount) and lets each payment type do its own thing. The card masks itself down to the last four digits, UPI shows the handle, and the wallet actually subtracts from a running balance. Tomorrow your boss asks for NetBanking and Crypto. You write two new classes, each with its own charge(), and you change nothing in process_payment(). Code that is open to new features but closed to edits has a name in software design: the Open/Closed Principle. Polymorphism is how you get there.

The Catch: A Silent Wrong Answer

Here is the one thing that bites everybody with polymorphism. You set up a parent class, you write a few children that override the method, and life is good. Then one day you (or a teammate) add a new child class and forget to write the method. If the parent quietly returns something instead of complaining, your program keeps running and hands back a wrong answer. No crash, no warning, just a bug that surfaces three days later in production.

The fix is to make the parent method loud. Raise NotImplementedError in the base class so any child that forgets to override fails fast, right where the mistake is, with a clear message. Watch what happens when a teammate named Aviraj adds an Email channel but forgets the send() method.

📄 notifications.py: a forgotten override that fails fast

class Notification:
    def send(self, message):
        raise NotImplementedError("Subclasses must implement send()")

class SMS(Notification):
    def send(self, message):
        return f"SMS sent: {message}"

# Aviraj adds Email but forgets to write send()
class Email(Notification):
    pass

def notify(channel, message):
    return channel.send(message)

print(notify(SMS(), "Build passed"))
print(notify(Email(), "Build passed"))

▶ Output

SMS sent: Build passed
Traceback (most recent call last):
  File "notifications.py", line 17, in <module>
    print(notify(Email(), "Build passed"))
          ~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
  File "notifications.py", line 14, in notify
    return channel.send(message)
           ~~~~~~~~~~~~^^^^^^^^^
  File "notifications.py", line 3, in send
    raise NotImplementedError("Subclasses must implement send()")
NotImplementedError: Subclasses must implement send()

What happened here: The SMS line worked fine because SMS overrode send(). The Email line blew up the instant it tried to send, because Email inherited the parent’s send(), which does nothing but raise NotImplementedError. That is exactly what you want. The traceback points straight at the problem and names the method that is missing. Compare that to a silent None coming back: this version turns a hidden bug into an obvious one. (If you want Python to catch the missing method even earlier, when the object is created rather than when it is used, that is the job of abstract base classes, covered in the abstract classes tutorial.)

When You Will Use This

Python polymorphism is not a fancy idea you admire from a distance. You reach for it the moment your code starts asking “what type is this?” over and over. Three everyday situations:

  • Report exporters: PDFReport, CSVReport, and HTMLReport each define their own render(). The calling code just loops over the reports and calls render() on each, no type checks anywhere.
  • Notification channels: email, SMS, and push notifications all expose the same send(message). Adding a WhatsApp channel later means writing one new class, not touching the code that sends alerts.
  • File-format parsers: a parse() method on JSONParser, CSVParser, and XMLParser lets one import pipeline handle any file type. You pick the parser once, then the rest of the pipeline stays format-blind.

Go deeper: the official Python documentation covers every edge case of this topic.

Frequently Asked Questions

What is polymorphism in Python?

Python polymorphism means one method name producing different behavior depending on the object it is called on. speak() on a Dog barks, on a Cat it meows, and the calling code never checks types. You get it through method overriding in subclasses or through duck typing, where any object with the right method works.

What is duck typing in Python?

Duck typing means Python cares about what an object can do, not what class it is. If an object has a speak() method, you can call speak() on it, no shared parent class required. Python simply tries the call and raises AttributeError if the method is missing.

Does Python support method overloading?

Not in the Java or C++ sense: defining the same method twice with different parameters just overwrites the first definition. Python gets the same effect with default arguments, *args, and keyword arguments in a single method. Method overriding across classes is where Python does its real polymorphic work.

How do I force a subclass to implement a method?

Two common options. Raise NotImplementedError in the parent method so a forgotten override fails loudly the first time it is called. Or go stricter with the abc module: inherit from ABC and mark the method with @abstractmethod, and Python refuses to even create an instance of an incomplete subclass.

Is inheritance required for polymorphism in Python?

No. Thanks to duck typing, three completely unrelated classes that each define a charge() method can all be passed to the same function. Inheritance is still useful when you want shared default behavior or a loud NotImplementedError safety net, but Python polymorphism works fine without any parent class.

Interview Questions on Python Polymorphism

Scenario questions, not trivia: this is the form this topic takes in a real interview.

Q: Design a payment system where new payment methods can be added without touching existing code. What does polymorphism buy you here?

Every payment method implements the same interface, say pay() and refund(). The checkout code calls method.pay(amount) without a single isinstance check, so adding PayPal or UPI later means writing one new class and registering it. The dispatcher never changes, which is the open-closed principle working for you.

Q: You inherit a codebase full of isinstance chains deciding behavior. How do you refactor, and when is isinstance actually the right tool?

Move each branch’s behavior into a method that all the classes implement, then replace the chain with one polymorphic call, or use functools.singledispatch when you cannot modify the classes. isinstance stays legitimate at system boundaries, like validating untrusted input or distinguishing genuinely unrelated types, but not as a hand-rolled dispatch table.

Q: Duck typing accepted an object with the right method name but the wrong behavior, and production got a silent wrong answer. How do you protect against that?

Names are not contracts. Define the contract explicitly with an abstract base class or typing.Protocol so mypy flags mismatches, and write tests against the contract that every implementation must pass. For critical paths, @runtime_checkable protocols or explicit validation catch impostors before they corrupt results.

Q: How does len() work on a class you wrote yourself, and what does that say about polymorphism in Python’s built-ins?

len(obj) simply calls obj.__len__(). The built-ins dispatch through dunder protocols, so your class plugs into the exact same machinery as list or dict. The same story holds for str() calling __str__ and iteration calling __iter__: built-in polymorphism is protocol dispatch all the way down.

Q: The + operator adds numbers but concatenates strings. How does Python resolve that, and how would you support + for your own type?

Python calls the left operand’s __add__; if that returns NotImplemented, it tries the right operand’s __radd__. You support + by implementing those methods and returning NotImplemented for types you do not handle, so Python can fall back cleanly instead of raising a confusing error.

Previous: Python: Multiple Inheritance & Method Resolution Order (MRO)

Next: Python Encapsulation: Private, Protected & Name Mangling

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *