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 candidateThis 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):
passA 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.







