Python ABCs vs Protocols: Choosing the Right Abstraction
Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.
What you'll learn
- ✓How Abstract Base Classes enforce interface contracts at instantiation
- ✓How Protocols enable structural (duck) typing with static analysis
- ✓Runtime checkability and registration with ABCs
- ✓Deciding between ABCs and Protocols for your architecture
Prerequisites
None — this post is self-contained.
Python gives you two ways to define interfaces: Abstract Base Classes (ABCs) from the abc module, and Protocols from typing. ABCs use nominal typing — a class must explicitly inherit from the ABC. Protocols use structural typing — any class with the right methods satisfies the protocol, no inheritance required. Both are valuable, and choosing the right one depends on whether you want enforcement at instantiation time or flexibility at type-checking time.
Abstract Base Classes
An ABC defines a contract. If a subclass does not implement all abstract methods, Python raises TypeError when you try to instantiate it.
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: str) -> dict:
...
@abstractmethod
def save(self, entity: dict) -> None:
...
def exists(self, id: str) -> bool:
"""Concrete method -- subclasses inherit this."""
try:
self.get(id)
return True
except KeyError:
return False
class InMemoryRepository(Repository):
def __init__(self):
self._store: dict[str, dict] = {}
def get(self, id: str) -> dict:
if id not in self._store:
raise KeyError(f"Not found: {id}")
return self._store[id]
def save(self, entity: dict) -> None:
self._store[entity["id"]] = entity
If you forget to implement save:
class BrokenRepository(Repository):
def get(self, id: str) -> dict:
return {}
repo = BrokenRepository()
# TypeError: Can't instantiate abstract class BrokenRepository
# with abstract method save
This is the key advantage: you get a clear, immediate error rather than a mysterious AttributeError later.
Abstract Properties
ABCs can also enforce properties:
from abc import ABC, abstractmethod
class Shape(ABC):
@property
@abstractmethod
def area(self) -> float:
...
@property
@abstractmethod
def perimeter(self) -> float:
...
class Circle(Shape):
def __init__(self, radius: float):
self._radius = radius
@property
def area(self) -> float:
import math
return math.pi * self._radius ** 2
@property
def perimeter(self) -> float:
import math
return 2 * math.pi * self._radius
Virtual Subclasses with register
You can register a class as a “virtual subclass” of an ABC without inheritance:
from abc import ABC, abstractmethod
class Drawable(ABC):
@abstractmethod
def draw(self) -> str:
...
class ThirdPartyWidget:
"""From an external library -- you cannot modify it."""
def draw(self) -> str:
return "Drawing widget"
Drawable.register(ThirdPartyWidget)
print(isinstance(ThirdPartyWidget(), Drawable)) # True
This does not enforce method implementation, so use it carefully.
Protocols — Structural Typing
A Protocol defines what methods and attributes a type must have, but does not require inheritance. If a class has the right shape, it satisfies the protocol.
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str:
...
class HtmlPage:
def render(self) -> str:
return "<html>...</html>"
class JsonResponse:
def render(self) -> str:
return '{"status": "ok"}'
def display(item: Renderable) -> None:
print(item.render())
# Both work -- no inheritance needed
display(HtmlPage())
display(JsonResponse())
mypy verifies that any object passed to display has a render method returning str. The classes never mention Renderable.
Protocol with Attributes
Protocols can define required attributes:
from typing import Protocol
class HasName(Protocol):
name: str
class User:
def __init__(self, name: str):
self.name = name
class Product:
def __init__(self, name: str, price: float):
self.name = name
self.price = price
def greet(entity: HasName) -> str:
return f"Hello, {entity.name}"
greet(User("Ada")) # OK
greet(Product("Widget", 9.99)) # OK -- has 'name' attribute
Runtime-Checkable Protocols
By default, Protocols are only enforced by static type checkers. To enable isinstance checks at runtime, use @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None:
...
import io
stream = io.StringIO()
print(isinstance(stream, Closeable)) # True
print(isinstance("hello", Closeable)) # False
Note: runtime checks only verify method existence, not signatures. Static type checkers do the full signature check.
Head-to-Head Comparison
# ABC approach -- explicit inheritance required
from abc import ABC, abstractmethod
class Cache(ABC):
@abstractmethod
def get(self, key: str) -> object | None: ...
@abstractmethod
def set(self, key: str, value: object) -> None: ...
class RedisCache(Cache): # Must inherit
def get(self, key: str) -> object | None:
...
def set(self, key: str, value: object) -> None:
...
# Protocol approach -- no inheritance needed
from typing import Protocol
class Cache(Protocol):
def get(self, key: str) -> object | None: ...
def set(self, key: str, value: object) -> None: ...
class RedisCache: # No inheritance
def get(self, key: str) -> object | None:
...
def set(self, key: str, value: object) -> None:
...
When to Use Which
| Criteria | ABC | Protocol |
|---|---|---|
| You own all implementations | Good fit | Good fit |
| Third-party classes must conform | Awkward (needs register) | Natural fit |
| Want instantiation-time errors | Yes | No (static only) |
| Want shared default methods | Yes | No |
| Want to avoid coupling | No (requires import) | Yes |
| Runtime isinstance checks | Built-in | Opt-in with runtime_checkable |
Use ABCs When:
- You are building a framework where subclasses must implement specific methods
- You want shared concrete methods in the base class
- You need guaranteed failure at instantiation, not at call time
- You are defining an internal plugin system where you control all implementations
Use Protocols When:
- You want to accept third-party objects that happen to have the right interface
- You are writing library code that should not force users to inherit from your types
- You want to keep modules decoupled (no import dependency on the interface)
- You are adding type hints to existing duck-typed code
Combining Both
You can use both in the same project. A common pattern is ABCs for your internal hierarchy and Protocols for the public API:
from abc import ABC, abstractmethod
from typing import Protocol
# Public API -- any object with .execute() works
class Executable(Protocol):
def execute(self, context: dict) -> dict: ...
# Internal base class with shared logic
class BaseTask(ABC):
@abstractmethod
def execute(self, context: dict) -> dict:
...
def log(self, message: str) -> None:
print(f"[{self.__class__.__name__}] {message}")
def run_pipeline(tasks: list[Executable]) -> None:
"""Accepts any Executable, whether it inherits BaseTask or not."""
context = {}
for task in tasks:
context = task.execute(context)
Key Takeaways
ABCs enforce contracts at instantiation and let you share implementation. Protocols describe shape without requiring inheritance and keep modules decoupled. Neither is universally better. Use ABCs when you want strict enforcement in code you control. Use Protocols when you want flexibility and compatibility with code you do not control.
Related articles
- Python Python Type Narrowing and @overload Patterns
Master Python type narrowing with isinstance, TypeGuard, TypeIs, and use @overload to write precise function signatures that type checkers understand.
- Python Python Descriptors: The Protocol Behind Properties
Learn how Python descriptors work under the hood, powering properties, class methods, and custom attribute access with __get__, __set__, and __delete__.
- Python Python Protocol Classes: Structural Subtyping Guide
Learn how to use Python Protocol classes for structural subtyping, duck typing with static checks, and writing flexible interfaces without inheritance.
- Python Python Design Patterns: Strategy, Factory, and Observer
Learn how to implement the Strategy, Factory, and Observer design patterns in Python using idiomatic constructs like first-class functions and protocols.