Python runtime_checkable: Protocol at Runtime

Published on: August 29, 2026
Reading time: 5 minutes
A person typing on a laptop with a Python programming book visible, capturing technology and learning.

typing.runtime_checkable allows selected Protocol classes to participate in isinstance() and issubclass() checks. It connects structural typing with runtime inspection, but there is an important limitation: the check generally confirms member presence, not complete signatures, parameter types, return types, or behavior.

This guide explains declarations, methods, data attributes, properties, false positives, ABC comparisons, plugins, adapters, generic Protocols, performance, stronger validation, and the cases where explicit registration is safer.

Structural Protocols

from typing import Protocol

class Savable(Protocol):
    def save(self) -> None: ...

A class does not need to inherit from Savable. A static checker can accept any class with a compatible method.

class Document:
    def save(self) -> None:
        print("saved")


def persist(item: Savable) -> None:
    item.save()

persist(Document())

This compatibility exists for static analysis. A normal Protocol cannot be used directly in an instance check.

Adding runtime_checkable

from typing import Protocol, runtime_checkable

@runtime_checkable
class Savable(Protocol):
    def save(self) -> None: ...

print(isinstance(Document(), Savable))

The decorator authorizes runtime inspection. The result is true when the object exposes the required members in a recognizable form.

The check does not validate signatures

class Incompatible:
    def save(self, path: str, force: bool) -> int:
        return 1

print(isinstance(Incompatible(), Savable))

The object has a callable member named save, so the runtime check may pass even though the signature is incompatible. A static checker can report the mismatch; isinstance() cannot provide the same precision.

Presence is not behavior

An object may expose the right method name and still fail, perform a different operation, or raise unexpected exceptions. runtime_checkable tests a shallow shape, not semantics. Do not treat a positive result as proof of correctness, safety, or quality.

Data attributes

@runtime_checkable
class Named(Protocol):
    name: str

class Product:
    name = "Keyboard"

isinstance(Product(), Named)

The test looks for a name member. It does not guarantee that the runtime value is a string. An integer or problematic descriptor may still satisfy a superficial presence check.

Properties

@runtime_checkable
class SizedResource(Protocol):
    @property
    def size(self) -> int: ...

A property or attribute with the expected name may satisfy the check. Return type and side effects are not validated. Avoid descriptors that perform I/O or dangerous work merely because an introspection operation accessed them.

Method-only Protocols and issubclass

issubclass() has stronger restrictions, especially when the Protocol includes data members. Method-only Protocols are better candidates for class-level checks.

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

class Resource:
    def close(self) -> None: ...

issubclass(Resource, Closable)

Comparison with hasattr

if hasattr(obj, "save"):
    obj.save()

hasattr() may be enough for a local one-member check. runtime_checkable centralizes a reusable set of capabilities, documents the interface, and participates in static analysis.

Comparison with abstract base classes

An ABC normally uses explicit inheritance or virtual registration. It is a better choice when you control the hierarchy, share implementation, enforce abstract methods during construction, or require nominal identity. Protocol is useful for decoupling and compatibility with external classes.

Plugin discovery

@runtime_checkable
class Plugin(Protocol):
    name: str
    def start(self) -> None: ...
    def stop(self) -> None: ...


def load(candidate: object) -> Plugin:
    if not isinstance(candidate, Plugin):
        raise TypeError("incompatible plugin")
    return candidate

This is a useful first filter. For untrusted plugins, also validate API version, configuration, signatures, permissions, and behavior. Explicit registration or inspect.signature() may be necessary.

Adapters

When an external object almost matches a Protocol, an adapter can expose the exact contract:

class FileAdapter:
    def __init__(self, file) -> None:
        self.file = file

    def save(self) -> None:
        self.file.flush()

Adapters are safer than relying on accidental name coincidences.

TypeGuard for stronger checks

from typing import TypeGuard

def is_savable(value: object) -> TypeGuard[Savable]:
    method = getattr(value, "save", None)
    return callable(method)

This example still only checks callability, but a custom function can validate markers, versions, attributes, or signatures. The Python TypeGuard guide explains the trust relationship.

Do not call methods during validation

Invoking a capability merely to see whether it works may cause side effects. Prefer metadata, inspection, registration, and separate tests. Interface validation should not send messages, write files, or mutate state.

Performance

Protocol checks can be slower than simple nominal checks because they inspect members. Do not repeat them in hot loops. Validate once at a boundary, retain the typed reference, and execute normal method calls afterward.

Caching and monkey-patching

