Intermediate
You are writing a function that accepts “anything that has a read() method” — maybe a file, a StringIO buffer, or a custom stream class. You do not care about the class hierarchy. You just need it to have read(). With traditional ABCs, every class must explicitly inherit from your abstract base. With Python’s typing.Protocol, you get the same compile-time safety without requiring inheritance at all.
Protocol, introduced in Python 3.8 via PEP 544, brings structural subtyping (also called static duck typing) to Python’s type system. If a class has the right methods with the right signatures, it satisfies the Protocol — no class MyClass(MyProtocol) needed. Type checkers like mypy verify this at analysis time.
In this article, we will start with a quick example comparing Protocol to ABC, then explain structural vs nominal subtyping. We will cover defining protocols, using runtime checkable protocols, combining protocols, and practical patterns for building flexible APIs. We will finish with a real-life notification system project.
Python Protocol Quick Example
# quick_protocol.py
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str:
...
def render(shape: Drawable) -> None:
print(f"Rendering: {shape.draw()}")
# No inheritance needed -- just implement draw()
class Circle:
def __init__(self, radius: float):
self.radius = radius
def draw(self) -> str:
return f"Circle(r={self.radius})"
class Square:
def __init__(self, side: float):
self.side = side
def draw(self) -> str:
return f"Square(s={self.side})"
# Both work with render() -- no inheritance required
render(Circle(5.0))
render(Square(3.0))
Output:
Rendering: Circle(r=5.0)
Rendering: Square(s=3.0)
Neither Circle nor Square inherits from Drawable. They satisfy the protocol simply by having a draw() method that returns a string. This is structural subtyping — the structure (what methods exist) matters, not the class hierarchy.
What Is Structural Subtyping?
Python has two approaches to type compatibility. Nominal subtyping (traditional inheritance) says “class B is compatible with A because B explicitly inherits from A.” Structural subtyping (protocols) says “class B is compatible with A because B has all the methods that A requires.”
Think of it like job hiring. Nominal subtyping is checking someone’s degree: “Did you graduate from our approved program?” Structural subtyping is checking their skills: “Can you do the work? Then you are hired.”
| Feature | ABC (Nominal) | Protocol (Structural) |
|---|---|---|
| Inheritance required | Yes | No |
| Checked at | Runtime (instantiation) | Static analysis (mypy) |
| Third-party classes | Must modify or register | Works automatically |
| Best for | Your own class hierarchy | Flexible APIs, duck typing |
| Python version | 2.6+ | 3.8+ |
Protocols are especially powerful when working with third-party classes you cannot modify. If a library class has a read() method, it automatically satisfies your Readable protocol — you do not need the library author to inherit from your ABC.
Defining Your Own Protocols
A protocol is a class that inherits from Protocol and defines methods (with type hints) that conforming classes must implement. The method bodies are typically ... (Ellipsis) since they serve as specifications, not implementations.
# defining_protocols.py
from typing import Protocol, runtime_checkable
class Serializable(Protocol):
def to_dict(self) -> dict:
...
def to_json(self) -> str:
...
class Persistable(Protocol):
def save(self, path: str) -> None:
...
def load(self, path: str) -> None:
...
# This class satisfies Serializable (has to_dict and to_json)
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def to_dict(self) -> dict:
return {"name": self.name, "email": self.email}
def to_json(self) -> str:
import json
return json.dumps(self.to_dict())
def export_data(obj: Serializable) -> None:
data = obj.to_dict()
json_str = obj.to_json()
print(f"Dict: {data}")
print(f"JSON: {json_str}")
user = User("Alice", "alice@example.com")
export_data(user)
Output:
Dict: {'name': 'Alice', 'email': 'alice@example.com'}
JSON: {"name": "Alice", "email": "alice@example.com"}
The User class never mentions Serializable anywhere. It satisfies the protocol purely through its method signatures. If you removed to_json() from User, mypy would catch the error at static analysis time.
Runtime Checkable Protocols
# runtime_checkable.py
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None:
...
# Test various objects
import io
file_like = io.StringIO("hello")
regular_list = [1, 2, 3]
print(f"StringIO is Closeable: {isinstance(file_like, Closeable)}")
print(f"list is Closeable: {isinstance(regular_list, Closeable)}")
# Custom class
class Connection:
def close(self) -> None:
print("Connection closed")
conn = Connection()
print(f"Connection is Closeable: {isinstance(conn, Closeable)}")
# Use in a function
def cleanup(resources: list) -> None:
for resource in resources:
if isinstance(resource, Closeable):
resource.close()
print(f" Closed {type(resource).__name__}")
cleanup([file_like, conn, regular_list])
Output:
StringIO is Closeable: True
list is Closeable: False
Connection is Closeable: True
Closed StringIO
Closed Connection
The @runtime_checkable decorator lets you use isinstance() checks with your protocol. Without it, isinstance would raise a TypeError. Note that runtime checks only verify method existence, not signatures — for full signature checking, use mypy.
Combining and Extending Protocols
Protocols can inherit from other protocols to create combined interfaces. This lets you build up complex requirements from simple building blocks.
# combining_protocols.py
from typing import Protocol
class Readable(Protocol):
def read(self) -> str:
...
class Writable(Protocol):
def write(self, data: str) -> None:
...
# Combined protocol
class ReadWritable(Readable, Writable, Protocol):
pass
class InMemoryBuffer:
def __init__(self):
self._data = ""
def read(self) -> str:
return self._data
def write(self, data: str) -> None:
self._data += data
def process(stream: ReadWritable) -> None:
stream.write("Hello, ")
stream.write("World!")
print(f"Content: {stream.read()}")
buf = InMemoryBuffer()
process(buf)
Output:
Content: Hello, World!
ReadWritable combines both Readable and Writable — any class that implements both read() and write() satisfies it. This composition pattern is cleaner than creating one large protocol with many methods.
Real-Life Example: Notification System
Let us build a notification dispatch system that accepts any backend implementing a simple protocol — no inheritance required.
# notification_system.py
from typing import Protocol, runtime_checkable
from datetime import datetime
@runtime_checkable
class NotificationBackend(Protocol):
def send(self, recipient: str, subject: str, body: str) -> bool:
...
@property
def backend_name(self) -> str:
...
class EmailBackend:
@property
def backend_name(self) -> str:
return "Email"
def send(self, recipient: str, subject: str, body: str) -> bool:
print(f" Email to {recipient}: {subject}")
return True
class SlackBackend:
def __init__(self, channel: str):
self.channel = channel
@property
def backend_name(self) -> str:
return f"Slack(#{self.channel})"
def send(self, recipient: str, subject: str, body: str) -> bool:
print(f" Slack #{self.channel} @{recipient}: {subject}")
return True
class WebhookBackend:
def __init__(self, url: str):
self.url = url
@property
def backend_name(self) -> str:
return "Webhook"
def send(self, recipient: str, subject: str, body: str) -> bool:
print(f" Webhook POST to {self.url}: {subject}")
return True
class NotificationDispatcher:
def __init__(self):
self.backends: list = []
self.log: list = []
def register(self, backend: NotificationBackend) -> None:
if not isinstance(backend, NotificationBackend):
raise TypeError(f"{type(backend).__name__} does not satisfy NotificationBackend protocol")
self.backends.append(backend)
print(f" Registered: {backend.backend_name}")
def notify(self, recipient: str, subject: str, body: str) -> dict:
results = {}
for backend in self.backends:
success = backend.send(recipient, subject, body)
results[backend.backend_name] = success
self.log.append({
"time": datetime.now().strftime("%H:%M:%S"),
"backend": backend.backend_name,
"recipient": recipient,
"success": success
})
return results
def summary(self) -> None:
print(f"\n Notification log: {len(self.log)} entries")
for entry in self.log:
status = "OK" if entry["success"] else "FAIL"
print(f" [{status}] {entry['time']} via {entry['backend']} to {entry['recipient']}")
# Setup
print("Registering backends:")
dispatcher = NotificationDispatcher()
dispatcher.register(EmailBackend())
dispatcher.register(SlackBackend("alerts"))
dispatcher.register(WebhookBackend("https://hooks.example.com/notify"))
print("\nSending notifications:")
dispatcher.notify("alice", "Deploy Complete", "v2.1.0 deployed to production")
dispatcher.notify("bob", "Build Failed", "CI pipeline failed on main branch")
dispatcher.summary()
Output:
Registering backends:
Registered: Email
Registered: Slack(#alerts)
Registered: Webhook
Sending notifications:
Email to alice: Deploy Complete
Slack #alerts @alice: Deploy Complete
Webhook POST to https://hooks.example.com/notify: Deploy Complete
Email to bob: Build Failed
Slack #alerts @bob: Build Failed
Webhook POST to https://hooks.example.com/notify: Build Failed
Notification log: 6 entries
[OK] 09:45:30 via Email to alice
[OK] 09:45:30 via Slack(#alerts) to alice
[OK] 09:45:30 via Webhook to alice
[OK] 09:45:30 via Email to bob
[OK] 09:45:30 via Slack(#alerts) to bob
[OK] 09:45:30 via Webhook to bob
The power here is extensibility. Anyone can write a new backend — SMSBackend, PagerDutyBackend, TeamsBackend — without inheriting from anything. As long as it has send() and backend_name, the dispatcher accepts it. The @runtime_checkable decorator catches mistakes at registration time.
Frequently Asked Questions
When should I use Protocol instead of ABC?
Use Protocol when you want to define an interface without forcing inheritance — especially useful with third-party classes you cannot modify. Use ABC when you control the class hierarchy and want runtime enforcement. In practice, Protocol is better for library APIs and ABC is better for internal frameworks.
Does Protocol work at runtime or only with mypy?
By default, Protocol is a static-only concept — mypy and other type checkers verify it. Add @runtime_checkable to enable isinstance() checks at runtime. However, runtime checks only verify method existence, not parameter types or return types.
Can a Protocol have default method implementations?
Technically yes — you can add method bodies to Protocol methods. However, these implementations are only available to classes that explicitly inherit from the Protocol. Classes that satisfy the protocol structurally do not get the default implementations. For shared functionality, consider a mixin class instead.
Can I use Protocol with dataclasses?
Yes. A dataclass that has the right methods and attributes satisfies a Protocol just like any other class. You can also define Protocols with attribute requirements using class variables with type annotations (without assignment), and dataclasses will satisfy them through their generated __init__.
What Python version do I need for Protocol?
Protocol was added in Python 3.8 via PEP 544. For Python 3.7, you can use typing_extensions.Protocol as a backport. Since Python 3.8 reached its minimum support baseline for most modern projects, you can safely use Protocol in any new code.
Conclusion
We covered Python’s typing.Protocol for structural subtyping: defining protocols with method specifications, using @runtime_checkable for isinstance checks, combining protocols through inheritance, and building flexible APIs that accept any object with the right methods. The notification system showed how protocols enable plugin-style architectures without inheritance.
Try converting an existing ABC-based system to use Protocol and compare the flexibility. For the complete reference, see the official Protocol documentation and PEP 544.