mypy vs pyright on Protocol Variance

TL;DR — When a Protocol has a mutable attribute, that attribute is treated as invariant, and a candidate class whose attribute is a subtype no longer conforms. mypy and pyright can report this differently — mypy raises [misc] on the assignment, pyright raises reportAssignmentType / reportGeneralTypeIssues — and sometimes one flags a variance mismatch the other lets pass. Make the protocol member read-only (a @property getter, or Final) and both tools agree it is covariant.

Structural typing promises that a class conforms to a Protocol if it has the right members. The subtlety is how those members must match. A plain annotated attribute in a Protocol is a read-write slot, and read-write slots are invariant: the candidate’s type must match exactly, not merely be a subtype. This is where the two most-used checkers can diverge, and it is a frequent source of “pyright is red but mypy is green” confusion covered more broadly in pyright vs mypy.

Mutable versus read-only protocol members and variance A candidate class with an Animal attribute is rejected against a protocol whose mutable member is typed Animal-supertype but accepted once that member is made a read-only property. Protocol (mutable) payload: Animal read-write → invariant Protocol (read-only) payload: property get-only → covariant Candidate class payload: Dog (Dog <: Animal) Dog is a subtype of Animal rejected [misc] accepted
A mutable protocol member is invariant and rejects the Dog subtype; a read-only member is covariant and accepts it.

A protocol with a mutable attribute

Here is the setup. Envelope declares a read-write payload typed as the supertype Animal. The candidate DogCrate stores a Dog, a subtype of Animal.

# Python 3.12, checked with mypy 1.13 / pyright 1.1.389
from typing import Protocol

class Animal: ...
class Dog(Animal): ...

class Envelope(Protocol):
    payload: Animal          # read-write attribute → invariant member

class DogCrate:
    def __init__(self) -> None:
        self.payload: Dog = Dog()

def ship(env: Envelope) -> None: ...

ship(DogCrate())             # does DogCrate satisfy Envelope?

Intuitively DogCrate “has an Animal” because a Dog is an Animal. But Envelope.payload is assignable, so a consumer holding an Envelope could legally write env.payload = Cat(). If DogCrate were accepted, that write would smuggle a Cat into a slot the crate believes is a Dog. To stay sound, the member is invariant: payload must be exactly Animal, not Dog. Both checkers agree it should be rejected — but they phrase the rejection differently, and that phrasing is what trips people up.

How each tool reports it

mypy attaches the error to the argument and cites the mutable-member rule with [misc]:

# mypy 1.13
error: Argument 1 to "ship" has incompatible type "DogCrate"; expected "Envelope"  [arg-type]
note: Following member(s) of "DogCrate" have conflicts:
note:     payload: expected "Animal", got "Dog"
note: Protocol member Envelope.payload expected settable variable, ... is invariant  [misc]

pyright frames the same conflict as a general type issue:

# pyright 1.1.389
error: Argument of type "DogCrate" cannot be assigned to parameter "env" of type "Envelope"
    "payload" is invariant because it is mutable
    "Dog" is not assignable to "Animal"  (reportArgumentType / reportGeneralTypeIssues)

The divergence people actually hit is not this symmetric case but the asymmetric ones: with generic Protocols, or a member whose declared type is itself parameterized, pyright’s variance inference is stricter and will emit reportGeneralTypeIssues on a class mypy quietly accepts — and occasionally the reverse, where mypy’s [misc] fires on a nominal-vs-structural edge pyright allows. The root cause is always the same axis: is the member mutable (invariant) or read-only (covariant)? The deeper mechanics are in variance and type parameters.

Making them agree: read-only members

Turn the attribute into a read-only property (a getter with no setter). A get-only member is covariant, so a Dog-returning candidate now satisfies an Animal-returning protocol, and both tools accept it.

# Python 3.12, mypy 1.13 / pyright 1.1.389 — both pass
from typing import Protocol

class Animal: ...
class Dog(Animal): ...

class Envelope(Protocol):
    @property
    def payload(self) -> Animal: ...   # get-only → covariant

class DogCrate:
    @property
    def payload(self) -> Dog: ...      # Dog <: Animal, now accepted

def ship(env: Envelope) -> None: ...
ship(DogCrate())                       # ✓ both checkers agree

If you need the member to stay a plain attribute rather than a property, declaring it Final on the candidate (or ReadOnly in a TypedDict context) has the same covariance-enabling effect: no writes means no soundness hole, so the subtype is permitted.

Runtime vs static analysis Protocol conformance is a purely static question — at runtime DogCrate works fine in ship() regardless of variance, because Python never checks the annotation. The [misc] / reportGeneralTypeIssues error only exists in the checker. Even isinstance(x, Envelope) with a @runtime_checkable protocol tests only for member presence, never the member's type, so it will not reproduce the variance rejection.

Common mistakes

  • Assuming a subtype attribute always conforms. A mutable payload: Dog against payload: Animal is rejected — mypy [misc], pyright reportGeneralTypeIssues — because the read-write member is invariant, not covariant.
  • Blaming a tool bug for the disagreement. When pyright reports reportGeneralTypeIssues and mypy stays silent (or vice versa), the classes differ in mutability or generic parameterization, not in correctness. Reconcile by making the member read-only.
  • Reaching for a mutable TypeVar to “fix” it. Adding a bare invariant TypeVar to the protocol usually produces [type-var] (mypy) or reportInvalidTypeVarUse (pyright) instead of resolving anything. The real fix is removing the write capability.
  • Using @runtime_checkable as a conformance test. It only verifies attribute existence at runtime and will happily pass an object the static checkers reject on variance grounds.

FAQ

Why does pyright reject a class mypy accepts here? The two implement structural variance with slightly different strictness, especially for generic protocol members. pyright infers member variance more aggressively and emits reportGeneralTypeIssues; mypy is sometimes lenient on the same nominal-vs-structural edge. Making the member read-only removes the ambiguity for both.

Does making the member a property hurt anything? Only if you genuinely need callers to assign through the protocol. If the interface is read-only in practice — most consumer protocols are — a @property getter is the correct, covariant design and eliminates the disagreement entirely.

Back to Pyright vs mypy Comparison