Variance in Python Generics: Covariance, Contravariance & Invariance

Variance is the rule that decides whether list[Dog] is an acceptable value where list[Animal] is expected. It is the single concept behind most confusing generic errors a static analyzer reports, and it is what separates a sound type from one that quietly lets a bug through. This guide covers the three variances — covariant, contravariant, and invariant — how to declare them on a TypeVar, and exactly how mypy and pyright enforce each one. For the underlying generic mechanics, start with the parent overview of Advanced Typing Patterns & Generics.

The three variances Covariant types preserve the subtype direction, contravariant types reverse it, and invariant types accept neither. Given Dog is a subtype of Animal… Covariant Sequence[Dog] ↓ is a Sequence[Animal] read-only producers Invariant list[Dog] ✗ neither way list[Animal] mutable containers Contravariant Callback[Animal] ↓ is a Callback[Dog] consumers / sinks
Covariance preserves the subtype direction, contravariance reverses it, invariance forbids both.

Syntax spec: declaring variance on a TypeVar

Before PEP 695, variance was declared explicitly with the covariant and contravariant keyword arguments to TypeVar. A plain TypeVar is invariant by default, and the two flags are mutually exclusive: passing both raises ValueError: Bivariant type variables are not supported. the moment the module is imported, so that particular mistake surfaces at runtime rather than only in the checker. Variance is a property of the type parameter, not of any one class — the same T_co reused across several generic classes carries its covariance into each of them.

Choosing a variance from usage position If T appears only in output positions it may be covariant, only in input positions contravariant, and in both it must stay invariant. Where does T appear? across the whole class Output only (return) Covariant T_co, covariant=True Both / mutable field Invariant T (the default) Input only (param) Contravariant T_contra, contravariant=True
Usage position dictates the only sound variance: outputs allow covariance, inputs allow contravariance, both force invariance.
# Python 3.8+, legacy explicit-variance syntax
from typing import TypeVar, Generic

T_co = TypeVar("T_co", covariant=True)      # produces values
T_contra = TypeVar("T_contra", contravariant=True)  # consumes values
T = TypeVar("T")                            # invariant (default)

class Producer(Generic[T_co]):
    def get(self) -> T_co: ...

class Consumer(Generic[T_contra]):
    def put(self, item: T_contra) -> None: ...

PEP 484 established a naming convention both mypy and pyright expect you to keep: suffix covariant variables with _co and contravariant ones with _contra. The runtime does not enforce the suffix, but it keeps the variance readable at every use site and mirrors the standard library, whose stubs write Sequence as Sequence[+_T_co] (a leading + marks covariance, - marks contravariance, and no sign marks invariance in stub notation).

Python 3.12’s PEP 695 type parameter syntax removes the manual annotation entirely: the checker infers variance from how each parameter is used. This is the recommended modern form.

# Python 3.12+, PEP 695 — variance is inferred, not declared
class Producer[T]:
    def get(self) -> T: ...        # T used only in output → inferred covariant

class Consumer[T]:
    def put(self, item: T) -> None: ...  # T used only in input → inferred contravariant

You do not have to switch syntaxes to get inference. Python 3.12 also gave the classic TypeVar an infer_variance=True flag (and typing_extensions.TypeVar backports it to 3.8+), which opts a named TypeVar into the same usage-based inference while keeping the explicit-declaration style. Combining infer_variance=True with an explicit covariant=/contravariant= is contradictory and raises ValueError at import.

# Python 3.12+, or via typing_extensions on 3.8–3.11
from typing import TypeVar          # 3.12+; else: from typing_extensions import TypeVar

T = TypeVar("T", infer_variance=True)  # plain name, variance decided by usage

Variance is orthogonal to a TypeVar’s bound and constraints: TypeVar("T_co", bound="Sized", covariant=True) is perfectly valid. The bound constrains what T_co may be substituted with; the variance constrains how Container[Sub] relates to Container[Super]. The two never conflict.

The producer/consumer rule

A type parameter that only ever appears in output positions (return types) can be covariant. A parameter that only appears in input positions (parameter types) can be contravariant. A parameter that appears in both — like the element type of a mutable list — must be invariant, because it is read and written. This is the type-system expression of the Liskov Substitution Principle: a covariant type may be substituted where its supertype is expected only because every operation the supertype promised (reading Animals out) still holds when you actually have the subtype (Dogs coming out are Animals); the promise breaks the instant you can write back in.

Method role maps to variance Return-only methods make a parameter covariant, parameter-only methods make it contravariant, and a public attribute of type T forces invariance. how T is used resulting variance def get(self) -> T (output) Covariant def put(self, x: T) (input) Contravariant self.x: T (read + write) Invariant
Each method role lands the parameter in an input or output position, and the union of positions fixes the variance.

