Python Polymorphism: Method Overriding and Duck Typing

Polymorphism lets you write code that works with objects of different types through a common interface. In statically-typed languages like Java or C++, this typically requires explicit inheritance...

Key Insights

  • Python supports two distinct forms of polymorphism: classical method overriding through inheritance and duck typing based on object behavior rather than type hierarchy
  • Duck typing embraces Python’s dynamic nature and often produces more flexible code than rigid inheritance hierarchies, following the EAFP principle over explicit type checking
  • Abstract Base Classes and Protocol types provide a middle ground, offering structural guarantees while preserving the flexibility that makes Python productive

Introduction to Polymorphism in Python

Polymorphism lets you write code that works with objects of different types through a common interface. In statically-typed languages like Java or C++, this typically requires explicit inheritance hierarchies and interface declarations. Python takes a fundamentally different approach.

Python’s dynamic type system means polymorphism is built into the language’s DNA. You don’t need to declare that a class implements an interface—if an object has the methods you’re calling, it works. This creates two distinct polymorphic patterns: traditional method overriding through inheritance, and duck typing where any object with the right behavior qualifies.

Understanding when to use each approach will make you a more effective Python developer. Inheritance provides clear contracts and IDE support. Duck typing offers flexibility and reduces coupling. Modern Python even provides tools to get the best of both worlds.

Method Overriding Fundamentals

Method overriding is the classical OOP approach to polymorphism. A child class inherits from a parent and replaces specific methods with its own implementation. Python makes this straightforward—just define a method with the same name in the child class.

class Shape:
    def __init__(self, name):
        self.name = name
    
    def calculate_area(self):
        raise NotImplementedError("Subclasses must implement calculate_area")
    
    def describe(self):
        return f"{self.name} with area {self.calculate_area()}"


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


class Circle(Shape):
    def __init__(self, radius):
        super().__init__("Circle")
        self.radius = radius
    
    def calculate_area(self):
        return 3.14159 * self.radius ** 2


# Polymorphism in action
shapes = [Rectangle(5, 3), Circle(4), Rectangle(2, 8)]

for shape in shapes:
    print(shape.describe())  # Each calls its own calculate_area()

The super() function is crucial for method overriding. It lets child classes extend rather than completely replace parent behavior. This is particularly important in __init__ methods where you often want the parent’s initialization logic plus your own additions.

Notice how describe() in the parent class calls calculate_area() without knowing which implementation will run. This is polymorphism—the same interface, different behaviors based on the actual object type.

Duck Typing: “If It Walks Like a Duck…”

Duck typing is where Python really diverges from traditional OOP languages. The principle is simple: if an object has the methods you need, it doesn’t matter what class it inherits from or what interfaces it claims to implement.

The name comes from the phrase “If it walks like a duck and quacks like a duck, it’s a duck.” In code terms: if an object has a write() method, you can treat it as writable, regardless of its actual type.

class FileWriter:
    def __init__(self, filename):
        self.filename = filename
    
    def write(self, data):
        with open(self.filename, 'a') as f:
            f.write(data + '\n')
        print(f"Wrote to file: {self.filename}")


class DatabaseWriter:
    def __init__(self, connection_string):
        self.connection = connection_string
    
    def write(self, data):
        # Simulate database write
        print(f"Inserted into database: {data}")


class APIWriter:
    def __init__(self, endpoint):
        self.endpoint = endpoint
    
    def write(self, data):
        # Simulate API call
        print(f"Posted to {self.endpoint}: {data}")


def log_event(writer, event):
    """Accepts ANY object with a write() method"""
    writer.write(f"Event: {event}")


# All three work interchangeably
log_event(FileWriter("events.log"), "User login")
log_event(DatabaseWriter("postgresql://localhost"), "Purchase completed")
log_event(APIWriter("https://api.example.com/events"), "Error occurred")

These classes share no inheritance relationship, yet they work identically with log_event(). This is duck typing in practice. The function doesn’t check types—it just calls write() and trusts the object to handle it.

This follows Python’s EAFP principle: “Easier to Ask for Forgiveness than Permission.” Rather than checking if an object is the right type before calling a method, just call it and handle any exceptions that arise.

Practical Comparison: Override vs Duck Typing

Both approaches have their place. Method overriding creates explicit contracts that IDEs understand and developers can see clearly. Duck typing maximizes flexibility and reduces coupling between components.

