Why list[Dog] Is Not list[Animal] but Sequence[Dog] Is

TL;DR

Mutable collections like list and dict are invariant: list[Dog] is not assignable to list[Animal], because the callee could append a Cat and corrupt your list. Read-only abstract types — Sequence, Mapping, Iterable, and tuple — are covariant, so Sequence[Dog] is a Sequence[Animal]. Fix [arg-type] / reportArgumentType errors by annotating parameters you only read with those abstract types instead of the concrete mutable ones.

This is the single most common generics error a static analyzer reports: you pass a list[Dog] to a function annotated list[Animal] and mypy rejects it. The reason is variance — specifically the producer/consumer rule applied to real collection types. This guide explains why each standard collection is invariant or covariant and shows the one-line fix that makes the error disappear correctly, without suppressing a genuine soundness problem. It assumes Python 3.11+ and mypy 1.x or pyright.

Context: the producer/consumer rule

A collection’s element type can be covariant only if the collection never lets you write that element. A list is both readable and writable, so its element type sits in input and output positions and must be invariant. A Sequence exposes only reads (__getitem__, iteration), so its element type is output-only and can be covariant. The same logic splits dict (invariant) from Mapping (covariant in its value type). This is PEP 483’s variance rule applied to the standard library.

Step 1 — Reproduce the invariance error

Passing a list[Dog] where list[Animal] is wanted is rejected, even though Dog is a subtype of Animal.

# Python 3.11+, mypy 1.x / pyright
class Animal: ...
class Dog(Animal): ...
class Cat(Animal): ...

def add_stray(animals: list[Animal]) -> None:
    animals.append(Cat())            # legal for a list[Animal]

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

The error is correct: add_stray would insert a Cat into something the caller believes is a list[Dog]. Invariance prevents that.

Step 2 — Switch a read-only parameter to Sequence

If the function only reads the collection, annotate it as Sequence[Animal]. Sequence is covariant, so Sequence[Dog] is accepted as a Sequence[Animal].

# Python 3.11+, mypy 1.x — read-only → covariant
from collections.abc import Sequence

def describe(animals: Sequence[Animal]) -> None:
    for a in animals:
        print(type(a).__name__)      # only reads — never appends

dogs: list[Dog] = [Dog()]
describe(dogs)                       # accepted: Sequence[Dog] <: Sequence[Animal]

A list is a Sequence, so callers pass their concrete list unchanged — you only relaxed the parameter type.

Step 3 — Apply the same fix to mappings

dict is invariant in both its key and value type. If a function only looks values up, accept a Mapping[str, Animal], which is covariant in the value type.

# Python 3.11+, mypy 1.x
from collections.abc import Mapping

def first_name(registry: Mapping[str, Animal]) -> str:
    return next(iter(registry))      # read-only

shelter: dict[str, Dog] = {"rex": Dog()}
first_name(shelter)                  # accepted via Mapping covariance

Step 4 — Know which collections are already covariant

tuple and the read-only abstract base classes need no fix — they are covariant out of the box because they expose no mutators.

# Python 3.11+, mypy 1.x / pyright
def total(values: tuple[Animal, ...]) -> int:
    return len(values)

dogs: tuple[Dog, ...] = (Dog(), Dog())
total(dogs)                          # accepted: tuple is covariant (immutable)
Runtime vs static analysis Variance is a static-only notion. At runtime list[Dog] and list[Animal] are the same list class — Python performs no element-type checking on append or assignment. The analyzer rejects the assignment to stop a bug it can prove is possible (appending a Cat), but nothing enforces it while the program runs. Switching to Sequence changes only what the checker accepts, not runtime behaviour.

Edge cases

  • A function that both reads and writes: Keep the invariant list[Animal]. The error is real — you cannot soundly accept list[Dog] if you ever append a non-Dog.
  • set and frozenset: set is invariant (mutable); frozenset is covariant. The mutable/ immutable split is the deciding factor again.
  • Covariant return, invariant field: A method may return Sequence[Animal] covariantly while a stored list[Animal] attribute stays invariant — annotate the boundary, not the storage.

Common mistakes

  • Annotating read-only parameters as list: This forces invariance and rejects valid subtype collections with [arg-type] / reportArgumentType. Use Sequence/Iterable/Mapping for read-only parameters.
  • Suppressing the error with # type: ignore: On a function that mutates, the error is genuine; ignoring it lets a Cat into a list[Dog]. Fix the type, do not silence [arg-type].
  • Expecting dict[str, Dog] to satisfy dict[str, Animal]: dict values are invariant. Use Mapping[str, Animal] if the function only reads, or you will hit [arg-type].

FAQ

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

Does using Sequence slow anything down? No. Sequence is an abstract base class for typing only; you still pass the same concrete list at runtime, with identical performance.

Back to Variance and Type Parameters