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.
# 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: ...
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
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 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]"
Analyzer behaviour
mypy
mypy enforces variance strictly during assignment and argument checks. With explicit-variance
TypeVars it also validates the declaration: 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”. Run
under mypy strict mode to
surface these at CI time.
# 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. pyright is generally faster to flag variance violations in
deeply nested generics; the divergences are catalogued in
pyright vs mypy.
Strictness tuning
Variance errors cannot be selectively disabled without losing soundness, but you can scope checks during incremental adoption with per-module overrides:
# pyproject.toml — relax a legacy module while you fix variance violations
[[tool.mypy.overrides]]
module = "legacy.collections_shim"
disable_error_code = ["arg-type"]
Prefer fixing the root cause: switch a mutable parameter type to its read-only protocol
(Sequence, Mapping, Iterable) so the parameter becomes legitimately covariant.
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
Common pitfalls
- 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. - Expecting
listto behave likeSequence:list[Dog]is not alist[Animal]. Annotate read-only parameters asSequence/Iterableto gain covariance safely. - Manually setting
covariant=Trueunder PEP 695: The new syntax infers variance; adding the old keyword is an error. Let the checker decide. - Confusing variance with subtyping of the parameter:
Dog <: Animalsays nothing on its own aboutContainer[Dog]vsContainer[Animal]— only the container’s variance does.
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.