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.

Why a writable protocol member must be invariant A consumer typed to the protocol assigns a Cat through the mutable payload, which lands in a slot the candidate class annotated as Dog, so accepting the subtype would be unsound. Consumer env: Envelope env.payload = Cat() legal: payload is settable DogCrate slot payload: Dog now holds a Cat unsound ∴ invariant A read-write member is both source and sink, so only an exact type match is sound.
Because a consumer can assign through the settable member, accepting a subtype attribute would let a Cat land in a Dog slot — so the member is invariant.

The invariance here is the same soundness rule that makes list[Dog] unassignable to list[Animal]. A read-write member sits in two positions at once: reading it out is a covariant use (returning a Dog where an Animal is expected is fine), while writing into it is a contravariant use (only an Animal or a supertype may be stored). The one type that satisfies both directions simultaneously is an exact match, which is precisely what invariance means. Any attribute you can assign to — a bare annotated slot in the Protocol body — therefore pins the candidate to the identical type.

You can confirm what each checker infers with reveal_type, which is the fastest way to debug a surprising rejection:

def ship(env: Envelope) -> None:
    reveal_type(env.payload)   # mypy & pyright both: "Animal"

It is worth being precise about what the annotation creates. Writing payload: Animal in the class body declares a settable instance-variable member, and that settability is the entire cause of the invariance. Contrast three nearby forms that behave differently: payload: ClassVar[Animal] is a class-level member, a @property getter is read-only and covariant (shown below), and a method is matched by signature rather than by attribute type. Wrapping the module in from __future__ import annotations (PEP 563) turns the annotations into strings but does not change any of this — variance is computed from the resolved types, not their textual form. Protocol itself has lived in typing since Python 3.8; on 3.7 you import it from typing_extensions, which also back-ports the most recent conformance fixes and is the version to prefer when you want the newest behavior.

This is not a Protocol-specific quirk — it is the same rule the standard library’s own generic containers obey. typing.Mapping[str, Animal] is covariant in its value type, so a dict[str, Dog] is assignable to it, whereas typing.MutableMapping[str, Animal] (and the concrete dict[str, Animal]) is invariant precisely because __setitem__ makes the value writable. A Protocol with a bare attribute is the hand-rolled equivalent of the mutable case, and a Protocol with a get-only property is the equivalent of the read-only Mapping. Recognising the parallel means you can predict a checker’s verdict without running it: if the interface exposes any write path to the member — an assignable attribute, a __setitem__, a setter — the member is invariant and a subtype will be rejected. Both mypy and pyright agree on this base rule; the disagreements only appear once generics or nested parameterization stack a second variance decision on top of the first, which is exactly the situation the next section’s error messages describe.

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.

Same rejection, two vocabularies The same variance conflict is attached, coded, and silenced differently by mypy and pyright across four rows of a comparison grid. One conflict, two error vocabularies mypy 1.13 pyright 1.1.389 attaches to the call argument the assignment error code [arg-type] + [misc] reportArgumentType config knob strict = true typeCheckingMode silence one line # type: ignore[misc] # pyright: ignore[...]
The rejection is the same; the message target, error code, enabling config, and per-line suppression directive all differ between the two checkers.

Two practical details follow from that grid. First, mypy stacks its diagnosis as a chain of note: lines beneath the top-level [arg-type] error — the actual variance reason lives in the last note, so a truncated CI log that shows only the first line hides the cause; run mypy with --show-error-codes (the default since 0.990) and read to the bottom of the note block. Second, pyright’s rule names are individually configurable in pyrightconfig.json or the [tool.pyright] table: reportGeneralTypeIssues can be set to "error", "warning", or "none", so a project that has dialled it down to "warning" will show the disagreement as a yellow squiggle rather than a hard failure, which is a common reason a shared repo “passes pyright” on one developer’s machine and not another’s. Silencing a single occurrence also differs: mypy honours # type: ignore[misc] with the specific code, while pyright wants # pyright: ignore[reportGeneralTypeIssues] — a bare # type: ignore suppresses both but loses the specificity that keeps unrelated errors visible.

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
From invariant attribute to covariant property Rewriting payload as a get-only property changes it from a rejected invariant slot to an accepted covariant member. before — mutable slot payload: Animal read + write → invariant Dog rejected [misc] / reportArgumentType after — get-only property @property def payload -> Animal read only → covariant Dog accepted both checkers agree
Removing the write capability flips the member from invariant to covariant, and the subtype that was rejected is now accepted by both tools.

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. ReadOnly for TypedDict items is standardized by PEP 705 and shipped in Python 3.13’s typing (import it from typing_extensions on 3.8–3.12); a ReadOnly[Animal] item is covariant for exactly the same reason a get-only property is.

The generic case is where the disagreement most often surfaces, and the fix is to make the type parameter’s variance explicit rather than leaving it to be inferred. A container protocol that only ever yields its element type should be declared covariant:

from typing import Protocol, TypeVar

T_co = TypeVar("T_co", covariant=True)   # naming convention: _co suffix

class Source(Protocol[T_co]):
    def get(self) -> T_co: ...           # T_co appears only in output position

class DogSource:
    def get(self) -> Dog: ...

def read(s: Source[Animal]) -> None: ...
read(DogSource())                        # ✓ Source[Dog] <: Source[Animal]

If T appeared in an input position (a method parameter) the checker would reject the covariant declaration with mypy’s [misc] “Cannot use a covariant type variable as a parameter” or pyright’s reportGeneralTypeIssues, because that position is contravariant. On Python 3.12+ the PEP 695 syntax class Source[T](Protocol) lets the checker infer variance from usage instead of forcing the _co/_contra suffix, and pyright and recent mypy both compute the same result — which removes a whole category of “why does only pyright complain” reports by construction. Mixing an explicit TypeVar(covariant=True) with PEP 695 inference in the same codebase is the remaining trap; pick one style per module.

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

When the two checkers disagree on a protocol, a short decision procedure almost always resolves it faster than reading the diff of error messages: ask whether the member is written anywhere, then whether it is generic, then whether you actually need write access. The tree below encodes that triage; the itemised traps follow.

Resolving a protocol variance disagreement Branch on whether the member is written and whether callers need to write to it, arriving at a property, a Final attribute, or a covariant type variable. tools disagree member assigned anywhere? no yes already read-only generic? make T_co covariant do callers need to write? invariant while mutable no yes @property or Final covariant → both accept keep exact type invariance is correct
Triage a disagreement by mutability and write-need: read-only members become properties, genuinely mutable ones keep their exact type because invariance is the correct answer.
  • 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.
  • Declaring a covariant TypeVar that appears in an input position. T_co used as a method parameter is unsound and both tools reject it — mypy with [misc] “Cannot use a covariant type variable as a parameter”, pyright with reportGeneralTypeIssues. Covariant variables belong only in return types; if you need it as an argument, the parameter position is contravariant and wants a separate T_contra.
  • Mixing explicit-variance TypeVars with PEP 695 inference in one class. On 3.12+ the class Box[T] form infers variance from usage; layering an old TypeVar("T", covariant=True) onto the same hierarchy produces contradictory variance and a reportGeneralTypeIssues from pyright that mypy may report differently. Commit to one style per module.
  • Forgetting that assignment compatibility is checked at the call site, not the class definition. A candidate class can define freely; the rejection only fires where you pass it where the protocol is expected, so reveal_type at the definition looks fine and the error surfaces one call away — read the traceback’s argument position, not the class body.

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