Python Protocol vs ABC: Choosing the Right Abstraction
A practical comparison of Python's typing.Protocol and abc.ABC: structural vs nominal subtyping, isinstance behavior, and when to use each.
When you need to define a common interface in Python, two standard tools are abc.ABC and typing.Protocol. The choice shapes how contracts are enforced, how isinstance behaves, and how much flexibility callers have. Both let you declare methods that implementing classes are expected to provide, but they differ fundamentally in how conformance is verified.
What ABC Provides: Nominal Subtyping
abc.ABC is the classic way to create abstract base classes. You define a class that inherits from ABC, mark methods with @abstractmethod, and any subclass must implement those methods before it can be instantiated.
from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: pass class Circle(Shape): def __init__(self, radius: float): self.radius = radius def area(self) -> float: return 3.14159 * self.radius ** 2
Here Circle is a subtype of Shape because it inherits from it. This is nominal subtyping: the relationship is explicit, usually by inheritance, or by registration with register() for virtual subclasses. isinstance(circle, Shape) returns True because of the inheritance chain.
ABCs are useful when you control the class hierarchy and want to enforce a contract at instantiation time. If a subclass forgets to implement an abstract method, Python raises a TypeError when you try to instantiate it, not later when the method is called.
What Protocol Provides: Structural Subtyping
typing.Protocol (PEP 544) enables structural subtyping. For type checkers, a class conforms to a Protocol if it has the required methods and attributes, regardless of whether it inherits from the protocol. You define a protocol by subclassing Protocol and declaring method signatures.
from typing import Protocol class AreaCalculable(Protocol): def area(self) -> float: ... class Square: def __init__(self, side: float): self.side = side def area(self) -> float: return self.side ** 2
Square does not inherit from AreaCalculable, but it is structurally compatible. When you annotate a function parameter with AreaCalculable, type checkers like mypy accept Square because it has the matching area method.
Protocols are useful when you want to accept any object that provides the required behavior, even if the object comes from a third-party library or was not designed with your interface in mind.
Key Differences Between Protocol and ABC
| Aspect | abc.ABC | typing.Protocol |
|---|---|---|
| Subtyping model | Nominal (explicit relationship) | Structural (method-based) |
| Required relationship | Must subclass or be registered | No explicit relationship needed |
Runtime isinstance | Works by default | Requires @runtime_checkable |
| Method signature checking | Not enforced at runtime | Not enforced at runtime |
| Type checker support | Works with nominal typing | Works with structural typing |
| Use case | When you own the class hierarchy | When you want duck typing with type safety |
The most important distinction is how conformance is determined. ABCs rely on an explicit relationship; Protocols rely on the object's shape.
When to Use ABC Over Protocol
Choose abc.ABC when you are designing a framework or a library where you expect users to subclass your base class. For example, a plugin system where each plugin must implement a specific set of methods and you want to guarantee that behavior at instantiation time.
class Plugin(ABC): @abstractmethod def run(self, context: dict) -> None: pass class LoggingPlugin(Plugin): def run(self, context: dict) -> None: print(context)
Because LoggingPlugin inherits from Plugin, isinstance(plugin, Plugin) follows the normal inheritance chain. You can also walk Plugin.__subclasses__() to find direct subclasses; Protocol has no equivalent registry of structurally compatible classes.
ABCs also allow you to define abstract properties, class methods, and static methods with the same @abstractmethod decorator. Protocols support those too, but the enforcement model is different.
When to Use Protocol Over ABC
Use typing.Protocol when you want to write functions that accept any object with a certain set of methods, without forcing callers to inherit from a specific class. This is especially valuable when integrating with external code that you cannot modify.
from typing import List def total_area(shapes: List[AreaCalculable]) -> float: return sum(shape.area() for shape in shapes)
This function works with Circle, Square, or any other class that defines area(). If you used an ABC instead, you would need every shape class to inherit from Shape, which may not be possible if the class already has a different base class.
Protocols are also useful for defining interfaces used only at type-check time. Without @runtime_checkable, a Protocol does not add instance-checking behavior at runtime.
Runtime Considerations: isinstance and Performance
By default, isinstance does not work with a Protocol unless you decorate it with @runtime_checkable. Even then, the check is shallow: it only verifies that the object has the required methods and attributes, not that their signatures match.
from typing import Protocol, runtime_checkable @runtime_checkable class Drawable(Protocol): def draw(self) -> None: ... class Pen: def draw(self, color: str) -> None: pass print(isinstance(Pen(), Drawable)) # True, even though signature differs
This can lead to surprising behavior. The runtime check passes even if the method takes different arguments, because Python's isinstance only checks attribute presence. In contrast, an ABC's isinstance check is based on the class/subclass relationship or registration, not on inspecting instance attributes.
Performance is rarely the main reason to choose one over the other. isinstance with an ABC follows the class/subclass relationship or registration. With a runtime_checkable Protocol, Python must inspect the object for the required attributes. If you are calling isinstance in a hot loop, profile before making it the deciding factor.
Common Pitfalls and How to Avoid Them
One common mistake is forgetting that a Protocol without @runtime_checkable cannot be used with isinstance. If you rely on runtime checks, you must add the decorator explicitly.
Another pitfall is assuming that a Protocol enforces method signatures. It does not, at runtime. Only static type checkers can catch signature mismatches, and they only do so when the code is analyzed.
ABCs have their own pitfalls. Forcing inheritance can create rigid hierarchies that are hard to change. If a class already inherits from another base class, adding an ABC may require multiple inheritance, which can lead to method resolution order conflicts.
Combining Protocol and ABC
You can combine both approaches. A class can inherit from an ABC to satisfy a nominal contract and also conform to a Protocol for structural typing. This is common when you want to provide a default implementation in the ABC while allowing external classes to be used structurally.
class Shape(ABC): @abstractmethod def area(self) -> float: ... class Circle(Shape): def __init__(self, radius: float): self.radius = radius def area(self) -> float: return 3.14159 * self.radius ** 2 class AreaCalculable(Protocol): def area(self) -> float: ... def print_area(shape: AreaCalculable) -> None: print(shape.area()) print_area(Circle(2)) # Works because Circle has area()
Here Circle is both a Shape (nominal) and an AreaCalculable (structural). This gives you flexibility: you can use the ABC for runtime isinstance checks and the Protocol for type annotations that accept any compatible object.
The decision between Protocol and ABC ultimately depends on whether you need to enforce a contract through inheritance or through structure. When you own the class hierarchy and want guaranteed behavior at instantiation, ABC is the right tool. When you want to accept any object that has the required methods, especially from code you do not control, Protocol is the better choice.