Python’s Protocol offers a flexible, implicit approach to typing that often surprises developers accustomed to more rigid, explicit systems.

Let’s see it in action. Imagine you have a function that needs to process "things" that can be serialized.

from typing import Protocol

class Serializable(Protocol):
    def serialize(self) -> str:
        ...

def process_data(item: Serializable):
    print(f"Serializing: {item.serialize()}")

class MyData:
    def __init__(self, value: int):
        self.value = value

    def serialize(self) -> str:
        return f"MyData(value={self.value})"

class AnotherData:
    def __init__(self, name: str):
        self.name = name

    def serialize(self) -> str:
        return f"AnotherData(name='{self.name}')"

data1 = MyData(123)
data2 = AnotherData("test")

process_data(data1)
process_data(data2)

Output:

Serializing: MyData(value=123)
Serializing: AnotherData(name='test')

Here, process_data expects an object that conforms to the Serializable protocol. It doesn’t care what the object actually is, only that it has a serialize method returning a string. MyData and AnotherData both happen to have this method, so they both work seamlessly, even though neither explicitly inherits from Serializable. This is structural typing: if it walks like a duck and quacks like a duck, it’s a duck. Python’s Protocol enables this.

Contrast this with Abstract Base Classes (ABCs). ABCs, like collections.abc.Mapping or typing.ABC, enforce nominal typing. To be considered a Mapping, a class must explicitly inherit from collections.abc.Mapping and register itself using register().

from abc import ABC, abstractmethod

class BaseSerializer(ABC):
    @abstractmethod
    def serialize(self) -> str:
        pass

class ConcreteSerializer(BaseSerializer):
    def serialize(self) -> str:
        return "Serialized!"

# This would fail if uncommented because it doesn't inherit from BaseSerializer
# class AnotherConcreteSerializer:
#     def serialize(self) -> str:
#         return "Another Serialized!"

# To make it work with ABCs, you'd need inheritance or registration:
# class AnotherConcreteSerializer(BaseSerializer):
#     def serialize(self) -> str:
#         return "Another Serialized!"
# OR
# from collections.abc import MutableMapping
# class MyDict(dict):
#     pass
# MutableMapping.register(MyDict) # Now MyDict is recognized as a Mapping

# def process_data_abc(item: BaseSerializer):
#     print(f"Serializing: {item.serialize()}")

# process_data_abc(ConcreteSerializer())

The core problem Protocol solves is that of duck typing in a statically typed world. Before Protocol, if you wanted type checking for duck-typed interfaces, you’d either have to rely on runtime checks or force inheritance from ABCs. Protocol allows you to define interfaces that objects can implicitly satisfy, making your type hints more expressive and less intrusive. You can define an interface and have any existing or future class that implements the required methods be considered compatible, without altering their original definition.

The real power of Protocol lies in its ability to define interfaces that don’t require any explicit relationship between the implementing class and the protocol itself. This is crucial for working with third-party libraries or built-in types where you can’t (or don’t want to) modify their source code to add inheritance. A Protocol is essentially a contract that says, "If you have these methods with these signatures, you conform to me." The type checker then verifies this adherence.

A subtle but powerful aspect of Protocols is that they can be generic. You can define a Protocol that itself has type parameters, allowing for even more flexible and precise interface definitions that can adapt to different contexts. This allows for defining interfaces that operate on generic types, similar to how generic classes or functions work, but applied to the structure of an object.

When using Protocol, remember that type checkers will verify the presence and signatures of methods. However, they won’t enforce that the return types of those methods are identical to the protocol’s definition if the implementing method’s return type is a subtype of the protocol’s return type. For example, if a Protocol requires serialize() -> str and an implementing class has serialize() -> 'MyCustomStringSubclassOfStr', the type checker will still consider it compatible.

The next frontier after mastering Protocol is understanding how to effectively leverage TypeVars and Generic within your Protocols for even more sophisticated interface design.

Want structured learning?

Take the full Python course →