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.
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.
# 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.
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
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.
# 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.
# 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.
- Marking a mutable container covariant: A covariant
TypeVaron a class with a setter is unsound; mypy rejects the declaration with[misc]. Use invariance for anything writable — a plainTypeVar("T")— and only expose covariance through a read-only view method. - Expecting
listto behave likeSequence:list[Dog]is not alist[Animal]. Annotate read-only parameters asSequence/Iterableto gain covariance safely; callers still pass their concretelistunchanged, sincelistis aSequence. - Manually setting
covariant=Trueunder PEP 695: The newclass C[T]:syntax infers variance; adding the old keyword is an error. Let the checker decide, or useinfer_variance=Trueon a classicTypeVarif you want inference without the new syntax. - Confusing variance with subtyping of the parameter:
Dog <: Animalsays nothing on its own aboutContainer[Dog]vsContainer[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.