Modern implementations may freeze the set of Protocol members and use static attribute lookup. Dynamically changing a Protocol or monkey-patching candidate classes can produce surprising results across versions. Avoid designing core behavior around mutable interfaces.

Optional capabilities

Protocol has no direct optional-member feature. Define smaller capability Protocols:

@runtime_checkable
class Savable(Protocol):
    def save(self) -> None: ...

@runtime_checkable
class Exportable(Protocol):
    def export(self, path: str) -> None: ...

Consumers test only the capability they need. Interface segregation improves reuse and lowers false requirements.

Capability composition

class Repository(Savable, Exportable, Protocol):
    pass

A composed Protocol groups capabilities. Verify runtime behavior and decorator requirements with the supported Python versions and checker.

Generic Protocols

from typing import TypeVar

T = TypeVar("T")

@runtime_checkable
class Reader(Protocol[T]):
    def read(self) -> T: ...

Generic arguments are erased at runtime. You can test the unparameterized Protocol, but an instance check cannot confirm that read() returns a particular type.

Parameterized Protocols in isinstance

Avoid isinstance(obj, Reader[str]). Parameterized typing objects are generally not runtime classes suitable for this operation, and the generic argument is unavailable to structural inspection.

Common mistakes

  • Assuming signatures were checked: runtime inspection mostly observes member names.
  • Using the result as a security guarantee: behavior and value types are not proven.
  • Testing a parameterized generic Protocol: runtime cannot validate the type argument.
  • Designing huge Protocols: small capabilities are easier to satisfy and test.
  • Calling methods during validation: side effects may occur.
  • Checking repeatedly in hot loops: introspection has overhead.

Complete example: notification providers

from typing import Protocol, runtime_checkable

@runtime_checkable
class Notifier(Protocol):
    name: str
    def send(self, destination: str, message: str) -> None: ...

class Email:
    name = "email"
    def send(self, destination: str, message: str) -> None:
        print(f"email to {destination}: {message}")

class Sms:
    name = "sms"
    def send(self, destination: str, message: str) -> None:
        print(f"sms to {destination}: {message}")


def register(candidate: object) -> Notifier:
    if not isinstance(candidate, Notifier):
        raise TypeError("invalid notifier")
    return candidate

notifiers = [register(Email()), register(Sms())]
for notifier in notifiers:
    notifier.send("customer", "Order approved")

The check accepts implementations without shared inheritance. A production registry can additionally validate configuration, version, limits, and error handling.

When to avoid runtime_checkable

Use nominal inheritance when you control every implementation and need strong invariants. Use an explicit validator when signatures and values matter at runtime. Use plugin registration when trust and versioning are required. Use runtime_checkable when a shallow capability check is sufficient for dispatch or a clearer error message.

Conclusion

runtime_checkable makes selected Protocols usable with isinstance() and issubclass(), supporting plugins, adapters, and capability detection. The check mainly confirms member presence, not the complete contract.

The official Python runtime_checkable documentation defines the restrictions. Combine static analysis with explicit runtime validation when signatures, data integrity, or security matter, and treat structural checks as screening rather than absolute proof.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    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 Unpack: Typed kwargs and Variadics

    Learn Python Unpack to type **kwargs with TypedDict, expand variadic tuples, and preserve precise callable signatures.

    Ler mais

    Tempo de leitura: 4 minutos
    29/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

    Required and NotRequired: Optional TypedDict Keys

    Learn Python Required and NotRequired to control mandatory and optional TypedDict keys without confusing absence with None.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    High-angle view of woman coding on a laptop, with a Python book nearby. Ideal for programming and tech content.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ReadOnly: Protect TypedDict Fields

    Learn Python ReadOnly to protect TypedDict fields, model stable response contracts, and prevent accidental writes during static analysis.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026
    Close-up of hands typing on a laptop keyboard, Python book in sight, coding in progress.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python TypeAliasType: Runtime Type Aliases

    Learn Python TypeAliasType to create explicit aliases, inspect them at runtime, and model reusable generic APIs safely.

    Ler mais

    Tempo de leitura: 6 minutos
    29/08/2026
    Detailed view of programming code in a dark theme on a computer screen.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python overload: Precise Function Signatures

    Learn Python overload for precise signatures with Literal, None, generics, methods, and return types that depend on arguments.

    Ler mais

    Tempo de leitura: 6 minutos
    29/08/2026
    A person reads 'Python for Unix and Linux System Administration' indoors.
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python ClassVar: Separate Class and Instance State

    Learn Python ClassVar to separate class and instance state in dataclasses, registries, caches, inheritance, and counters.

    Ler mais

    Tempo de leitura: 5 minutos
    29/08/2026