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.
Why Protocols Beat ABCs (Sometimes)
Before Python 3.8 you had two options for “this object must support these methods”: runtime ABC inheritance or duck typing with no static checks. typing.Protocol is the third path — structural typing that gets enforced by type checkers like mypy without forcing inheritance:
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def shutdown(resource: SupportsClose) -> None:
resource.close()
# Any class with close() satisfies the protocol — no inheritance needed
class FileWrapper:
def __init__(self, path):
self.f = open(path)
def close(self):
self.f.close()
class HttpClient:
def close(self):
print("Client closed")
# Both work — mypy verifies them
shutdown(FileWrapper("data.txt"))
shutdown(HttpClient())
The third-party type doesn’t need to know about your Protocol. This is the killer feature — you can declare contracts for libraries you don’t control.
Runtime-Checkable Protocols
Static analysis is the primary use case, but you can opt into runtime isinstance checks with @runtime_checkable:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Comparable(Protocol):
def __lt__(self, other: "Comparable") -> bool: ...
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __lt__(self, other):
return self.celsius < other.celsius
print(isinstance(Temperature(25), Comparable)) # True
print(isinstance(42, Comparable)) # True (int has __lt__)
Runtime checks have caveats — they only verify method presence, not signatures or types. For strict validation, lean on static type checkers.
Generic Protocols
Protocols can be parameterized just like generic ABCs. The classic example is "anything supporting __getitem__":
from typing import Protocol, TypeVar
T = TypeVar("T", covariant=True)
class Container(Protocol[T]):
def __getitem__(self, key: int) -> T: ...
def __len__(self) -> int: ...
def first(c: Container[T]) -> T:
return c[0]
x: int = first([1, 2, 3]) # ok
y: str = first(["a", "b"]) # ok
z: int = first(("a", "b")) # mypy error — Container[int] required
Protocol vs ABC: Quick Decision Tree
| Need | Use |
|---|---|
| Enforce at runtime (TypeError if missing) | ABC |
| Provide default method implementations | ABC |
| Type third-party classes you don't control | Protocol |
| No inheritance hierarchy needed | Protocol |
| Plugin systems with discovery | ABC |
| Lightweight interface declarations | Protocol |
Common Pitfalls
- Forgetting @runtime_checkable for isinstance. Without it,
isinstance(obj, MyProtocol)raises TypeError. Static checks always work; runtime checks need the decorator. - Protocols don't enforce signatures at runtime. A class with
def close(self, x, y)still passesisinstanceforSupportsClose. Static type checkers catch this; runtime doesn't. - Mixing Protocol with regular base classes. A class that inherits from both a Protocol and a regular class can lead to MRO confusion. Most of the time, just declare the Protocol — no inheritance needed.
- Forgetting __init__ vs __post_init__. Protocols can declare
__init__but it's awkward — usually you want the Protocol to specify behavior, not construction. - Overusing Protocols. For your own classes that you control, sometimes a plain ABC or a regular class is clearer. Use Protocols when you need the structural-typing flexibility.
FAQ
Q: Protocol or ABC for new code?
A: Protocol if you're typing third-party objects or want lightweight interfaces. ABC if you need runtime enforcement, default methods, or a real inheritance tree.
Q: Do Protocols work with dataclasses?
A: Yes. A dataclass automatically satisfies any Protocol whose methods/attributes it provides. @dataclass doesn't conflict with Protocol.
Q: Can a Protocol have non-abstract methods?
A: Yes — methods with bodies act as default implementations for classes that inherit from the Protocol. But classes don't need to inherit; they just need the structural shape.
Q: How do mypy and Pyright handle Protocols?
A: Both fully support them, including generic Protocols and variance. Pyright is slightly stricter on covariance/contravariance — fine, it usually catches real bugs.
Q: PEP 544 vs PEP 695?
A: PEP 544 introduced Protocols (3.8). PEP 695 added cleaner generic syntax (3.12+). They work together — PEP 695 just makes generic Protocols less verbose.
Wrapping Up
typing.Protocol is one of the most useful features added to Python in the last decade. Use it when you want to type interfaces without forcing inheritance — third-party objects, plugin shapes, structural duck typing made type-safe. For deeper enforcement and default methods, stick with ABCs. The two paradigms complement each other; pick the right tool per interface.