Skip to content

Fix: Python Protocol Not Working — Type Checker Rejects Compatible Class, runtime_checkable Fails, or Protocol Not Recognized

FixDevs · (Updated: )

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 issubclass

Or 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 others

Why 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 inherit from 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 with isinstance(), 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: str requires that the class or instance has a name attribute of type str. 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 method def process(self, value: float) -> str may 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 method

Warning: @runtime_checkable only checks for the presence of attributes and methods — not their type signatures. isinstance(obj, Serializable) returns True if obj has a to_json attribute, 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 close

Fix 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"))  # OK

Extend 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 NotImplementedError

Fix 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()  # OK

Dataclass 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.0

Fix 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 Drawable

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

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles