Polymorphism in Python — Missing .serialize() Export
A missing .serialize() method in a new CRM silently produced empty CSV export files.
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.
- Polymorphism: one interface, multiple behaviours
- Duck typing: any object with the right method qualifies — no inheritance needed
- Method overriding: subclasses replace or extend parent behaviour with same method name
- Operator overloading: define __add__, __eq__ etc. to make your objects work with Python syntax
- Performance insight: duck typing adds ~50ns per method call vs. direct calls — negligible until billions of calls
- Production insight: missing a duck‑typed method causes AttributeError at runtime, often far from the true source
- Biggest mistake: writing isinstance() chains instead of trusting duck typing — breaks extensibility
Polymorphism in Python is the language's ability to present the same interface for different underlying data types. Unlike statically-typed languages where polymorphism is enforced through class hierarchies and explicit interfaces, Python achieves it primarily through duck typing: if an object walks like a duck and quacks like a duck, it's treated as a duck.
This means any object that implements the expected methods can be used interchangeably, without requiring a common base class. The practical consequence is that you can write generic, reusable code that works across unrelated types—as long as they share the same method signatures.
This is why Python's works on strings, lists, dicts, and custom objects that define len()__len__, and why for x in obj works on anything with __iter__ or __getitem__.
Polymorphism in Python manifests in four concrete forms: duck typing (implicit, runtime), method overriding (explicit, via inheritance), operator overloading (via dunder methods like __add__), and abstract base classes (ABCs from abc module, which provide explicit contracts with @abstractmethod). The standard library is a masterclass in this—consider how accepts any object with a json.dumps().serialize() method, or how collections.abc defines interfaces like MutableMapping that dict and OrderedDict both satisfy.
The missing .serialize() export problem arises when you have multiple classes (User, Order, Product) that each define their own or to_dict() method, but no unified interface forces them to agree on the method name—leading to brittle serialize()if checks or runtime AttributeErrors. The solution is to either enforce a protocol via ABCs or embrace duck typing with consistent naming conventions across your codebase.isinstance()
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.
Polymorphism in Python — The Missing .serialize() Export
Polymorphism in Python means different object types respond to the same interface without sharing a base class. The core mechanic is duck typing: if an object has a .serialize() method, you can call it regardless of whether it inherits from a Serializer class. This is structural subtyping, not nominal — Python checks for the method at call time, not at class definition time. In practice, this gives you O(1) flexibility per new type: add a method with the right name and signature, and existing code that calls it works immediately. The key property is that polymorphism in Python is runtime, not compile-time — there's no compiler enforcing that your Dog class has a .speak() method before you iterate over a list of animals. This means you get late binding by default: the method resolution happens when the code executes, which is both powerful and dangerous. Use duck-typed polymorphism when you're building plugin systems, serialization pipelines, or any adapter pattern where new types arrive from external code. It matters because it lets you write generic functions that work with any object that quacks like a duck — but only if you document the expected interface clearly. Without explicit protocols (PEP 544), a missing method raises AttributeError at runtime, not at import time.
isinstance() checks or hasattr() guards at the boundary where external objects enter your system.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.
# 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)
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.
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)
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.super().__init__() is the most common production bug introduced by method overriding.super().__init__() as the first line of any overriding __init__ that extends the parent.super() is a bug waiting to happen.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.
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}")
Polymorphism with Abstract Base Classes — Explicit Contracts for Complex Systems
Duck typing works great for small utilities. But as your codebase grows, undocumented implicit contracts become a maintainability hazard. Abstract Base Classes (ABCs) solve this by making the contract explicit and enforceable at class creation time.
Using the abc module and @abstractmethod, you define a base class that cannot be instantiated directly. Any subclass must implement all abstract methods — Python raises TypeError at class creation if they're missing. This catches interface violations early, not at 2 AM in production.
Python also provides collections.abc — a set of ABCs for container types (Iterable, Sized, Mapping, etc.). Implementing these gives you standard methods (__iter__, __len__, __getitem__) for free, plus components like sorted() work automatically.
The trade-off: ABCs introduce a base class requirement. You lose the complete flexibility of duck typing. Choose ABCs when you control the hierarchy (e.g., a framework providing extension points) and duck typing when you don't (e.g., accepting arbitrary user-defined objects).
from abc import ABC, abstractmethod from collections.abc import Iterable, Sized class Drawable(ABC): """ Explicit interface: any subclass MUST implement draw(). No more guessing what methods to provide. """ @abstractmethod def draw(self): pass class Circle(Drawable): def __init__(self, radius): self.radius = radius def draw(self): print(f"Drawing a circle with radius {self.radius}") class Square(Drawable): def __init__(self, side): self.side = side def draw(self): print(f"Drawing a square with side {self.side}") # This line would raise TypeError: Can't instantiate abstract class Drawable # d = Drawable() class Canvas: def __init__(self): self.shapes = [] def add(self, shape): # We're still using duck typing here — any object with .draw() works. # But the ABC gives us confidence that the contract is solid. self.shapes.append(shape) def render_all(self): for shape in self.shapes: shape.draw() # Using collections.abc to make our own sizeable container class Team(Iterable, Sized): def __init__(self, members): self._members = members def __iter__(self): return iter(self._members) def __len__(self): return len(self._members) # Now Team works with len(), for x in, sorted() etc. my_team = Team(["Alice", "Bob", "Charlie"]) print(f"Team size: {len(my_team)}") for member in my_team: print(f" - {member}")
- ABCs: Explicit, enforceable at instantiation time. Good for framework code, public APIs, and team-owned hierarchies.
- Duck typing: Implicit, enforced at call time. Good for utility functions, third-party object integration, and small codebases.
- Hybrid approach: Use ABCs for your own base classes but accept duck-typing for parameters in public methods.
Polymorphism in Python's Standard Library — Lessons from Real Code
Python's standard library is a masterclass in polymorphism. The built-in functions len(), iter(), sorted(), reversed(), min(), max() — they all work through duck typing. They don't care about your object's type. They call __len__, __iter__, __lt__, and so on.
Consider sorted(). It accepts any iterable. Lists, tuples, dicts, sets, generators, custom iterables — all work. Why? Because sorted() doesn't check types. It calls iter() on the argument, which calls . Any object with __iter__() qualifies.__iter__()
This design pattern is called "protocol-based polymorphism." Python defines protocols (like Iterable, Sized, Callable) and any object that satisfies the protocol can be used in that context. It's the foundation of Python's flexibility.
Another example: the json module. json.dump() works with any object that implements .read() or .write() (file-like objects). You can pass it an open file, a StringIO, a BytesIO, or a custom class with .write() — it doesn't care.
Key lesson: when designing your own libraries, follow this pattern. Accept protocols, not concrete types. Your users will thank you.
# The sorted() function works on anything that's Iterable. # Here's custom iterable that generates Fibonacci numbers. class FibonacciIterable: """ Implements the Iterable protocol (__iter__) to work with sorted(). """ def __init__(self, count): self.count = count def __iter__(self): a, b = 0, 1 for _ in range(self.count): yield a a, b = b, a + b fib = FibonacciIterable(10) # Can't call sorted() on this directly? Actually sorted() calls iter() internally. # But FibonacciIterable is not a sequence, so the elements come out in order. # To demonstrate polymorphism, we can reverse it or use min/max. # Works with min() and max() — they call __iter__ print(f"Min: {min(fib)}") # 0 # Also works with list() which accepts any iterable numbers = list(fib) # But fib is exhausted after min()! So create a new one. numbers2 = list(FibonacciIterable(10)) print(f"Numbers: {numbers2}") # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] # Demonstrating file-like duck typing with json module import json from io import StringIO class CustomWriter: """A custom class that behaves like a file — has .write() method.""" def __init__(self): self.buffer = [] def write(self, text): self.buffer.append(text) def getvalue(self): return ''.join(self.buffer) writer = CustomWriter() data = {"name": "Polymorphism", "type": "duck"} json.dump(data, writer) # json.dump only requires .write() print(writer.getvalue()) # {"name": "Polymorphism", "type": "duck"}
hasattr() or use try/except and let them know what's expected.json.dump() in Python 3.6+ — the method must return the number of characters written.Method Overloading? Python Doesn't Have It — And That's Actually Smart
You came from Java or C++ and want to write two methods with the same name but different arguments. Python won't let you. The last definition wins. Period. That's not a bug — it's a design choice that saves your production code from a specific kind of rot.
Method overloading is compile-time polymorphism. It's syntactic sugar that hides branching logic behind the same function name. Python rejects that because it has a better tool: default arguments and *args. When you see a Java method that does three different things based on argument count, that's three code paths you have to test. In Python, you explicitly handle those branches with a single function signature, making the branching visible and testable.
Here's the real-world play: use optional parameters with sentinel values. Or if you genuinely need different behavior based on argument types, check types with isinstance() — but do it sparingly. The rule is one function, one behavior variant, not five. Your codebase stays flatter, your mental stack stays smaller, and your on-call rotations get fewer 'why is this method doing three things?' Slack pings.
// io.thecodeforge — python tutorial def dispatch_payment(account_id: str, amount: float, currency: str = "USD", metadata: dict | None = None): # Single method handles all payment variants via defaults # No overloading needed — Python's default args ARE the overload if currency not in {"USD", "EUR", "GBP"}: raise ValueError(f"Unsupported currency: {currency}") # If no metadata passed, treat as single-transaction if metadata is None: metadata = {"single_transaction": True} print(f"Dispatching {amount} {currency} to account {account_id}") print(f"Metadata: {metadata}") return True # Usage — same function, different call patterns if __name__ == "__main__": dispatch_payment("acct_123", 49.99) # Simple USD dispatch_payment("acct_456", 250.00, "EUR") # EUR with no metadata dispatch_payment("acct_789", 999.99, "GBP", {"batch_id": "bat_001"}) # Batch payment
create_order_from_cart() and create_order_from_subscription(). Your future self during an outage will thank you.Polymorphism in Production — The Message Dispatch Pattern You Already Use
Abstract talk about interfaces is fine for whiteboards. Here's where polymorphism saves your ass in production: event-driven message dispatch. Every service that routes messages to handlers is a polymorphic system, whether you realize it or not.
The pattern is simple: you receive a named event ("order.placed", "invoice.paid") with a payload. Instead of writing a 500-line if-elif chain, you build a registry that maps event names to handler objects. Each handler implements the same interface — a .handle() method. That's polymorphism. The calling code never knows or cares which concrete handler runs. It just calls .handle().
Why this matters at scale: when you add a new event type, you write a new handler class. You never touch the dispatcher. You never touch other handlers. The dispatcher is a single, stable, testable loop. No merge conflicts. No 'I accidentally broke payment when fixing notifications.' This is the same principle behind Python's logging handlers, threading executors, and even Django middleware. It's how real systems stay maintainable past 50k lines.
// io.thecodeforge — python tutorial from abc import ABC, abstractmethod from typing import Any # 1. Define the contract — every handler MUST have .handle() class EventHandler(ABC): @abstractmethod def handle(self, event_type: str, payload: dict[str, Any]) -> None: ... # 2. Concrete handlers — each knows how to process ONE event type class PaymentCapturedHandler(EventHandler): def handle(self, event_type: str, payload: dict[str, Any]) -> None: print(f"Capturing payment for order {payload['order_id']}") # Real logic: call payment gateway, update ledger class InvoiceGeneratedHandler(EventHandler): def handle(self, event_type: str, payload: dict[str, Any]) -> None: print(f"Sending invoice {payload['invoice_id']} to customer") # Real logic: generate PDF, email it # 3. The dispatcher — zero if-elif, pure polymorphism class EventRouter: def __init__(self): self._registry: dict[str, EventHandler] = {} def register(self, event_type: str, handler: EventHandler) -> None: self._registry[event_type] = handler def route(self, event_type: str, payload: dict[str, Any]) -> None: handler = self._registry.get(event_type) if handler is None: raise KeyError(f"No handler registered for event: {event_type}") handler.handle(event_type, payload) # Polymorphic call — one line, any handler # 4. Wire it up — one registration, no switch statements if __name__ == "__main__": router = EventRouter() router.register("payment.captured", PaymentCapturedHandler()) router.register("invoice.generated", InvoiceGeneratedHandler()) # Simulate incoming event stream router.route("payment.captured", {"order_id": "ord_1001"}) router.route("invoice.generated", {"invoice_id": "inv_5000"})
The Silent AttributeError: How a Missing .serialize() Broke Our Export Pipeline
super() and accidentally omitted the serialize method entirely.serialize() is missing, Python raises AttributeError. In this incident, the new CRM class had a serialize method that returned None because of a bug, but the symptom is similar. Let's refine: The new CRM class did not implement .serialize(); the export runner had a try/except that caught AttributeError but logged it at DEBUG level, which was not checked. So exports silently skipped the record and produced empty files.- Duck typing saves coupling but costs runtime safety — always use ABCs to enforce contracts across team boundaries.
- Never silence AttributeError in generic dispatch code. Log it loudly during development and at ERROR in production.
- Add integration tests that call every public method of every registered implementation with representative data.
hasattr() or try/except at the boundary, not scattered isinstance().print(type(obj))print([m for m in dir(obj) if not m.startswith('_')])print(type(a), type(b))print(hasattr(a, '__add__'), hasattr(b, '__radd__'))print(hasattr(obj, '__len__'))print(obj.__len__())| Aspect | Duck Typing | Method Overriding | Operator Overloading |
|---|---|---|---|
| Requires inheritance? | No — any object qualifies | Yes — subclass of a base class | No — any class can implement dunders |
| Contract enforcement | Runtime only — no compile-time check | ABC + @abstractmethod enforce at class creation | Runtime only — Python calls dunder if it exists |
| Best used when... | Writing generic utilities and libraries | Modelling is-a relationships with shared behaviour | Your class represents a value, collection or quantity |
| Failure mode | AttributeError at runtime if method is missing | TypeError if instantiating abstract class directly | TypeError if dunder not defined for the operation |
| Real-world example | django.template renders any object with .render() | unittest.TestCase — override setUp(), tearDown() | Pandas DataFrame supports +, -, *, / natively |
| Extensibility | Unlimited — just add the right method | Add new subclasses without changing caller code | Unlimited — any operator can be redefined |
| Code coupling | Very low — caller knows nothing about the type | Low — caller depends only on the base class API | Very low — caller just uses natural syntax |
Key takeaways
Common mistakes to avoid
3 patternsUsing isinstance() checks instead of embracing duck typing
hasattr() at the boundary — never scatter isinstance() throughout business logic.Forgetting to call super().__init__() in an overriding subclass
super().__init__(args, *kwargs) as the first line of a subclass __init__ that extends (rather than completely replaces) the parent's initialisation.Implementing __eq__ without also implementing __hash__
Interview Questions on This Topic
What is the difference between duck typing and traditional inheritance-based polymorphism, and when would you choose one over the other in Python?
If 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?
How 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__?
Describe a real production scenario where duck typing caused a bug and how you would prevent it.
What is the principle of least surprise in the context of operator overloading? Give an example of a bad overloading.
len() for size, str() for human-readable representation.Frequently Asked Questions
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.
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.
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.
__add__ is called when your object is on the left side of the + operator. __radd__ is the 'reflected' version called when your object is on the right side and the left object doesn't know how to add your type. Implement __radd__ when your class needs to support operations like 5 + MyClass() — without __radd__, Python would call int.__add__ with MyClass as argument, which likely returns NotImplemented, then it tries MyClass.__radd__. Example: if you have a custom Number class, implement __radd__ to handle int + CustomNumber.
You can use typing.Protocol (PEP 544) to define structural subtyping. A protocol class defines the methods and attributes that an object must have, and then you can use isinstance() with the protocol at runtime (if you use @runtime_checkable). This gives explicit contracts without requiring inheritance — objects just need to match the protocol's structure. This is more flexible than ABCs and still catches interface violations early when used with type checkers.
20+ years shipping production Python across data and backend systems. Notes here come from systems that actually shipped.
That's OOP in Python. Mark it forged?
8 min read · try the examples if you haven't