Python already uses duck typing. typing.Protocol describes that behavioral contract to static analysis tools without requiring classes to share a base class. It complements Python type hints and does not change runtime behavior by itself.

from typing import Protocol

class UserRepository(Protocol):
    def find(self, user_id: int) -> dict | None:
        ...

def display_user(repo: UserRepository, user_id: int) -> str:
    user = repo.find(user_id)
    return user["name"] if user else "Not found"

Any class with a compatible find method satisfies the protocol. No explicit inheritance is needed. This makes in-memory test implementations and database adapters easier to substitute.

Protocols may include properties:

class HasId(Protocol):
    @property
    def id(self) -> int:
        ...

Describe only what consumers use. Large protocols become difficult interfaces and hide responsibilities. The official Protocol specification covers members and subprotocols.

@runtime_checkable enables limited isinstance checks, but it does not fully verify signatures. Do not use it for external data validation; Pydantic solves a different problem.

Choose Protocol for small behavior-based boundaries. Choose an ABC when nominal membership or shared implementation matters. Keep contracts small, run a type checker in CI, and test implementation behavior. Protocol formalizes duck typing while preserving loose coupling at service, repository, and client boundaries.

Compatibility includes method signatures

Matching a method name is not enough. A type checker compares parameters and return types too. A repository whose find() accepts a string does not satisfy a contract requiring an integer. Variance rules matter: broadly, an implementation must accept everything the consumer may pass and return something compatible with what the consumer expects.

This diagnosis happens during static analysis, not when Python imports the module. Pyright, mypy, and other tools may phrase errors differently and expose different strictness settings. Run the chosen checker across the project, because inference and configuration affect the result.

An explicit assignment can document the composition boundary:

repository: UserRepository = MemoryRepository()

There is no need to scatter such assignments everywhere. They are most helpful where a concrete adapter is connected to a consumer and an incompatibility should be reported close to application setup.

Generic protocols

Make a protocol generic when one behavior works across value types:

from typing import Protocol, TypeVar

T_co = TypeVar("T_co", covariant=True)

class Reader(Protocol[T_co]):
    def read(self) -> T_co:
        ...

def print_text(reader: Reader[str]) -> None:
    print(reader.read())

The co suffix documents covariance: this reader only produces values. A protocol that consumes and produces the same type will commonly need an invariant parameter. Do not add variance merely to silence a checker; model the actual direction in which values flow.

Recent Python versions also provide type-parameter syntax, but a library must honor its minimum supported interpreter. Consistency with the project's version is often more useful than immediately adopting the newest notation.

Callbacks and asynchronous methods

A protocol can describe class methods, static methods, read-only attributes, and async methods. For a callback with keyword-only parameters or overloads, a protocol containing __call__ is often more precise than Callable:

class Transformer(Protocol):
    def __call__(
        self,
        value: str,
        *,
        strip: bool = True,
    ) -> str:
        ...

def normalize(text: str, transform: Transformer) -> str:
    return transform(text, strip=True)

This retains parameter names and categories, including the keyword-only argument. It can also declare extra callback attributes if those genuinely belong to the consumer's contract.

Declare asynchronous behavior with async def in the protocol. Do not describe a synchronous method as asynchronous merely because one implementation accesses the network. The consumer must know whether it needs await, so the boundary should express that difference.

Compose small capabilities

Protocols may inherit from other protocols:

class Readable(Protocol):
    def read(self, size: int = -1) -> bytes:
        ...

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

class ReadableResource(Readable, Closeable, Protocol):
    pass

A function that only calls read() should accept Readable, not the combined interface. Requiring close() without using it increases coupling. Segregated capabilities create smaller test doubles and let existing library types satisfy a boundary naturally.

Explicitly inheriting from a protocol is allowed and can document intent. It may also expose default implementations supplied by the protocol. The structural benefit remains that unrelated existing classes can conform without importing or naming the protocol.

The limits of runtime_checkable

With @runtime_checkable, isinstance() checks for the presence of required attributes. It does not invoke methods or fully compare their signatures and annotations. An object with close = 42 may pass a presence-oriented check even though calling it fails. Protocol runtime checks can also be slower than nominal isinstance() checks.

Use the decorator only where a shallow capability check is useful, then handle normal operational errors. Validate JSON, forms, and environment variables with a data-validation tool. Use an ABC when runtime membership in a hierarchy is the actual requirement.

Testing through a narrow boundary

Consider a receipt service:

class Sender(Protocol):
    def send(self, destination: str, message: str) -> None:
        ...

def issue_receipt(
    sender: Sender,
    email: str,
    total: str,
) -> None:
    sender.send(email, f"Total: {total}")

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

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

A test passes SpySender without subclassing or a complicated mock. The protocol states exactly what the service consumes. Static compatibility is not a substitute for behavioral tests, however: it cannot guarantee business rules, failure handling, or security.

Avoid creating a protocol for every concrete class. It pays off at a boundary with multiple plausible implementations, when consumers need a narrow subset, or when an external dependency must be replaced in tests. Direct annotations are usually enough for simple internal functions.

Attributes, mutation, and implementation mistakes

A writable protocol attribute is a stronger requirement than a read-only property. If consumers only inspect a value, declare @property; this permits implementations to compute it or expose a compatible property without promising assignment. If consumers mutate it, the accepted type must remain safe in both read and write directions.

Explicit inheritance can expose a subtle mistake. A subclass that inherits a protocol but leaves a required member unimplemented may remain abstract and fail when instantiated. Structural implementations do not inherit that runtime behavior; their compatibility is reported by the checker. Decide whether you want documentation by inheritance or complete independence.

Review the boundary

Before publishing a protocol, find every member used by the consumer and remove unused requirements. Check synchronous versus asynchronous methods, positional versus keyword-only parameters, optional returns, and whether exceptions are part of the documented behavior.

Run the checker with at least two implementations: the production adapter and a small test substitute. Then run behavioral contract tests against both. Types describe call shape, but they cannot guarantee that a repository commits correctly, a client closes resources, or an operation is idempotent.

Public libraries should treat changes to a protocol carefully. Adding a required member can break every structural implementation without an import error. Prefer a new smaller capability or a versioned transition when compatibility matters.

Document semantic expectations that annotations cannot encode, including ownership, idempotency, expected exceptions, and whether callers may retain or mutate returned objects. The protocol defines call shape; documentation and contract tests define dependable behavior across implementations.

Keep that documentation beside the protocol so implementations and consumers evolve from the same contract.