Sitemap

Understanding Python Protocol Types

8 min readMay 8, 2026

--

Press enter or click to view image in full size

Python has long been known as a language that embraces duck typing. If an object behaves like what a function expects, then in many cases it can be used directly without explicitly inheriting from a common base class. This design philosophy makes Python flexible and expressive. However, as a code base grows, the same flexibility can also make interfaces less explicit, make contracts harder to see, and make refactoring more difficult.

Over the past several years, Python’s type hint system has become increasingly important in real-world software development [1]. Within that broader typing system, Protocol is one of the most practical features because it formalizes duck typing through structural typing, allowing interfaces to be described explicitly while still preserving Python’s natural programming style [2][3].

In this article, I am going to summarize the practical ideas behind protocol types, discuss when they are most useful, and demonstrate a small example implementation. The demonstration code is intentionally simple, but it captures several common use cases, including structural typing for regular objects, callback protocols, and runtime-checkable protocols. The goal is not to cover every corner case of the typing system, but to build a practical understanding of when and why Protocol improves Python code.

Figure 1 below illustrates conceptual position of protocol types: it sits between informal duck typing and explicit static typing, preserving the flexibility of the former while improving the clarity of the latter.

Press enter or click to view image in full size
Fig 1. Conceptual position of protocol types between duck typing and nominal interface-based typing.

1. Protocol Types — Practical Guide

1.1 What Problem Does `Protocol` Solve?

Traditional object-oriented typing is nominal. In other words, whether a value is accepted depends on the class hierarchy it belongs to, and a class usually needs to inherit from a particular base class or implement a particular interface explicitly.

In Python, however, this is not always the most natural way to model behavior. In many situations, what we actually care about is not ancestry, but capability. If an object provides the methods and signatures that a function needs, then the object is effectively compatible.

This is the main motivation for protocol types. A protocol describes the shape of an object, and if another object matches that shape, then from the perspective of static type checking it satisfies the protocol even if it does not inherit from it [2][3].

Consider a function that writes messages to different outputs. One output may print to the console, another may collect messages in memory, and a third may send them to an external system. If each object exposes a compatible write() method, then each should be usable by the same function. Protocol types provide a clean and explicit way to describe that requirement.

At a high level, the shift is from asking whether an object belongs to a particular inheritance tree to asking whether it satisfies the required interface shape. That conceptual shift is small, but in practice it leads to a much more natural typing model for Python code.

1.2 Structural Typing

The core idea of protocol types is structural typing, where compatibility is determined by shape rather than inheritance [2].

The simplified example below illustrates the idea.

from typing import Protocol

class Writer(Protocol):

def write(self, message: str) -> None:
...


def broadcast(message: str, writer: Writer) -> None:
writer.write(message)

With the definition above, any object that provides a compatible write() method can be passed into broadcast(). It does not need to inherit from Writer, and that is precisely the key difference between protocol types and traditional interface hierarchies [2][3].

In practice, this makes protocols especially useful in Python projects that already rely on informal duck typing. Instead of rewriting the entire class hierarchy, a developer can describe the expected behavior directly and let the type checker verify compatibility. In many cases, this allows typing to be introduced incrementally rather than through a large redesign.

Figure 2 below compares nominal typing and structural typing.

Press enter or click to view image in full size
Figure 2. Nominal typing versus structural typing. In the nominal case, compatibility depends on explicit inheritance. In the structural case, compatibility depends on whether the required methods and signatures are present.

1.3 Callback Protocols

Another useful feature of protocol types is that they can also describe callable interfaces [2][3].

In many code bases, developers annotate callbacks using Callable. While this works, it can also be less descriptive than desired. A callback protocol makes the expected call signature more explicit and, as a result, easier to understand.

For example:

class ScoreFormatter(Protocol):

def __call__(self, title: str, score: float) -> str:
...

This protocol describes any callable object that accepts a title and a score and returns a string. Such a callable may be a normal function, a lambda expression, or an instance of a class implementing __call__().

Compared with a broad type such as Callable[..., str], a callback protocol communicates intent more clearly. This becomes increasingly useful when the callback is part of a public API or an internal extension point, where readability of the contract matters as much as correctness.

In other words, callback protocols are not only about satisfying the type checker. They also improve communication between developers by making the expected callable contract visible at the point of use.

1.4 Runtime-Checkable Protocols

Protocol types are primarily intended for static type checking. At the same time, Python also allows selected protocols to be used with runtime checks through the @runtime_checkable decorator [1][3].

For example:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closable(Protocol):

def close(self) -> None:
...

With this definition, code such as the following becomes valid:

if isinstance(obj, Closable):
obj.close()

This feature is useful when a program needs to handle optional behavior dynamically, but it is also important to understand its limitation. The runtime check only verifies that the expected attributes are present in a compatible form; it does not guarantee deeper semantic correctness [1][3]. Accordingly, runtime-checkable protocols should be treated as a convenience for lightweight runtime checks rather than as a replacement for full validation.

