Polymorphism in Python

By the Syncfusion DevTutorials team · Last updated

One name and many behaviours: method overriding, why Python has no overloading, dynamic binding, and operator overloading with magic methods.

Think about the word "play".

Play a song. Play a game. Play a part in a school drama.

One word, three different actions. Nobody is confused, because what happens depends on what you are playing.

Programs do the same. One method name, and what actually happens depends on which object you called it on.

One name, three behaviours

class Student:
    def describe(self):
        return "I study here"

class Teacher:
    def describe(self):
        return "I teach here"

class Visitor:
    def describe(self):
        return "I am visiting"

for person in [Student(), Teacher(), Visitor()]:
    print(person.describe())

The loop does not need separate code for each class. It calls describe() on each object, and Python runs that object's method.

Polymorphism means the same method name produces different behaviour depending on the object.

What You Will Learn

In this lesson, you will learn:

  • What polymorphism is, and why it removes long chains of type checks
  • How a child class replaces a parent's method
  • Why Python has no method overloading, and what it uses instead
  • What dynamic binding is, and when Python decides which method to run
  • How to give your own classes meaning for +, == and the rest

Polymorphism

What Is Polymorphism?

Polymorphism means one name can have different behaviours. In this lesson, the object receiving a method call determines which behaviour runs.

You have been using it since lesson 1:

Polymorphism you already use

print(len("Asha"))
print(len([78, 85, 60]))
print(len({"maths": 78}))

print(78 + 85)
print("78" + "85")
print([78] + [85])

len() counts characters, items and keys. + adds, joins text and joins lists. One name each time; different work behind it.

Benefits of Polymorphism

Without polymorphism, a function may need a new if branch for every kind of object it handles.

A chain that grows with every new class

class Student:
    pass

class Teacher:
    pass

class Visitor:
    pass

def describe(person):
    if isinstance(person, Student):
        return "I study here"
    elif isinstance(person, Teacher):
        return "I teach here"
    elif isinstance(person, Visitor):
        return "I am visiting"
    return "unknown"

for person in [Student(), Teacher(), Visitor()]:
    print(describe(person))

Add a Cleaner and you must find every chain like this one and add a branch. Miss one and it silently returns "unknown".

With polymorphism the knowledge lives in each class:

Each class knows its own answer

class Student:
    def describe(self):
        return "I study here"

class Teacher:
    def describe(self):
        return "I teach here"

class Visitor:
    def describe(self):
        return "I am visiting"

class Cleaner:
    def describe(self):
        return "I keep the place clean"

for person in [Student(), Teacher(), Visitor(), Cleaner()]:
    print(person.describe())

Adding Cleaner needed no change to the loop at all.

Runtime Polymorphism

Python decides which method to run when the line runs, not when the file is written. That is why one loop can handle objects it has never seen.

The decision happens as it runs

class Student:
    def describe(self):
        return "a student"

class Teacher:
    def describe(self):
        return "a teacher"

def announce(person):
    return "This is " + person.describe()

print(announce(Student()))
print(announce(Teacher()))

announce() does not need to know the object's class in advance. It calls describe() on whatever object it receives.

This is often called duck typing: if an object supports the operation you need, you can use it for that task. Python does not check for the method in advance; it tries the call, and raises AttributeError if the method is missing.

No shared parent needed

class Student:
    def describe(self):
        return "a student"

class Robot:
    def describe(self):
        return "a robot"

for thing in [Student(), Robot()]:
    print(thing.describe())

Student and Robot are unrelated classes, and the loop does not care.

When the method is missing, it fails at that moment:

An object without the method

class Student:
    def describe(self):
        return "a student"

class Chair:
    pass

for thing in [Student(), Chair()]:
    print(thing.describe())

AttributeError: 'Chair' object has no attribute 'describe'. The student's description prints first; the error occurs when Python reaches the chair and tries to call a method it does not have.

Polymorphism with Inheritance

A shared parent can provide a common method for its child classes. A child can keep that version or replace it with its own.

Person (parent)
  ├─ Student
  ├─ Teacher
  └─ Visitor

A parent setting the expectation

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

    def describe(self):
        return f"{self.name} is a person"

class Student(Person):
    def describe(self):
        return f"{self.name} is a student"

