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.

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

NeedUse
Enforce at runtime (TypeError if missing)ABC
Provide default method implementationsABC
Type third-party classes you don't controlProtocol
No inheritance hierarchy neededProtocol
Plugin systems with discoveryABC
Lightweight interface declarationsProtocol

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 passes isinstance for SupportsClose. 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.