1.5 `Protocol` Versus Abstract Base Class

In practice, both protocols and abstract base classes can be used to describe interfaces, but they serve different purposes.

Get Chuan Zhang’s stories in your inbox

Join Medium for free to get updates from this writer.

Abstract base classes are often appropriate when shared implementation, explicit inheritance, or controlled registration are required. Protocols, by contrast, are a better fit when the main concern is whether an object provides the required behavior [2][3].

Roughly speaking, an abstract base class says: the object must belong to this interface family. A protocol says: the object must behave like this.

In Python projects, the second requirement is often the more natural one, especially when working with existing classes, third-party libraries, or infrastructure code that was not originally designed around a shared inheritance model.

For that reason, protocols are often most valuable not in newly designed examples, but in existing code bases where the behavioral contract already exists informally and simply needs to be made explicit.

2. Protocol Types — A Small Demonstration

To better demonstrate the practical use of protocol types, a small sample program is provided in protocol_demo.py. The demonstration focuses on three pieces:

1. A Writer protocol describing writable outputs.

2. A ScoreFormatter protocol describing formatter callbacks.

3. A Closable protocol used for optional runtime checks.

Figure 3 below can be used to show the relationship among these three protocol patterns in the sample program.

Figure 3. Overview of the sample program. `Writer` defines a structural interface for output objects, `ScoreFormatter` defines a callable interface for formatting, and `Closable` provides an optional runtime-checkable behavior.

2.1 Structural Typing for Writers

The first part of the demonstration defines a Writer protocol and two classes, ConsoleWriter and MemoryWriter.

class Writer(Protocol):

def write(self, message: str) -> None:
...

class ConsoleWriter:

def write(self, message: str) -> None:
print(message)

class MemoryWriter:

def __init__(self) -> None:
self.messages: list[str] = []

def write(self, message: str) -> None:
self.messages.append(message)

The important point here is that neither class inherits from Writer. Nevertheless, both classes satisfy the protocol because each provides a compatible write() method [2][3].

This is exactly the type of scenario where protocols work well, because existing classes can immediately participate in the typed interface as long as they already expose the expected behavior. In other words, the protocol captures the contract without forcing the implementation into a new class hierarchy.

2.2 Callback Protocol for Formatting

The second part of the example uses a callback protocol to format job match scores.

class ScoreFormatter(Protocol):

def __call__(self, title: str, score: float) -> str:
...


def render_match(match: JobMatch, formatter: ScoreFormatter) -> str:
return formatter(f"{match.title} @ {match.company}", match.score)

Two formatter functions are used in the sample code, and because both match the required callable signature, both can be passed into render_match() directly.

This demonstrates one important advantage of callback protocols: they provide a clearer contract for extension points without constraining the implementation style [2][3]. That balance is often difficult to achieve with broader callable annotations.

2.3 Optional Runtime Behavior

The last part of the example shows how a runtime-checkable protocol can be used for optional cleanup behavior.

def close_if_possible(value: object) -> None:
if isinstance(value, Closable):
value.close()
else:
print("[close] object is not closable")

In the sample program, MemoryWriter implements close(), while ConsoleWriter does not. As a result, the cleanup helper closes the memory writer and safely skips the console writer.

Although simple, this example reflects a common real-world pattern in which some objects provide additional behavior beyond the minimum required interface, and the program needs to detect and use that behavior safely [1][3].

2.4 Running the Demonstration

The sample code can be executed directly with: python protocol_demo.py

python protocol_demo.py

The output shows that both writer implementations can be used through the same protocol, that both formatter functions can be passed through the callback interface, and that the closable object is handled correctly at runtime. Even though the example is small, it demonstrates the essential value of protocol types: explicit contracts without unnecessary coupling.

If desired, Figure 4 can be added as a lightweight flow diagram for the example execution.

Press enter or click to view image in full size
Figure 4. Execution flow of the sample program: create match data, format output through a callback protocol, broadcast through writer implementations, then perform optional cleanup through a runtime-checkable protocol.

3. Conclusion

Protocol types provide one of the cleanest ways to combine Python’s duck typing philosophy with modern static type checking. They allow developers to describe behavior explicitly without introducing unnecessary inheritance, which makes them especially useful for loosely coupled application code, callback-driven APIs, and systems that integrate third-party classes [2][3].

In this article, the practical ideas behind protocol types have been summarized, and a small demonstration has been presented and discussed. The key takeaway is that Protocol is most useful when it is used to describe the minimum behavior a piece of code truly depends on. Applied that way, it improves readability, flexibility, and maintainability at the same time. For modern Python code bases that already rely on informal duck typing, this is often one of the most practical typing features to adopt early.

References

[1] Python 3 Standard Library Documentation: typing — Support for type hints

[2] I. Levkivskyi, J. Lehtosalo, D. Foster, et. al. (2017) PEP 544 — Protocols: Structural subtyping (static duck typing) Python Enhancement Proposals.

[3] Python 3 Standard Library Documentation: typing.Protocol

--

--