class Teacher(Person):
    def describe(self):
        return f"{self.name} teaches"

class Visitor(Person):
    pass

for person in [Student("Asha"), Teacher("Meera"), Visitor("Ravi")]:
    print(person.describe())

Visitor defined nothing, so it used the parent's version. The other two replaced it.

Method Overriding

Overriding Parent Methods

Overriding means a child defines a method the parent already has. The child's version is used.

The child's version wins

class Person:
    def greet(self):
        return "Hello"

class Student(Person):
    def greet(self):
        return "Hi, I am a student"

print(Person().greet())
print(Student().greet())

Nothing special marks an override. The name alone is enough, which is why a typo creates a new method instead of replacing one:

A typo quietly fails to override

class Person:
    def greet(self):
        return "Hello from Person"

class Student(Person):
    def gret(self):
        return "Hi from Student"

asha = Student()

print(asha.greet())
print(asha.gret())

greet() still gives the parent's answer. Nothing warned, because gret is a perfectly valid new method.

Customized Behaviour

Overriding is how each class gives its own answer to a shared question.

One question, four answers

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

    def daily_routine(self):
        return f"{self.name} comes to school"

class Student(Person):
    def daily_routine(self):
        return f"{self.name} attends classes and does homework"

class Teacher(Person):
    def daily_routine(self):
        return f"{self.name} teaches lessons and marks work"

class Principal(Teacher):
    def daily_routine(self):
        return f"{self.name} runs the school and meets parents"

for person in [Person("Ravi"), Student("Asha"), Teacher("Meera"), Principal("Ken")]:
    print(person.daily_routine())

An override can also extend the parent rather than replace it, using super():

Extending instead of replacing

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

    def details(self):
        return f"Name: {self.name}"

class Student(Person):
    def __init__(self, name, marks):
        super().__init__(name)
        self.marks = marks

    def details(self):
        return super().details() + f", Marks: {self.marks}"

print(Student("Asha", 78).details())

The child added to the parent's sentence instead of rewriting it.

Runtime Method Resolution

When you call a method, Python first looks in the object's class. If it does not find the method there, it checks parent classes in order and uses the first version it finds.

Where Python looks, in order

class Person:
    def describe(self):
        return "Person's version"

class Teacher(Person):
    def describe(self):
        return "Teacher's version"

class Principal(Teacher):
    pass

print(Principal().describe())
print([cls.__name__ for cls in Principal.__mro__])

Principal has no describe(), so Python used Teacher's and never reached Person's.

You can still reach a specific version deliberately:

Calling a particular version on purpose

class Person:
    def describe(self):
        return "Person's version"

class Student(Person):
    def describe(self):
        return "Student's version"

asha = Student()

print(asha.describe())
print(Person.describe(asha))
print(super(Student, asha).describe())

Method Overloading in Python

What Is Method Overloading?

In some languages you can write the same method name several times with different parameters, and the compiler picks the right one. That is overloading.

Python's Approach to Overloading

Python does not have it. The last definition simply replaces the earlier ones.

The second definition wins

class Report:
    def show(self, name):
        return f"Report for {name}"

    def show(self, name, marks):
        return f"Report for {name}: {marks}"

report = Report()

print(report.show("Asha", 78))
print(report.show("Asha"))

The two-argument version replaced the one-argument version, so the second call fails with a TypeError. Nothing warned when the class was defined.

Python gets the same results in other ways.

Default Arguments

One method, with values that may be left out.

One method, several ways to call it

class Report:
    def show(self, name, marks=None, subject="all subjects"):
        if marks is None:
            return f"Report for {name}"
        return f"Report for {name}: {marks} in {subject}"

report = Report()

print(report.show("Asha"))
print(report.show("Asha", 78))
print(report.show("Asha", 78, "Maths"))

This covers most of what overloading is used for, and it is clearer: one place to read, one place to change.

Variable-Length Arguments

*args and **kwargs handle a genuinely unknown number of values.

Any number of marks

class Report:
    def average(self, *marks):
        if not marks:
            return 0
        return round(sum(marks) / len(marks), 1)

report = Report()

print(report.average())
print(report.average(78))
print(report.average(78, 85, 60))

Checking the type by hand is possible when the behaviour really must differ:

