skip to content

Covariance and Contravariance

Why list[Dog] is not accepted where list[Animal] is expected, even though Dog is an Animal. This is the generics question that separates people who copied annotations from people who understand them.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why does a type checker reject a list[Dog] argument where list[Animal] is expected?

level: middleimportance: must knowfreq 60%

answer

  1. Which direction subtyping travels through a container
  2. Mutation is the whole reason
  3. The callee could store the wrong element
  4. Read-only views are safe, writable ones are not
  5. list invariant, Sequence covariant

basics

~20 s

Because list is invariant: list[Dog] and list[Animal] are unrelated types even though Dog subclasses Animal. A function holding a list[Animal] may append a Cat, which would corrupt the caller's list of dogs, so the checker refuses the call.

solid answer

~40 s

Variance is the rule that decides whether `Dog` being a subclass of `Animal` makes `C[Dog]` a subtype of `C[Animal]`. For `list` the answer is no: `list` is **invariant**, so `list[Dog]` and `list[Animal]` are simply different types. The reason is mutation. If the call were allowed, the callee — which believes it holds a `list[Animal]` — could legally `append` a `Cat`, and the caller's `list[Dog]` would now contain a non-dog that every later `dogs[i].bark()` trusts. Read-only views do not have that problem, so `collections.abc.Sequence` is declared covariant and `Sequence[Animal]` happily accepts a `list[Dog]`. The fix is almost always to widen the parameter annotation to `Sequence[Animal]` (or `Iterable[Animal]`) rather than to silence the checker.

code

python · 10 lines
python
class Animal: ...
class Dog(Animal): ...
class Cat(Animal): ...

def adopt(shelter: list[Animal]) -> None:
    shelter.append(Cat())

dogs: list[Dog] = [Dog()]
adopt(dogs)  # a type checker rejects this call
print(type(dogs[-1]).__name__)  # runtime is happy: Cat

go deeper

for a junior

Recall the headline: a list of dogs is not a list of animals to a type checker. Be able to state that the container's type parameter has to match exactly, and that widening the parameter is the usual fix.

for a middle

Explain the mechanism, not just the rule. Walk through the append-a-Cat argument out loud, name invariance, and show that swapping the parameter to collections.abc.Sequence fixes the call because Sequence is read-only and covariant.

for a senior

Show judgement about the fix. Argue why widening a parameter beats casting or Any, decide when a function that mutates its argument should be generic instead, and note that nothing is enforced at runtime so the checker is the only line of defence.

for a principal

Own the API-shape consequence for a codebase: parameter annotations that default to concrete mutable containers propagate invariance friction to every caller. Be ready to set a house rule and to say what it costs in review time and in casts avoided.