This is why list[T] is invariant but Sequence[T] (read-only) is covariant. Passing a list[Dog] where list[Animal] is expected would let the callee append a Cat, corrupting the original list — so the analyzer rejects it.

# Python 3.11+, mypy 1.x
def add_animal(animals: list[Animal]) -> None:
    animals.append(Cat())          # legal for list[Animal]

dogs: list[Dog] = [Dog()]
add_animal(dogs)                   # mypy error: [arg-type]
# pyright: reportArgumentType — "list[Dog]" is not assignable to "list[Animal]"

When a checker infers variance under PEP 695, it scans every place the parameter appears in the class body: return annotations (output), parameter annotations (input), and attribute types. A public attribute x: T counts as both positions at once, because it can be read and reassigned, so it pins the whole parameter to invariant. Two things are deliberately excluded from the scan — the parameters of __init__/__new__, and name-mangled private attributes (self.__x). A constructor consumes a value only to seed the instance; it is not an ongoing input channel, so it does not force contravariance.

# Python 3.12+ — inference ignores the constructor and private state
class Box[T]:
    def __init__(self, value: T) -> None:  # constructor input — NOT counted
        self._value = value                # single-underscore is still public to inference
    def get(self) -> T:                    # output only
        return self._value
# T is inferred covariant: Box[Dog] is usable as Box[Animal]

Expose the same value through a mutable public attribute instead, and the parameter is forced back to invariance, because assignment to self.value is an input position:

# Python 3.12+
class Cell[T]:
    def __init__(self, value: T) -> None:
        self.value = value      # public attribute of type T: read AND written
# T is inferred invariant: Cell[Dog] is NOT usable as Cell[Animal]

The mnemonic from other languages — “Producer Extends, Consumer Super” (PECS) — is the same rule: a type you only produce (return) is covariant, a type you only consume (accept) is contravariant. The overview at generics and TypeVar walks through declaring these parameters on your own generic classes.

Analyzer behaviour

Both checkers enforce variance in two distinct moments: when you declare a generic (does the variance you asked for match how you actually use the parameter?) and when you use it (is this Container[Dog] assignable to that Container[Animal]?). mypy and pyright agree on the underlying rules and on the outcome of almost every program, but they package the diagnostics differently and disagree on a handful of inference edge cases.

mypy versus pyright variance diagnostics For a bad variance declaration mypy emits misc while pyright emits reportGeneralTypeIssues, and for a bad assignment mypy emits arg-type while pyright emits reportArgumentType. mypy bad declaration → [misc] "covariant used as a parameter" bad assignment → [arg-type] pyright bad declaration → reportGeneralTypeIssues inferred-variance mismatch bad assignment → reportArgumentType
The two checkers raise the same two categories of variance error under different diagnostic names.

mypy

mypy enforces variance strictly during assignment and argument checks. With explicit-variance TypeVars it also validates the declaration at class-definition time: if you mark a parameter covariant but use it in an input position, mypy raises [misc] — “Cannot use a covariant type variable as a parameter”. The symmetric case is caught too: a contravariant variable used in a return position reports “Cannot use a contravariant type variable as return type”. These checks are always on — they are not gated behind mypy strict mode; strict mode adds unrelated flags but never relaxes variance.

# Python 3.8+, mypy 1.x
from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)

class Box(Generic[T_co]):
    def set(self, value: T_co) -> None: ...  # mypy error: [misc]
    # "Cannot use a covariant type variable as a parameter"

pyright

pyright performs the same soundness checks and, for PEP 695 classes, reports an inferred-variance mismatch as reportGeneralTypeIssues. If you write an explicit-variance TypeVar whose declared variance contradicts its usage, pyright surfaces it too; and because pyright infers variance for the new-style syntax, adding a redundant covariant=True where inference already decides is itself flagged. pyright is generally faster to flag variance violations in deeply nested generics; the divergences are catalogued in pyright vs mypy. Both agree on the practical cases that bite most often, including Callable contravariance in its argument types — Callable[[Animal], None] is assignable to Callable[[Dog], None], never the reverse.

Strictness tuning

Variance errors cannot be selectively disabled without losing soundness, so the right hierarchy of responses runs from “silence everything” (worst) to “fix the type” (best). You can scope checks during incremental adoption with per-module overrides, but understand exactly how broad each knob is before reaching for it.

From suppression to root fix Broadly disabling arg-type is the least sound response, a single type ignore is narrower, and switching to a read-only type is the sound fix. soundness disable_error_code = ["arg-type"] (whole module — very broad) # type: ignore[arg-type] (one call site — narrower, still a smell) annotate parameter as Sequence / Mapping (sound fix) best: the error disappears because it was never real
Prefer the sound fix at the top; each rung below trades away more safety for less effort.
# pyproject.toml — relax a legacy module while you fix variance violations
[[tool.mypy.overrides]]
module = "legacy.collections_shim"
disable_error_code = ["arg-type"]

