Duck typing works, until it doesn't
Duck typing is powerful, but it offers no guarantees. If you define a NotificationService that all notification backends should implement, nothing stops someone from writing a backend that forgets one of the required methods. The error only shows up at runtime, when the missing method is actually called.
1# A contract described in a comment is not enforced2# All backends must implement: send(to, subject, body)34class EmailBackend:5 def send(self, to, subject, body):6 print(f"Emailing {to}: {subject}")78class SMSBackend:9 def send(self, to, subject, body):10 print(f"Texting {to}: {body}")1112class SlackBackend:13 def notify(self, to, message): # oops, wrong method name14 print(f"Slacking {to}: {message}")1516def notify_user(backend, user):17 backend.send(user.email, "Hello", "Welcome!")1819# This fails at runtime, not at class definition time20slack = SlackBackend()21notify_user(slack, user) # AttributeError: 'SlackBackend' has no 'send'
For a small team or a personal project, this is often fine. For a library or a large codebase where multiple people implement the same interface, you want the error earlier. Ideally when someone defines the class, not when they call it in production.
Abstract base classes give you that earlier error.
ABC and @abstractmethod
Python's abc module provides ABC (Abstract Base Class) and the @abstractmethod decorator. When you mark a method as abstract, any class that inherits from your ABC but does not implement that method cannot be instantiated. The error happens at class instantiation, not at the call site.
1from abc import ABC, abstractmethod23class NotificationBackend(ABC):4 @abstractmethod5 def send(self, to: str, subject: str, body: str) -> None:6 """Send a notification to the given address."""78class EmailBackend(NotificationBackend):9 def send(self, to, subject, body):10 print(f"Emailing {to}: {subject}")1112class SlackBackend(NotificationBackend):13 def notify(self, to, message): # wrong name, not implementing send14 print(f"Slacking {to}: {message}")1516# EmailBackend is fine17e = EmailBackend() # works1819# SlackBackend fails immediately, at instantiation20s = SlackBackend() # TypeError
The error message is clear and tells you exactly what is missing. Compare that to the duck typing version, where you get AttributeError at some point during program execution, possibly deep in a call stack.
ABCs can share implementation too
Abstract methods define the contract. But the ABC can also provide concrete methods that are inherited by all subclasses. This is the distinction between an ABC and a pure interface.
1from abc import ABC, abstractmethod23class NotificationBackend(ABC):4 @abstractmethod5 def send(self, to: str, subject: str, body: str) -> None:6 pass78 def send_bulk(self, recipients: list, subject: str, body: str) -> None:9 # Concrete method shared by all backends10 for to in recipients:11 self.send(to, subject, body)1213class EmailBackend(NotificationBackend):14 def send(self, to, subject, body):15 print(f"Emailing {to}: {subject}")1617e = EmailBackend()18e.send_bulk(["alice@example.com", "bob@example.com"], "Hi", "Hello!")
send_bulk() is implemented once and works for every backend, because it calls self.send() which each backend implements its own way.
Protocol: structural subtyping
ABCs use nominal subtyping: to be considered a NotificationBackend, you must explicitly inherit from it. Python 3.8 introduced Protocol from the typing module, which uses structural subtyping: any class that has the right methods qualifies, regardless of what it inherits from.
1from typing import Protocol23class Sendable(Protocol):4 def send(self, to: str, subject: str, body: str) -> None:5 ...67# No inheritance required8class EmailBackend:9 def send(self, to, subject, body):10 print(f"Emailing {to}: {subject}")1112class SMSBackend:13 def send(self, to, subject, body):14 print(f"Texting {to}: {body}")1516def notify(backend: Sendable, to: str, subject: str, body: str) -> None:17 backend.send(to, subject, body)1819# Both pass the type checker, neither inherits from Sendable20notify(EmailBackend(), "alice@example.com", "Hi", "Hello")21notify(SMSBackend(), "+1555000000", "Hi", "Hello")
Protocols are primarily a type-checking tool. A static type checker like mypy or pyright will flag code that passes the wrong type without you needing to add isinstance() checks everywhere.
runtime_checkable
By default, you cannot use isinstance(x, Sendable) at runtime. Add the @runtime_checkable decorator if you need that:
1from typing import Protocol, runtime_checkable23@runtime_checkable4class Sendable(Protocol):5 def send(self, to: str, subject: str, body: str) -> None:6 ...78class EmailBackend:9 def send(self, to, subject, body):10 print(f"Emailing {to}: {subject}")1112e = EmailBackend()13print(isinstance(e, Sendable)) # True -- has .send()
ABC vs Protocol: when to use which
There is no single right answer, but there are clear defaults.
Use an ABC when:
- You own the full hierarchy and want enforcement at class definition time.
- The abstract base provides shared concrete methods that all subclasses should inherit.
- You want
isinstance()checks to reflect membership explicitly.
Use a Protocol when:
- You are writing library or utility code and want to accept any compatible object.
- The implementors do not or cannot inherit from your base class.
- You want duck typing but with type-checker enforcement.
1# ABC: you own everything, want enforcement and shared code2from abc import ABC, abstractmethod34class StorageBackend(ABC):5 @abstractmethod6 def read(self, key: str) -> bytes: ...78 @abstractmethod9 def write(self, key: str, data: bytes) -> None: ...1011 def exists(self, key: str) -> bool: # shared concrete method12 try:13 self.read(key)14 return True15 except KeyError:16 return False1718# Protocol: you want to accept anything with a .read() method19from typing import Protocol2021class Readable(Protocol):22 def read(self, n: int = -1) -> bytes: ...2324def process(source: Readable) -> None:25 data = source.read()26 # works with open() file handles, BytesIO, network streams, anything
The Readable protocol in the example above already matches Python's built-in file objects, the BytesIO class, network sockets, and any third-party object with a read() method. None of them need to know about your Protocol. That flexibility is the whole point.