Consider a payment processing system implemented both ways:

# Inheritance-based approach
class Payment:
    def process(self, amount):
        raise NotImplementedError
    
    def refund(self, transaction_id):
        raise NotImplementedError


class CreditCardPayment(Payment):
    def process(self, amount):
        return f"Charged ${amount} to credit card"
    
    def refund(self, transaction_id):
        return f"Refunded transaction {transaction_id}"


class PayPalPayment(Payment):
    def process(self, amount):
        return f"Processed ${amount} via PayPal"
    
    def refund(self, transaction_id):
        return f"PayPal refund for {transaction_id}"


# Duck typing approach
class StripeProcessor:
    def process(self, amount):
        return f"Stripe: charged ${amount}"
    
    def refund(self, transaction_id):
        return f"Stripe: refunded {transaction_id}"


class CryptoProcessor:
    def process(self, amount):
        return f"Crypto: transferred ${amount}"
    
    def refund(self, transaction_id):
        return f"Crypto: reversed {transaction_id}"


def handle_payment(processor, amount):
    """Works with any processor that has process() method"""
    result = processor.process(amount)
    print(result)


# Both approaches work
handle_payment(CreditCardPayment(), 100)
handle_payment(StripeProcessor(), 50)

Use inheritance when you have a genuine “is-a” relationship and want to share implementation code. Use duck typing when you need flexibility or when objects from different domains happen to share behavior. In large codebases with multiple teams, inheritance can make contracts more explicit. In smaller projects or when integrating third-party code, duck typing reduces friction.

Abstract Base Classes: The Middle Ground

Abstract Base Classes (ABCs) from Python’s abc module provide formal interfaces while preserving flexibility. They let you define required methods without forcing rigid inheritance hierarchies.

from abc import ABC, abstractmethod


class DataProcessor(ABC):
    @abstractmethod
    def load(self, source):
        """Load data from source"""
        pass
    
    @abstractmethod
    def transform(self, data):
        """Transform the data"""
        pass
    
    @abstractmethod
    def save(self, data, destination):
        """Save transformed data"""
        pass


class CSVProcessor(DataProcessor):
    def load(self, source):
        return f"Loading CSV from {source}"
    
    def transform(self, data):
        return f"Transforming: {data}"
    
    def save(self, data, destination):
        return f"Saving to {destination}"


class JSONProcessor(DataProcessor):
    def load(self, source):
        return f"Parsing JSON from {source}"
    
    def transform(self, data):
        return f"Mapping: {data}"
    
    def save(self, data, destination):
        return f"Writing JSON to {destination}"


# This won't work - missing required methods
# class IncompleteProcessor(DataProcessor):
#     def load(self, source):
#         return "loaded"
# TypeError: Can't instantiate abstract class


def process_pipeline(processor: DataProcessor, source, dest):
    data = processor.load(source)
    transformed = processor.transform(data)
    processor.save(transformed, dest)

ABCs provide compile-time (well, instantiation-time) guarantees that objects implement required methods. You get the safety of interfaces with the flexibility of Python’s dynamic typing.

Best Practices and Common Pitfalls

Modern Python offers Protocol from the typing module for structural subtyping—duck typing with type hints. This gives you IDE autocomplete and type checker support without requiring inheritance.

from typing import Protocol


class Drawable(Protocol):
    def draw(self) -> str:
        ...


class Circle:
    def draw(self) -> str:
        return "Drawing circle"


class Square:
    def draw(self) -> str:
        return "Drawing square"


def render(shape: Drawable) -> None:
    """Type checkers verify shape has draw() method"""
    print(shape.draw())


# Both work, no inheritance needed
render(Circle())
render(Square())

Follow the Liskov Substitution Principle: subclasses should be usable wherever their parent class is expected. If overriding a method changes its fundamental behavior or contracts, you’re probably violating LSP.

Avoid over-engineering with deep inheritance hierarchies. Composition often beats inheritance. If you find yourself creating abstract base classes with only one or two implementations, duck typing might be simpler.

Test polymorphic code with multiple implementations. Don’t just test the happy path with one concrete class—verify that all implementations work correctly with your polymorphic functions.

Python’s flexibility is a strength, but with large teams, explicit ABCs or Protocols can prevent bugs and improve code comprehension. Choose the right tool for your context. When in doubt, start simple with duck typing and add structure as complexity demands it.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.