Fix: Python Protocol Not Working — Type Checker Rejects Compatible Class, runtime_checkable Fails, or Protocol Not Recognized
Part of: Python Errors
Quick Answer
How to fix Python Protocol class issues — structural subtyping vs nominal typing, runtime_checkable, Protocol inheritance, TypeVar constraints, and common mypy/pyright errors with Protocol.
The Problem
A class that implements all the required methods is rejected by the type checker:
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # mypy error: Argument 1 has incompatible type "Circle"Or isinstance() raises a TypeError at runtime:
from typing import Protocol
class Serializable(Protocol):
def to_json(self) -> str: ...
obj = MyObject()
isinstance(obj, Serializable) # TypeError: Protocols with non-method members don't support issubclassOr a Protocol with a class variable isn’t matched:
class HasName(Protocol):
name: str # Class variable requirement
class Person:
def __init__(self, name: str):
self.name = name # Instance attribute
obj: HasName = Person("Alice") # Works in some type checkers, rejected in othersWhy This Happens
Python’s Protocol implements structural subtyping (duck typing with type checking), but has specific rules:
- Structural != nominal — unlike abstract base classes, you don’t need to
inheritfrom a Protocol. A class automatically satisfies a Protocol if it has the required attributes and methods with compatible signatures. But signatures must match exactly — return types, parameter types, and names all matter. isinstance()requires@runtime_checkable— by default, Protocols are only for static type checking. To use them withisinstance(), you must decorate the Protocol with@runtime_checkable. Even then, runtime checks only verify the presence of attributes/methods, not their signatures.- Class variables vs instance attributes — a Protocol that declares
name: strrequires that the class or instance has anameattribute of typestr. Instance attributes defined in__init__do satisfy this, but type checkers differ in how strictly they enforce this. - Method signatures must be compatible — if the Protocol requires
def process(self, value: int) -> str, a class methoddef process(self, value: float) -> strmay or may not satisfy it depending on variance rules.
The deeper reason Protocol confusion is so common is that Python developers learned the language through duck typing — “if it walks like a duck, it quacks like a duck, treat it as a duck” — and Protocol formalizes that without changing runtime behavior. The static checker now enforces what the runtime never did: that every method present on the duck has the exact right signature. Code that worked fine for years suddenly fails type-checking, not because the runtime changed, but because the tooling is now enforcing constraints the codebase always implicitly assumed.
The second source of confusion is that Protocol arrived in PEP 544 with Python 3.8 (and is back-ported via typing_extensions for older versions), but the ecosystem still has two competing patterns: nominal abc.ABC subclasses that require explicit inheritance, and the older collections.abc Mixins. Library authors who started before 3.8 still ship ABCs; newer libraries (including parts of the standard library like os.PathLike) ship Protocols. Mixing the two in one codebase is fine but requires understanding when nominal vs structural checking applies — isinstance(obj, MyABC) checks inheritance, isinstance(obj, MyProtocol) (with @runtime_checkable) checks attribute presence only.
How Other Tools Handle This
Python’s structural typing has a long history, and comparing it with adjacent tools clarifies when to use which.
Python Protocol (PEP 544, since 3.8) vs ABC. Abstract base classes (abc.ABC with @abstractmethod) enforce nominal typing: a class is a MyABC only if it inherits from MyABC or is registered with MyABC.register(). Protocols enforce structural typing: any class with matching attributes satisfies the Protocol regardless of inheritance. ABCs trigger TypeError at instantiation if any @abstractmethod is unimplemented, providing a hard runtime check; Protocols are static-only unless decorated with @runtime_checkable. Use ABC when you want to share implementation (mixins) or enforce a contract at instantiation. Use Protocol when you want to type third-party objects you do not control (a class from a vendor library can satisfy your Protocol without being modified).
Protocol vs duck typing. Plain duck typing has zero static guarantees — obj.read() works at runtime if obj has a read method and fails otherwise. Protocol gives you the same flexibility at runtime plus a static check that every call site passes objects with the right shape. The cost is that you must define the Protocol and your type checker must be running. For one-off internal scripts, plain duck typing is fine; for library APIs and long-lived code, Protocols pay off within a few months.
mypy vs pyright on Protocol checking. mypy is generally lenient about parameter names — def process(self, input: str) -> int satisfies a Protocol declaring def process(self, data: str) -> int even though input and data differ. pyright is strict by default about parameter names because Python supports keyword arguments at call sites, and a name mismatch could break callers using obj.process(data='x') against an implementation using input='x'. If you support keyword-argument calls, match parameter names exactly; if your Protocol methods are always positional, mark them with the Concatenate/positional-only marker / (PEP 570). pyright also enforces stricter variance rules on generic Protocols and flags issues mypy still misses as of 2026.
@runtime_checkable vs structural type guards. @runtime_checkable lets isinstance(x, MyProtocol) work at runtime, but only checks attribute presence — not signatures, not types. For genuine runtime validation, use TypeGuard[T] (PEP 647) or TypeIs[T] (PEP 742, Python 3.13+) to write predicate functions the type checker treats as narrowing. TypeIs is strictly more powerful than TypeGuard because it propagates the negative case (else branch sees the type narrowed away). For Pydantic users, Pydantic’s TypeAdapter performs full structural validation against a Protocol’s annotated shape — slower but exhaustive.
Other languages compared. Go’s interfaces are structural like Python’s Protocols — any type with the right methods implements an io.Reader without declaring it. Rust’s traits are nominal — you must impl Trait for Type explicitly. TypeScript’s interfaces are structural like Protocols and Go interfaces, with full signature checking. Java and C# use nominal interfaces with explicit implements and : syntax. Python’s choice of structural typing in PEP 544 followed Go’s success and intentionally mirrors how Python developers already write code.
In Production: Incident Lens
The most common Protocol-related production bug is the silent @runtime_checkable false positive. You write if isinstance(obj, Closeable): obj.close() and the check passes because obj happens to have an attribute named close — but it is an integer (a flag bit) or a property that returns None, not a method. obj.close() raises TypeError: 'int' object is not callable. @runtime_checkable only checks attribute presence, not callability. For real runtime safety, follow up with callable(obj.close) or use a stricter validation library.
The second incident pattern is over-broad Protocols slipping into public APIs. You define class HasUser(Protocol): user_id: str for internal use and accidentally export it through __all__. Three months later you need to add a method to it, and every downstream library that wrote class Order: user_id: str is now technically declared to implement your Protocol — but the new method breaks them. Treat exported Protocols like ABCs and version them carefully, or keep them internal and import them with leading underscores.
The third recurring issue is performance degradation from isinstance checks on @runtime_checkable Protocols in hot paths. The check walks __class__ and tries each declared attribute against hasattr, which is significantly slower than a nominal isinstance(obj, SomeClass) check. Profiling a Pydantic-heavy API often shows 5-15% of request time spent in Protocol isinstance checks. Move the check to the boundary of the system (deserialization), cache the result on the instance if you must repeat it, or switch the hot-path check to a nominal type.
Fix 1: Define Protocols Correctly
Protocol methods use ... or pass as the body — they define the interface, not the implementation:
from typing import Protocol
# WRONG — Protocol with actual implementation (should use ABC instead)
class Drawable(Protocol):
def draw(self) -> None:
print("default draw") # This becomes an implementation, not just a signature
# CORRECT — Protocol with ellipsis bodies
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
# CORRECT — Protocol with properties
class HasArea(Protocol):
@property
def area(self) -> float: ...
@property
def perimeter(self) -> float: ...
# CORRECT — Protocol with class methods
class Factory(Protocol):
@classmethod
def create(cls, data: dict) -> 'Factory': ...Implementing a Protocol — no inheritance required:
class Circle:
def __init__(self, radius: float):
self.radius = radius
def draw(self) -> None:
print(f"Drawing circle with radius {self.radius}")
def resize(self, factor: float) -> None:
self.radius *= factor
# Circle implicitly satisfies Drawable — no need to inherit from it
def render(shape: Drawable) -> None:
shape.draw()
render(Circle(5.0)) # OK — Circle has .draw() and .resize()Fix 2: Enable runtime_checkable for isinstance Checks
If you need isinstance() checks at runtime, decorate the Protocol:
from typing import Protocol, runtime_checkable
# WRONG — no decorator, isinstance() raises TypeError
class Serializable(Protocol):
def to_json(self) -> str: ...
isinstance({}, Serializable) # TypeError!
# CORRECT — add @runtime_checkable
@runtime_checkable
class Serializable(Protocol):
def to_json(self) -> str: ...
class JsonDocument:
def to_json(self) -> str:
return '{"type": "document"}'
isinstance(JsonDocument(), Serializable) # True
isinstance({}, Serializable) # False — dict has no to_json methodWarning:
@runtime_checkableonly checks for the presence of attributes and methods — not their type signatures.isinstance(obj, Serializable)returnsTrueifobjhas ato_jsonattribute, even if it’s not callable or returns the wrong type.
Practical runtime_checkable pattern:
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
def safe_close(resource: object) -> None:
if isinstance(resource, Closeable):
resource.close()
# No error if resource doesn't support closeFix 3: Fix Signature Mismatch Errors
Protocol method signatures must be compatible with the implementing class:
from typing import Protocol
class Processor(Protocol):
def process(self, data: str) -> int: ...
# WRONG — parameter name mismatch (mypy is lenient about this, pyright is strict)
class MyProcessor:
def process(self, input: str) -> int: # 'input' vs 'data'
return len(input)
# WRONG — return type incompatible
class BadProcessor:
def process(self, data: str) -> float: # float is not int
return len(data) * 1.5
# WRONG — extra required parameter
class AlsoWrong:
def process(self, data: str, encoding: str) -> int: # extra required param
return len(data)
# CORRECT — extra optional parameter is OK
class GoodProcessor:
def process(self, data: str, encoding: str = 'utf-8') -> int:
return len(data.encode(encoding))Use Protocol with TypeVar for generic protocols:
from typing import Protocol, TypeVar
T = TypeVar('T')
class Comparable(Protocol[T]):
def __lt__(self, other: T) -> bool: ...
def __le__(self, other: T) -> bool: ...
class Score:
def __init__(self, value: int):
self.value = value
def __lt__(self, other: 'Score') -> bool:
return self.value < other.value
def __le__(self, other: 'Score') -> bool:
return self.value <= other.value
def find_minimum(items: list[Comparable]) -> Comparable:
return min(items)Fix 4: Protocol Inheritance and Composition
Combine Protocols to build composite interfaces:
from typing import Protocol
class Readable(Protocol):
def read(self, n: int = -1) -> bytes: ...
class Writable(Protocol):
def write(self, data: bytes) -> int: ...
class Seekable(Protocol):
def seek(self, pos: int) -> int: ...
def tell(self) -> int: ...
# Compose protocols
class ReadWritable(Readable, Writable, Protocol): ...
class BinaryFile(Readable, Writable, Seekable, Protocol): ...
# A class satisfying BinaryFile must implement all methods from all three protocols
import io
def process_file(f: BinaryFile) -> None:
data = f.read(1024)
f.seek(0)
f.write(data)
# io.BytesIO satisfies BinaryFile
process_file(io.BytesIO(b"test data")) # OKExtend Protocol with abstract behavior:
from typing import Protocol
from abc import abstractmethod
class Repository(Protocol):
@abstractmethod
def find_by_id(self, id: int): ...
@abstractmethod
def save(self, entity): ...
def find_all(self):
# Protocol CAN have default implementations
# Subclasses can override or use this default
raise NotImplementedErrorFix 5: Protocols with Class Variables and Properties
Declaring class-level variables and properties in a Protocol:
from typing import Protocol, ClassVar
class Configuration(Protocol):
# Instance attribute — implementing class must have this as instance or class attr
debug: bool
# Class variable — must be on the class, not the instance
VERSION: ClassVar[str]
# Property
@property
def name(self) -> str: ...
class AppConfig:
VERSION = "1.0.0" # Class variable ✓
def __init__(self, debug: bool = False):
self.debug = debug # Instance attribute ✓
@property
def name(self) -> str: # Property ✓
return "MyApp"
# AppConfig satisfies Configuration
config: Configuration = AppConfig() # OKDataclass as Protocol implementation:
from dataclasses import dataclass
from typing import Protocol
class Point2D(Protocol):
x: float
y: float
@dataclass
class CartesianPoint:
x: float
y: float
@dataclass
class PolarPoint:
r: float
theta: float
@property
def x(self) -> float: # Property satisfies 'x: float' in Protocol
import math
return self.r * math.cos(self.theta)
@property
def y(self) -> float:
import math
return self.r * math.sin(self.theta)
def distance_from_origin(p: Point2D) -> float:
return (p.x ** 2 + p.y ** 2) ** 0.5
distance_from_origin(CartesianPoint(3.0, 4.0)) # 5.0
distance_from_origin(PolarPoint(5.0, 0.927)) # ~5.0Fix 6: Explicit Protocol Implementation with Typing
When you want to be explicit that a class implements a Protocol (useful for documentation and early error detection):
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
# Option 1: Inherit from Protocol (makes it explicit but class is now a Protocol itself)
class Circle(Drawable, Protocol): # This makes Circle a Protocol, not an implementation!
... # DON'T do this for concrete classes
# Option 2: Use runtime_checkable + assert in tests
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
# In tests — verify implementation at development time
assert isinstance(Circle(), Drawable), "Circle must implement Drawable"
# Option 3: TYPE_CHECKING guard for explicit annotation
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import assert_type
c = Circle()
assert_type(c, Drawable) # Type checker error if Circle doesn't satisfy Drawable
# Option 4: Python 3.12+ — @override for method implementations
from typing import override
class Circle:
@override # Explicit, but only works with class inheritance — not Protocols
def draw(self) -> None:
print("Drawing circle")Protocol with __init__ — use a factory Protocol instead:
# Protocols can't constrain __init__ signatures meaningfully
# Use a factory Protocol instead:
class WidgetFactory(Protocol):
def __call__(self, width: int, height: int) -> 'Widget': ...
class ButtonFactory:
def __call__(self, width: int, height: int) -> 'Widget':
return Button(width, height)
def create_widget(factory: WidgetFactory, w: int, h: int) -> 'Widget':
return factory(w, h)Still Not Working?
mypy and pyright disagree on Protocol compatibility — mypy and pyright implement Protocol checking with slightly different strictness. If one passes and the other fails, check: (1) property vs attribute compatibility, (2) contravariance/covariance in method parameters, and (3) whether you have strict = true in your config. pyright is generally stricter.
Protocol check fails for __dunder__ methods — some special methods require exact signatures. For example, __len__ must return int, not float. Check the exact signature required by the Protocol and match it precisely.
Callable Protocol for function-like objects — if you need to type a callable with a specific signature, use Callable from typing or a Protocol with __call__:
from typing import Callable, Protocol
# Using Callable — simpler for plain functions
Handler = Callable[[str, int], bool]
# Using Protocol — allows for additional attributes on the callable
class HandlerProtocol(Protocol):
retry_count: int # Callable with extra attributes
def __call__(self, event: str, code: int) -> bool: ...TypeVar bound to Protocol for generic functions:
from typing import TypeVar, Protocol
class Drawable(Protocol):
def draw(self) -> None: ...
T = TypeVar('T', bound=Drawable)
def draw_twice(shape: T) -> T:
shape.draw()
shape.draw()
return shape # Returns the same type as input, not just Drawable
circle: Circle = draw_twice(Circle()) # Returns Circle, not DrawableFor related Python type hint issues, see Fix: Python mypy Type Error, Fix: Python Decorator Not Working, Fix: Pydantic Validation Error, and Fix: Python AttributeError NoneType Has No Attribute.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Python contextmanager Not Working — GeneratorExit, Missing yield, or Cleanup Not Running
How to fix Python context manager issues — @contextmanager generator, __enter__ and __exit__, exception handling inside with blocks, async context managers, and common pitfalls.
Fix: Python pathlib Not Working — Path Object Errors, Joins, and Common Pitfalls
How to fix Python pathlib issues — TypeError with string concatenation, path joining, glob patterns, reading files, cross-platform paths, and migrating from os.path.
Fix: Python asyncio.gather Not Handling Errors — Exceptions Swallowed or All Tasks Cancelled
How to fix asyncio.gather error handling — return_exceptions parameter, partial failures, task cancellation propagation, TaskGroup alternatives, and exception isolation patterns.
Fix: Python Decorator Not Working — Function Signature Lost or Decorator Not Applied
How to fix Python decorator issues — functools.wraps, decorator factories with arguments, class decorators, stacking order, async function decorators, and common pitfalls.