Note that disable_error_code = ["arg-type"] silences every argument-type error in that module, not only the variance-driven ones, so it can hide unrelated bugs while you migrate. A single # type: ignore[arg-type] on the offending call is narrower and self-documents where the suppression lives. pyright offers the same spectrum: set reportArgumentType to "warning" or "none" in [tool.pyright], or scope it to one file with a # pyright: reportArgumentType=false comment at the top.

# pyproject.toml — pyright equivalent, scoped to one severity
[tool.pyright]
reportArgumentType = "warning"

Prefer fixing the root cause: switch a mutable parameter type to its read-only protocol (Sequence, Mapping, Iterable) so the parameter becomes legitimately covariant. That is not a suppression — the error genuinely no longer exists, because the interface no longer permits the write that made the substitution unsound.

Debugging false positives

A frequent “false positive” is really a genuine soundness error: passing dict[str, Dog] where dict[str, Animal] is expected. dict values are invariant. If the function never mutates the dict, accept Mapping[str, Animal] instead — a covariant, read-only type — and the error disappears correctly.

Retyping to a read-only Mapping clears the error A dict of str to Dog fails against a dict of str to Animal parameter, but succeeds once the parameter becomes a Mapping of str to Animal. Before arg: dict[str, Dog] param: dict[str, Animal] ✗ [arg-type] — dict is invariant read-only After arg: dict[str, Dog] param: Mapping[str, Animal] ✓ Mapping is covariant in value
The fix is a parameter retype, not a suppression: Mapping exposes no writes, so the value type is covariant.
# Python 3.11+, mypy 1.x — fix by accepting a read-only Mapping
from collections.abc import Mapping

def describe(registry: Mapping[str, Animal]) -> None: ...  # covariant in the value type
describe({"rex": Dog()})           # now accepted

The same pattern recurs with local assignments, not just arguments: x: list[float] = [1, 2, 3] is fine (the literal is inferred as the target type), but ints: list[int] = [1]; floats: list[float] = ints is rejected as [assignment] for exactly the invariance reason. If you only iterate floats, annotate it Sequence[float] and the assignment type-checks. set behaves like list here — it is invariant because add is an input position — whereas frozenset is covariant. Another genuine-but-surprising rejection is returning a narrower generic: a function annotated -> list[Animal] cannot return some_list_of_dogs; widen the return to Sequence[Animal] or build a new list[Animal]. When in doubt, ask whether the callee could write through the parameter — if it cannot, a read-only ABC is both sound and accepted. The concrete unsound-append walkthrough lives at why list is invariant.

Common pitfalls

Most variance mistakes reduce to declaring a variance the usage cannot support, or expecting a mutable container to behave like its read-only protocol. Each has a distinct error and a mechanical fix.

Anti-pattern to error to fix Writing a covariant setter or a list-typed read-only parameter produces a checker error, and the fix is to use invariance or a read-only ABC. Anti-pattern covariant setter / list read-only param Checker error [misc] / [arg-type] Fix invariant TypeVar / Sequence / Iterable
Every common variance mistake follows the same path: an unsound declaration, a specific error code, and a mechanical retype.
  • Marking a mutable container covariant: A covariant TypeVar on a class with a setter is unsound; mypy rejects the declaration with [misc]. Use invariance for anything writable — a plain TypeVar("T") — and only expose covariance through a read-only view method.
  • Expecting list to behave like Sequence: list[Dog] is not a list[Animal]. Annotate read-only parameters as Sequence/Iterable to gain covariance safely; callers still pass their concrete list unchanged, since list is a Sequence.
  • Manually setting covariant=True under PEP 695: The new class C[T]: syntax infers variance; adding the old keyword is an error. Let the checker decide, or use infer_variance=True on a classic TypeVar if you want inference without the new syntax.
  • Confusing variance with subtyping of the parameter: Dog <: Animal says nothing on its own about Container[Dog] vs Container[Animal] — only the container’s variance does. A container can be invariant even when its element types are in a clean subtype relationship.

FAQ

Why is list invariant but tuple covariant? tuple is immutable, so its element type appears only in output positions and can be covariant. list is mutable — the element type is both read and written — so it must be invariant.

Do I still need covariant=True in Python 3.12? No. Under PEP 695 the type checker infers variance from usage. The explicit keywords remain only for legacy TypeVar declarations on 3.8–3.11.

How do I make a function accept “a list of any animal subtype”? Type the parameter as Sequence[Animal] (covariant, read-only) rather than list[Animal], or make the function itself generic with a bounded TypeVar.

Back to Advanced Typing Patterns & Generics