Large Python projects often need functions to accept objects with a particular behavior without forcing every implementation to inherit from the same base class. A service may only need save(); a logger may need write(); a cache may need get() and set(). typing.Protocol describes those capabilities with structural typing: an object is compatible when it has the required members, even without explicit inheritance.
This guide explains how to define protocols, declare attributes and methods, create generic protocols, use runtime_checkable, model callbacks, test dependencies, and decide when an abstract base class is still the better tool.
Nominal and structural typing
Nominal typing bases compatibility on names and inheritance. A class implements an interface because it inherits from it. Structural typing bases compatibility on shape: the methods and attributes that are present.
from typing import Protocol
class Savable(Protocol):
def save(self, destination: str) -> None:
...
class Report:
def save(self, destination: str) -> None:
print(f"Saving to {destination}")
def persist(item: Savable) -> None:
item.save("output.txt")
persist(Report())Report does not inherit from Savable, but a static checker such as mypy or Pyright can see that its method matches the contract.
Why protocols reduce coupling
A protocol lets a consumer declare only the behavior it needs. Business logic can depend on a small cache, repository, email sender, or clock contract instead of a concrete vendor SDK.
This extends the ideas in the guide to Python type hints. Type hints expose contracts; Protocol keeps them small and usage-oriented.
Protocols with attributes
Protocols can require attributes, properties, and methods.
class VisibleUser(Protocol):
id: int
name: str
@property
def active(self) -> bool:
...
def display(user: VisibleUser) -> str:
state = "active" if user.active else "inactive"
return f"{user.id}: {user.name} ({state})"A plain attribute usually implies read and write access. If the consumer only reads a value, a read-only property often communicates the requirement more accurately and avoids variance problems.
Precise method signatures
Parameter kinds, return types, and relevant names are part of the contract. An implementation returning str does not satisfy a method that promises bytes. An implementation also cannot accept inputs that are narrower than the protocol allows.
class Serializer(Protocol):
def dumps(self, value: object, *, indent: bool = False) -> str:
...Keyword-only, positional-only, optional, and variadic parameters should reflect what the consumer actually calls.
Generic protocols
Use generic parameters when the processed type must be preserved.
from typing import Protocol, TypeVar
T = TypeVar("T")
class Repository(Protocol[T]):
def get(self, item_id: int) -> T | None:
...
def add(self, item: T) -> None:
...
class Product:
def __init__(self, name: str) -> None:
self.name = name
def load_product(repo: Repository[Product], item_id: int) -> Product:
product = repo.get(item_id)
if product is None:
raise LookupError(item_id)
return productThe type checker keeps the relationship between the repository input and output, reducing unsafe casts.
Callback protocols
Callable is sufficient for simple functions, but a callback protocol can model keyword-only parameters, overloaded call forms, and callable objects with attributes.
class OnComplete(Protocol):
def __call__(self, result: str, *, duration: float) -> None:
...
def run(callback: OnComplete) -> None:
callback("ok", duration=0.42)Any function or callable object with a compatible signature can satisfy the protocol.
runtime_checkable
Protocols are primarily designed for static analysis. Adding @runtime_checkable allows shallow isinstance() checks.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closable(Protocol):
def close(self) -> None:
...
if isinstance(resource, Closable):
resource.close()The runtime check verifies that required attributes exist. It does not validate detailed signatures, return annotations, side effects, or semantics. It should not be treated as complete interface validation.
Protocol versus ABC
An abstract base class is useful when you control implementations, need shared code, want explicit registration, or must prevent incomplete instances. Protocol is usually better when you want third-party objects and ordinary duck typing to participate without inheritance.
The article on Python abstract base classes covers nominal contracts in detail. Both approaches can coexist: a library can offer an ABC for official implementations and a smaller protocol for consumers.
Dependency injection and testing
Protocols make test doubles easy to write.
class EmailSender(Protocol):
def send(self, destination: str, subject: str, body: str) -> None:
...
class RegistrationService:
def __init__(self, email: EmailSender) -> None:
self.email = email
def register(self, address: str) -> None:
self.email.send(address, "Welcome", "Account created")
class FakeEmail:
def __init__(self) -> None:
self.messages: list[tuple[str, str, str]] = []
def send(self, destination: str, subject: str, body: str) -> None:
self.messages.append((destination, subject, body))FakeEmail does not inherit from production code. Its compatibility comes from the method signature, keeping tests independent.
Recursive protocols and composition
Protocols may reference themselves and inherit from other protocols.
class Named(Protocol):
name: str
class TreeNode(Named, Protocol):
@property
def children(self) -> list["TreeNode"]:
...Prefer several focused protocols over one large interface. A giant protocol recreates the coupling that structural typing is meant to reduce.
Common mistakes
- Requiring too many members: include only what the consumer uses.
- Treating runtime_checkable as validation: it does not inspect full signatures.
- Using mutable attributes when read-only properties are intended: this can create incompatibilities.
- Skipping a static checker: Protocol does not enforce annotations by itself at runtime.
- Creating abstractions without a substitution need: an interface should solve a concrete design problem.
- Forcing explicit inheritance: that removes the main structural benefit.
Practical design rules
Name protocols after capabilities such as Readable, Closable, or Repository. Define the protocol near the consuming layer because the consumer knows the smallest useful contract. Run mypy or Pyright in CI and add behavioral tests for requirements that annotations cannot express.
Public APIs should document semantics such as idempotency, ordering, thread safety, ownership, and error behavior in addition to types.
Complete example: replaceable cache
from typing import Protocol, TypeVar
T = TypeVar("T")
class Cache(Protocol[T]):
def get(self, key: str) -> T | None:
...
def set(self, key: str, value: T, ttl: int) -> None:
...
class MemoryCache:
def __init__(self) -> None:
self._data: dict[str, object] = {}
def get(self, key: str):
return self._data.get(key)
def set(self, key: str, value: object, ttl: int) -> None:
self._data[key] = value
def load(cache: Cache[str], key: str) -> str:
value = cache.get(key)
if value is None:
value = "computed"
cache.set(key, value, ttl=60)
return valueA Redis adapter, a fake, or another library can satisfy the same contract without depending on MemoryCache.
Conclusion
typing.Protocol adds static verification to Python’s traditional duck typing. It supports independent implementations, small consumer-owned contracts, generic relationships, callback models, and low-coupling tests.
The official Python Protocol documentation covers inheritance, generics, and runtime checks. Use protocols when behavior matters more than a shared class hierarchy, and keep each contract minimal, precise, and focused on the consumer.







