Skip to main content

Polymorphism in Python

OOP Design

Polymorphism & Duck Typing

Python embraces duck typing: if an object implements the right methods, it can stand in for anything else.

Duck typing

Polymorphism means one piece of code working with many types. Python's default flavor needs no shared base class at all — if it walks like a duck and quacks like a duck, it's a duck:

class Duck:
def speak(self):
return "quack"

class Robot:
def speak(self):
return "beep"

def announce(speaker):
print(speaker.speak())

announce(Duck()) # quack
announce(Robot()) # beep

Duck and Robot share no ancestor. announce never checks types — it just calls .speak(), and whichever object arrives supplies its own implementation. The same idea scales to collections of mixed objects:

def send_all(notifiers: list):
for notifier in notifiers:
notifier.send("Hello")

As long as objects implement .send(), they work.

Overriding through a base class

The classic OOP form of polymorphism uses inheritance: a base class fixes the interface, subclasses override it, and callers hold references typed as the base:

class Shape:
def area(self) -> float:
raise NotImplementedError

class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h

def area(self) -> float:
return self.w * self.h

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

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

for shape in [Rectangle(3, 4), Circle(1)]:
print(shape.area())
# 12
# 3.14159

The loop body is identical for every shape; which area runs is decided at call time by the object's actual class. This buys the same flexibility as duck typing, plus a documented contract.

Polymorphism in the built-ins

You already use polymorphism constantly — the built-ins are defined against behavior, not concrete types. len works on anything with __len__; iteration works on anything with __iter__:

for obj in ["hi", [1, 2, 3], {"a": 1}, (4, 5)]:
print(len(obj))
# 2
# 3
# 1
# 2
print(sorted("cab"))          # ['a', 'b', 'c']
print(sorted((3, 1, 2))) # [1, 2, 3]
print(sorted({"b": 1, "a": 2})) # ['a', 'b'] — dicts iterate over keys

Writing your own functions against "anything iterable" or "anything with .read()" instead of list or file is what makes them composable. The hooks behind this are dunder methods.

EAFP vs LBYL

Duck typing pairs with Python's EAFP style — easier to ask forgiveness than permission. Instead of checking types up front (LBYL, look before you leap), try the operation and handle failure:

def get_port(config):
try:
return config["port"]
except (TypeError, KeyError):
return 8080

print(get_port({"port": 9000})) # 9000
print(get_port(None)) # 8080

That said, isinstance checks are legitimate when types that look alike must be treated differently. The classic case: a string is iterable, so pure duck typing would happily shred it into characters:

def normalize(tags):
if isinstance(tags, str): # strings are iterable — guard them
return [tags]
return list(tags)

print(normalize("python")) # ['python']
print(normalize(["python", "oop"])) # ['python', 'oop']

Use isinstance to dispatch between known shapes; avoid it as a gate that rejects unknown types — that's where duck typing's flexibility dies.

Interfaces

  • Use abstract base classes or Protocols to define required methods.
from abc import ABC, abstractmethod

class Notifier(ABC):
@abstractmethod
def send(self, message: str) -> None: ...

Abstract base classes enforce the contract at runtime — instantiating an incomplete subclass raises TypeError. For static typing, typing.Protocol describes duck typing to the type checker: any class with a matching send method satisfies Notifier-shaped parameters, no inheritance required. It's the formal version of "has the right methods", checked by tools like mypy rather than at runtime — see type hints.

Frequently Asked Questions

Duck typing vs inheritance-based polymorphism — which should I use?

Duck typing is the default: accept any object with the right methods and keep functions maximally reusable. Reach for a shared base class when you want an explicit, enforced contract — a plugin API, a framework extension point — or when subclasses share real implementation, not just an interface. Protocols give you a middle path: duck typing at runtime, contracts at type-check time.

Does Python have method overloading?

Not in the Java/C++ sense — defining a method twice simply replaces the first definition. The idiomatic substitutes are default arguments and *args/**kwargs for optional parameters, and functools.singledispatch when behavior should genuinely vary by argument type.

from functools import singledispatch

@singledispatch
def describe(x):
return f'object: {x}'

@describe.register
def _(x: int):
return f'int: {x}'

print(describe(3)) # int: 3
print(describe('a')) # object: a

When is an isinstance check OK?

When you dispatch between types that share an interface by accident — the string-vs-iterable case being the classic — or when validating external input at a system boundary. It becomes an anti-pattern when used to whitelist types deep inside your logic: that blocks every valid duck-typed object a caller might pass.

Next up in your learning path