Python often relies on duck typing: if an object supports the operation a function needs, its concrete class does not matter. typing.Protocol gives static type checkers a way to describe that idea explicitly without requiring implementations to inherit from a shared base class.

Define the behavior you consume

from typing import Protocol

class ByteWriter(Protocol):
    def write(self, data: bytes) -> int: ...


def emit_header(writer: ByteWriter) -> None:
    writer.write(b"NLR1")

Any statically compatible object can satisfy ByteWriter, even if its class never mentions the protocol.

This is structural typing: compatibility is based on available members rather than nominal inheritance.

Keep protocols small

A protocol is most useful when it captures the minimum behavior a consumer needs. If a function only calls write, do not require close, flush, configuration properties, and unrelated methods “for completeness.”

Small protocols reduce coupling and make tests easier because a lightweight fake can implement only the relevant behavior.

Use properties when callers need attributes

class Job(Protocol):
    @property
    def name(self) -> str: ...

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

Choose the protocol shape based on how consumers use the object. Do not expose implementation details merely to satisfy a type checker.

Runtime checks are optional and limited

Protocols primarily serve static analysis. @runtime_checkable allows limited isinstance checks:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...

Runtime protocol checks only verify the presence of members, not full type signatures. Do not use them as a substitute for validation or authorization.

Protocols versus abstract base classes

Use an abstract base class when you intentionally own an inheritance hierarchy, want shared implementation, or need nominal runtime identity. Use a protocol when consumers care about a capability and implementations may come from unrelated libraries or application layers.

The two approaches can coexist. A concrete class may inherit from an ABC and still structurally satisfy several protocols.

Common pitfalls

Designing protocols around providers

Start from what the consuming function needs, not every method a large service happens to expose. Consumer-driven interfaces stay smaller.

Creating a protocol for every class

Concrete types are often clearer inside a module. Protocols earn their cost at boundaries where multiple implementations, tests, or external types are expected.

Assuming type hints enforce runtime behavior

Python does not automatically reject incompatible objects at runtime because a parameter is annotated with a protocol. Static checking and runtime validation solve different problems.

Making mutable attributes too permissive

Writable protocol attributes impose both read and write expectations. Prefer read-only properties when mutation is not part of the contract.

Use structural interfaces deliberately

Protocols let Python code keep the flexibility of duck typing while documenting and statically checking important boundaries. The best protocols are narrow, consumer-oriented, and focused on behavior rather than class ancestry.