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.
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.
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: Dogagainstpayload: Animalis rejected — mypy[misc], pyrightreportGeneralTypeIssues— because the read-write member is invariant, not covariant. - Blaming a tool bug for the disagreement. When pyright reports
reportGeneralTypeIssuesand 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
TypeVarto “fix” it. Adding a bare invariantTypeVarto the protocol usually produces[type-var](mypy) orreportInvalidTypeVarUse(pyright) instead of resolving anything. The real fix is removing the write capability. - Using
@runtime_checkableas 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.