### Variance, defined once Variance answers one question: given that `Dog` is a subtype of `Animal`, what is the relationship between `C[Dog]` and `C[Animal]` for some generic `C`? There are three possible answers. - **Covariant** — subtyping flows in the same direction: `C[Dog]` is usable as a `C[Animal]`. This is sound for things that only *produce* values. - **Contravariant** — subtyping flows the other way: `C[Animal]` is usable as a `C[Dog]`. This is sound for things that only *consume* values. - **Invariant** — neither direction holds; `C[Dog]` and `C[Animal]` are unrelated types. In Python's type system `list`, `set`, `dict` (in both its key and its value parameter) and every mutable collection abstract base class are invariant. `collections.abc.Sequence`, `Iterable`, `Iterator`, `Collection`, `frozenset` and the value parameter of `collections.abc.Mapping` are covariant, as is `tuple`, which is immutable. ### Why mutation forces invariance The classic demonstration is three lines long. Suppose the checker allowed `list[Dog]` where `list[Animal]` was declared: ```python def adopt(shelter: list[Animal]) -> None: shelter.append(Cat()) # perfectly legal for a list[Animal] dogs: list[Dog] = [Dog()] adopt(dogs) # if this were allowed... dogs[0].fetch() # ...this line can now hit a Cat ``` Every statement inside `adopt` is legitimate for the type it was given. The unsoundness is created entirely by the assignment at the call site, so that is where the checker has to say no. The mirror-image argument rules out contravariance. If `list[Animal]` were accepted where `list[Dog]` is expected, a function that reads `dogs[0]` and calls a dog-only method would receive a container that may legally hold cats. A `list` supports both reads and writes, so both covariance and contravariance are unsound for it, and the only sound answer left is invariance. ### The same rule stated as producer/consumer A useful shorthand: a type parameter can be covariant if it only appears in *output* positions (return values, items you read out) and contravariant if it only appears in *input* positions (arguments, items you write in). `list` has `__getitem__` returning `T` **and** `append` taking `T`, so `T` appears in both positions and neither relaxation is available. `Sequence` has the read half without the write half, which is exactly why it is covariant. ### What to do about it The overwhelmingly common fix is to widen the *parameter*, not to widen or cast the *argument*: - If the function only iterates or indexes, annotate the parameter `Sequence[Animal]`. A `list[Dog]`, a `tuple[Dog, ...]` and a custom read-only view all satisfy it. - If it only loops once, `Iterable[Animal]` is looser still. - If it genuinely mutates the container, invariance is telling you the truth: mutating a caller's `list[Dog]` through an `Animal`-typed name is a real bug waiting to happen. Either make the function generic over the element type, or build and return a new list instead of mutating in place. - Constructing the caller's list as `list[Animal] = [Dog()]` also works when the list really is heterogeneous. Note that this changes what the caller may later put in it, which is the point. What you should *not* do is reach for `typing.Any` or a cast. Both delete the check without changing the hazard, and the next reader has no idea whether the suppression was reasoned or copied. ### It is a static rule only Nothing about invariance exists at runtime. Annotations are not enforced by the interpreter, and since Python 3.14 (PEP 649/749) they are not even evaluated by default until something asks for them. `adopt(dogs)` runs perfectly happily and quietly leaves a cat among the dogs; the failure surfaces later, somewhere else, as an `AttributeError` in code that looks correct. That is the practical argument for running a checker at all: it takes seconds, while a 27-minute test suite may never execute the path that mixes the two. ### Version notes Subscripting builtins directly — `list[Dog]` rather than `typing.List[Dog]` — has been available since Python 3.9 (PEP 585), and the `typing` aliases are deprecated. Python 3.12's type-parameter syntax changed how variance is *declared* for your own generic classes, but it changed nothing about the builtin containers: `list` was invariant before and is invariant on 3.14. ### Saying it in an interview The compact form that lands well is three beats. First name the property: `list` is invariant, so `list[Dog]` and `list[Animal]` are unrelated types. Second, justify it in one sentence with the mutation argument — the callee is entitled to store any `Animal` in what it believes is a `list[Animal]`, and that would leave a non-dog in the caller's container. Third, give the fix and its condition: widen the parameter to `collections.abc.Sequence[Animal]` if the body only reads, and keep the invariant type honestly if it writes. Candidates who stop after the first beat sound like they memorised a rule; the second beat is what shows they can derive it, and the third is what shows they have used it.

  • Is tuple[Dog, ...] accepted where tuple[Animal, ...] is expected?
    Yes. `tuple` is immutable, so its element parameter appears only in output positions and it is declared covariant. There is no `append` through which a caller's tuple could be polluted, so `tuple[Dog, ...]` is a subtype of `tuple[Animal, ...]`, and the same holds for `frozenset[Dog]` against `frozenset[Animal]`.
  • What about passing a dict[str, Dog] where dict[str, Animal] is expected?
    Rejected, for the same reason: `dict` is invariant in both its key and its value parameter, since you can both read and write through either. If the function only reads, annotate the parameter `collections.abc.Mapping[str, Animal]` — `Mapping` is covariant in the value type (and still invariant in the key type), so a `dict[str, Dog]` satisfies it.
  • Does the checker's rule have any effect at runtime?
    None. Annotations do not constrain what the interpreter allows, and since 3.14 they are lazily evaluated by default. The offending call runs and silently leaves an object of the wrong class in the container; the symptom appears later as an attribute or type error far from the cause.

A box labelled "dogs only" cannot be handed to someone holding a form that says "animals" — they are entitled to drop a cat in it, and the label on your box would then be a lie.

saying these in an interview costs you the question

  • Argues Dog is an Animal so list[Dog] must be a list[Animal]
  • Calls the invariance error a type-checker bug or false positive
  • Silences it with a cast or Any instead of widening the parameter
  • Thinks the interpreter enforces the rule at runtime
  • Believes covariance is always the safe default for containers
  • Cannot say why tuple behaves differently from list

context

open as a page

Why accept Sequence[Animal] but return list[Animal] from a public function?

level: middleimportance: should knowfreq 48%

basics

~20 s

Parameters should be the widest read-only type the body needs, so callers can pass a list, a tuple or a list of a subclass. Returns should be the concrete type you built, so callers keep its full API.

open as a page

Why can a Callable[[Animal], None] be passed where Callable[[Dog], None] is expected?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Because a callable's parameter positions are contravariant. The slot promises the callback will only ever be handed a Dog, and a function that handles any Animal handles that. The reverse direction is the unsafe one.

open as a page

What does TypeVar("T_co", covariant=True) change about a Generic class using it?

level: seniorimportance: nice to knowfreq 20%

basics

~20 s

It makes the class covariant in that parameter, so Box[Dog] is usable where Box[Animal] is expected. In exchange the checker requires the parameter to appear only in output positions; using it as a method parameter is an error.

open as a page