Behaving differently by type

class Report:
    def add(self, value):
        if isinstance(value, (list, tuple)):
            return f"Adding {len(value)} marks"
        if isinstance(value, int):
            return f"Adding one mark: {value}"
        return f"Cannot add {type(value).__name__}"

report = Report()

print(report.add(78))
print(report.add([78, 85]))
print(report.add("seventy"))

Use this sparingly. A method that behaves completely differently for different types is usually two methods wearing one name.

Instead of overloadingPython uses
two versions, one with fewer parametersa default argument
versions taking 1, 2 or 3 values*args
versions taking different named options**kwargs
versions taking different typesone method, checking with isinstance()

Dynamic Binding

What Is Dynamic Binding?

Binding is deciding which piece of code a call refers to. Dynamic binding means the decision is made when the line runs, using the object that is actually there.

Python chooses the method using the object that is actually present when the call runs. A variable can refer to objects from different classes at different times, so the method can change from one call to the next.

Runtime Method Binding

The same line, two different methods

class Student:
    def describe(self):
        return "a student"

class Teacher:
    def describe(self):
        return "a teacher"

def announce(person):
    return person.describe()

for person in [Student(), Teacher()]:
    print(announce(person))

person.describe() is one line in the source and ran two different methods.

Dynamic Dispatch

Choosing a method at run time based on the object's class is called dynamic dispatch.

The object decides, not the variable

class Person:
    def describe(self):
        return "Person"

class Student(Person):
    def describe(self):
        return "Student"

thing = Person()
print(thing.describe())

thing = Student()
print(thing.describe())

The same variable gave two answers, because what matters is the object it points at now.

Dynamic Binding Examples

Python's lateness goes further than most languages: a method can be replaced while the program runs.

Changing behaviour at run time

class Student:
    def describe(self):
        return "a student"

asha = Student()
print(asha.describe())

def new_describe(self):
    return "a student, now with a better description"

Student.describe = new_describe

print(asha.describe())

The existing object now uses the new method because Python looks up the method each time the call runs.

This is powerful and easy to misuse. It is worth knowing about mostly to understand why Python cannot catch a misspelt method name until that line runs.

Why a typo waits until the line runs

class Student:
    def describe(self):
        return "a student"

asha = Student()

print("This line runs fine")
print(asha.descrybe())

Nothing is checked until the call happens. The first line printed; the second failed.

Because Python looks up methods when a call runs, a misspelt name may not be noticed until that line is reached. Tests should cover the different paths through a program so these errors are found sooner.

Operator Overloading

What Is Operator Overloading?

Operator overloading is teaching your own class what +, ==, < and the others should mean for it.

For example, Python cannot add two Marks objects unless the class defines what + should do:

Python will not guess

class Marks:
    def __init__(self, value):
        self.value = value

print(Marks(78) + Marks(85))

TypeError: unsupported operand type(s) for +. Python does not know what adding two Marks objects should mean, so it raises an error.

Using Magic Methods for Operator Overloading

Each operator maps to a method with two underscores on each side. These are called magic methods or dunder methods, and defining one tells Python what the operator means.

OperatorMethod
+__add__
-__sub__
*__mul__
/__truediv__
==__eq__
!=__ne__
<__lt__
<=__le__
>__gt__
>=__ge__

Overloading Arithmetic Operators

Teaching a class to add

class Marks:
    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        return Marks(self.value + other.value)

    def __sub__(self, other):
        return Marks(self.value - other.value)

    def __mul__(self, factor):
        return Marks(self.value * factor)

    def __repr__(self):
        return f"Marks({self.value})"

a = Marks(78)
b = Marks(85)

print(a + b)
print(b - a)
print(a * 2)

a + b is turned by Python into a.__add__(b). Returning a new Marks keeps the original two untouched, which is how + behaves everywhere else.

A more useful example

class MarkSheet:
    def __init__(self, marks):
        self.marks = marks

    def __add__(self, other):
        return MarkSheet(self.marks + other.marks)

    def __repr__(self):
        return f"MarkSheet({self.marks})"

term1 = MarkSheet([78, 85])
term2 = MarkSheet([60, 92])

print(term1 + term2)
print(term1)

Overloading Comparison Operators

