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.”

FeatureABC (Nominal)Protocol (Structural)
Inheritance requiredYesNo
Checked atRuntime (instantiation)Static analysis (mypy)
Third-party classesMust modify or registerWorks automatically
Best forYour own class hierarchyFlexible APIs, duck typing
Python version2.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.

Structural vs nominal subtyping
Protocol checks what you can do, not who your parents are.

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.

Runtime checkable protocol
@runtime_checkable — isinstance() for duck typing.

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

Notification dispatch system
One Protocol, infinite notification backends. Add SMS without changing a line of dispatch code.

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.

Modular protocol dashboard
Three backends today, five tomorrow. Protocol makes the interface, not the inheritance.

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.