Python Protocol: Structural Typing

Published on: August 28, 2026
Reading time: 4 minutes
A developer typing code on a laptop with a Python book beside in an office.

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 product

The 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 value

A 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.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    A developer typing code on a laptop with a Python book beside in an office.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python memoryview: Zero-Copy Buffers

    Learn Python memoryview for zero-copy buffers, slices, writable bytearray data, cast, mmap, struct, sockets, and safe lifecycle management.

    Ler mais

    Tempo de leitura: 4 minutos
    28/08/2026
    A person typing on a laptop with a Python programming book visible, capturing technology and learning.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python tracemalloc: Track Memory

    Learn Python tracemalloc to measure peaks, create and compare snapshots, filter allocations, and diagnose memory growth and leaks.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Commuters line up at a subway station platform, showcasing public transportation dynamics.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python queue: Communicate Between Threads

    Learn Python queue for thread communication with FIFO, LIFO, priority, backpressure, task tracking, sentinels, and safe shutdown.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    A vertical macro shot showcasing metallic socket wrenches in a shallow focus arrangement.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python selectors: Many Sockets

    Learn Python selectors to multiplex sockets, handle partial reads and writes, manage buffers, deadlines, wakeups, and backpressure.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    Vivid close-up of a green tree python coiled on a branch in the rainforest.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python contextvars: Async Context

    Learn Python contextvars for task-local context, request IDs, logging, copy_context, thread propagation, and safe token restoration.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026
    A minimalist June calendar page adorned with red hearts for a romantic touch.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python sched: Schedule Events

    Learn Python sched to schedule events, use priorities, cancel work, create drift-free recurrence, test with fake clocks, and integrate executors.

    Ler mais

    Tempo de leitura: 5 minutos
    28/08/2026