By default, == on two objects asks whether they are the same object, so two identical records are not equal:

The default is identity

class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

a = Student("Asha", 78)
b = Student("Asha", 78)

print(a == b)
print(a is b)

__eq__ fixes that:

Teaching a class what equal means

class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

    def __eq__(self, other):
        return self.name == other.name and self.marks == other.marks

a = Student("Asha", 78)
b = Student("Asha", 78)
c = Student("Ravi", 85)

print(a == b)
print(a == c)
print(a != c)

!= came free: Python works it out from __eq__ unless you say otherwise.

__lt__ makes a class sortable, because sorting only needs "is this less than that":

One method makes a class sortable

class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

    def __lt__(self, other):
        return self.marks < other.marks

    def __repr__(self):
        return f"{self.name}({self.marks})"

students = [Student("Asha", 78), Student("Ravi", 85), Student("Meera", 60)]

print(sorted(students))
print(max(students))
print(Student("Asha", 78) < Student("Ravi", 85))

Defining all six comparisons by hand is tedious, so Python offers a shortcut:

Four more comparisons for free

from functools import total_ordering

@total_ordering
class Student:
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

    def __eq__(self, other):
        return self.marks == other.marks

    def __lt__(self, other):
        return self.marks < other.marks

a = Student("Asha", 78)
b = Student("Ravi", 85)

print(a < b, a <= b, a > b, a >= b, a == b, a != b)

@total_ordering works out the other four from __eq__ and __lt__.

A method that cannot handle the other value should say so rather than crash oddly:

Refusing politely

class Marks:
    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        if not isinstance(other, Marks):
            return NotImplemented
        return Marks(self.value + other.value)

    def __repr__(self):
        return f"Marks({self.value})"

print(Marks(78) + Marks(85))

try:
    print(Marks(78) + 5)
except TypeError as error:
    print("TypeError:", error)

Returning NotImplemented tells Python that this method cannot handle the other value. Python can then try the other operand's matching method; if neither side can handle the operation, Python raises TypeError.

Overload an operator only when the meaning is obvious to a reader. Adding two mark sheets is clear. Dividing a student by a teacher is not, and a plain method with a name would serve better.

Common mistakes

Each row is one mistake. The middle column is what you will actually see on screen.

MistakeWhat you seeWhy it happensFix
A typo in an overriding method nameThe parent's version still runsThe typo made a new method, not an overrideCheck the spelling against the parent
Two methods with the same name in one classTypeError about argumentsThe second definition replaces the firstUse default arguments or *args
Overriding and losing the parent's workBehaviour silently missingThe child replaced rather than extendedCall super().method() inside
Expecting a misspelt method to be caught earlyAttributeError deep into a runPython binds the name when the line runsTest every branch
Using + on a class that has no __add__TypeError: unsupported operand type(s)Python will not guess a meaningDefine __add__
Expecting two identical objects to be equalFalse== compares identity by defaultDefine __eq__
An __add__ that changes selfThe original object mutates+ is expected to produce a new valueReturn a new object
Raising TypeError in __add__ for an unsupported typePython cannot try the other operand's methodThe method stops the operator lookup earlyReturn NotImplemented
Defining __eq__ but needing sortingTypeError: '<' not supportedSorting needs __lt__Add __lt__, or use @total_ordering
A chain of isinstance() checks that growsA branch missed for a new classThe knowledge is outside the classesGive each class the method

What to Remember

  • Polymorphism means the same method name produces different behaviour depending on the object.
  • It replaces long isinstance() chains: each class carries its own answer, so adding a class changes nothing else.
  • Python uses duck typing — it checks that the method exists, not what class the object is.
  • Overriding is a child defining a method the parent already has. Nothing marks it, so a typo silently fails to override.
  • super().method() lets an override extend the parent instead of replacing it.
  • Python has no method overloading. A second definition replaces the first; use defaults, *args or isinstance().
  • Dynamic binding means the method is chosen when the line runs, which is why a misspelt method is only found then.
  • Magic methods teach your class what an operator means: __add__ for +, __eq__ for ==, __lt__ for <.
  • An operator should return a new object rather than changing the one on the left.
  • Return NotImplemented for a type you cannot handle, and let Python raise its own clear error.

Next: Abstraction