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.

Position of T decides variance A type parameter used only in output positions can be covariant, only in input positions contravariant, and in both must be invariant. Where does T appear across the type's methods? Position of T Allowed variance T only in return types Covariant — Sequence[T] T only in parameter types Contravariant — Callable[[T], R] T in both (read and write) Invariant — list[T]
The variance a container may claim is fixed by where its element type appears in its own methods.

The rule is mechanical and worth stating precisely. For a generic type C[T], T may be covariant if it appears only in output (return) positions of C’s methods, contravariant if it appears only in input (parameter) positions, and must be invariant if it appears in both. list.__getitem__ returns T while list.append and list.__setitem__ take T, so the element type is used both ways — invariant. Every method on collections.abc.Sequence (__getitem__, __iter__, __contains__, __reversed__, index, count) produces or inspects T without ever storing one, so its element type is output-only.

You can see this directly in typeshed, the stub library both mypy and pyright consume. Sequence is declared with a covariant type variable, conventionally suffixed _co, while MutableSequence reintroduces the writing methods and falls back to an invariant variable.

# Sketch of the typeshed declarations (simplified)
from typing import TypeVar

_T_co = TypeVar("_T_co", covariant=True)   # produces values
_T = TypeVar("_T")                         # invariant (no covariant=/contravariant=)

class Sequence(Collection[_T_co]):
    def __getitem__(self, index: int) -> _T_co: ...      # output only → covariant

class MutableSequence(Sequence[_T]):
    def insert(self, index: int, value: _T) -> None: ... # input position → invariant
    def append(self, value: _T) -> None: ...

The mnemonic is producer/consumer: a type that only produces T (hands it to you) can be covariant; a type that only consumes T (takes it from you) can be contravariant; a type that does both must be invariant. The contravariant case is easiest to see with function types, covered in variance and type parameters: Callable[[Animal], None] is a subtype of Callable[[Dog], None], not the other way round.

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.

The corrupting append the checker prevents A caller's list of Dog is passed as list of Animal, the callee appends a Cat, and the list the caller still reads as Dog now holds a Cat, so the analyzer rejects the call. dogs: list[Dog] [Dog()] passed as add_stray(list[Animal]) animals.append(Cat()) dogs now holds a Cat checker blocks step 1: [arg-type] / reportArgumentType
Rejecting the call is the only place the analyzer can stop the corrupting append.

Both checkers agree here, and there is no flag that relaxes it: list invariance is unconditional, not a strictness setting. The error surfaces at the call site — the argument, not the function body — and mypy tags it [arg-type] while pyright emits reportArgumentType. The same invariance shows up on a plain assignment, where mypy reports [assignment] instead:

# Same invariance, surfaced at an assignment rather than a call
dogs: list[Dog] = [Dog()]
animals: list[Animal] = dogs     # mypy error: [assignment]
# error: Incompatible types in assignment
#        (expression has type "list[Dog]", variable has type "list[Animal]")

It helps to read the message literally: “assignable” (pyright) and “compatible type” (mypy) both mean is a subtype of. list[Dog] is not a subtype of list[Animal] even though Dog <: Animal, because a generic’s subtyping is governed by its variance, not by its arguments’ subtyping. You can confirm the checker’s view with reveal_type(), which prints during type checking and is a no-op at runtime; it will report the parameter as list[Animal] inside add_stray, confirming that append is fully available there. That capability is exactly what makes accepting a list[Dog] unsound.

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.

Before and after: widen the parameter to Sequence With a list of Animal parameter the caller's list of Dog is rejected, but changing the parameter to Sequence of Animal accepts the identical caller unchanged. Before def f(animals: list[Animal]) f(dogs) # dogs: list[Dog] ✗ rejected [arg-type] invariant parameter After def f(animals: Sequence[Animal]) f(dogs) # unchanged caller ✓ accepted covariant parameter
Only the annotation changes; the caller keeps passing the same concrete list.

Reach for the weakest interface you actually use — the abstract base classes nest as IterableCollectionSequence. If you only loop once and never index, Iterable[Animal] is broader still and admits sets and generators as well:

# Pick the weakest interface you actually use
from collections.abc import Iterable

def describe(animals: Iterable[Animal]) -> None:
    for a in animals:                 # single pass, no indexing
        print(type(a).__name__)

describe([Dog()])          # list is Iterable
describe((Dog(), Cat()))   # tuple is Iterable
describe({Dog()})          # set is Iterable too

Import these from collections.abc, not typing: since Python 3.9 (PEP 585) the typing.Sequence / typing.Iterable aliases are deprecated in favour of the real ABCs, which subscript directly. typing.Sequence still works and is unavoidable if you must run on 3.8, but new code — or code with from __future__ import annotations — should prefer collections.abc. See Sequence vs list in function parameters for the fuller argument that read-only parameters should almost always be abstract.

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
Mapping is covariant in its value type only A dict of str to Dog cannot stand in for dict of str to Animal, but does satisfy Mapping of str to Animal because Mapping's value type is covariant while its key type stays invariant. shelter dict[str, Dog] {"rex": Dog()} as dict[str, Animal] ✗ value invariant — [arg-type] as Mapping[str, Animal] ✓ value covariant
Swapping the read-only parameter to Mapping makes the value type covariant.

Note the asymmetry between the two type parameters. In typeshed Mapping is Mapping(Collection[_KT], Generic[_KT, _VT_co]): the value variable _VT_co is covariant, but the key variable _KT is invariant, because a key is both accepted by __getitem__(self, key) and produced by keys(). So value-widening works while key-widening does not:

# Value is covariant; key is NOT
from collections.abc import Mapping

def names(reg: Mapping[str, Animal]) -> list[str]:
    return list(reg)

shelter: dict[str, Dog] = {"rex": Dog()}
names(shelter)                    # OK: value Dog <: Animal

wide: Mapping[object, Animal] = shelter   # mypy error: [assignment]
# key type of Mapping is invariant — str is not object here

MutableMapping reintroduces __setitem__, so it is invariant in the value too; use it only when the function genuinely stores into the mapping. As with sequences, the concrete dict satisfies Mapping with no conversion and no runtime cost — you are just narrowing the interface the callee is allowed to touch.

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)
Which standard collections are covariant vs invariant Immutable and read-only types tuple, frozenset, Sequence, Iterable and Mapping value are covariant; mutable containers list, set, dict and MutableSequence are invariant. Covariant — read-only / immutable tuple[T, ...] frozenset[T] / AbstractSet[T] Sequence[T] / Iterable[T] / Iterator[T] Mapping[K, V] (value V only) Invariant — mutable list[T] set[T] / MutableSet[T] dict[K, V] / MutableMapping[K, V] MutableSequence[T] / bytearray mutability is the deciding factor in every row
Immutable and read-only types are covariant; anything with a mutator is invariant.

Both the homogeneous tuple[T, ...] and fixed-length forms like tuple[Dog, Cat] are covariant position by position, because a tuple never rebinds a slot. frozenset is covariant while set is not; the abstract collections.abc.Set (aliased as AbstractSet) is the covariant read-only view of either:

from collections.abc import Set as AbstractSet

def count_unique(a: AbstractSet[Animal]) -> int:
    return len(a)

frozen: frozenset[Dog] = frozenset({Dog()})
count_unique(frozen)          # OK: frozenset is covariant
count_unique({Dog(), Dog()})  # OK: a set satisfies the read-only AbstractSet

The practical takeaway is a design guideline: prefer immutable containers and read-only ABCs at your public boundaries. They compose with subtyping the way intuition expects — tuple[Dog, ...] really is a tuple[Animal, ...] — whereas the mutable concrete types force exact-match element types on every caller.

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.
Choosing a collection parameter type If you mutate the collection keep an invariant list or make the function generic; otherwise pick Iterable to loop, Sequence to index, or Mapping to look up keys. Do you mutate it? yes no keep list[T] (invariant) or make it generic: TypeVar read-only — how do you read it? loop once Iterable index it Sequence by key Mapping
Start from what the function does to the collection, then pick the weakest matching type.

Two more edges are worth knowing. First, a list literal is inferred against its expected type, so this compiles even though a pre-typed list[Dog] would not — a common source of confusion about when invariance “bites”:

# A list literal is inferred at the expected type — this is fine
animals: list[Animal] = [Dog(), Cat()]   # OK: literal, not a pre-typed list[Dog]
# The invariance error only fires when an *already-typed* list[Dog] is passed/assigned.

Second, when a function must both accept subtype collections and preserve the exact element type in its result, invariance is not the tool — a generic function is. A bounded TypeVar threads the concrete type through:

from typing import TypeVar
from collections.abc import Sequence

A = TypeVar("A", bound=Animal)

def first(animals: Sequence[A]) -> A:      # preserves the element type
    return animals[0]

reveal_type(first([Dog(), Dog()]))         # Revealed type is "Dog", not "Animal"

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].
Invariance mistakes, symptoms, and fixes A read-only list parameter, a type ignore on a mutator, and a dict parameter each cause a symptom that is resolved by widening to a read-only abstract type or correcting the type. Mistake Symptom Fix read-only list[T] param [arg-type] on valid callers use Sequence / Iterable # type: ignore on mutator a Cat leaks into list[Dog] fix the type, keep the check dict[str, Dog] as Animal value invariant — [arg-type] Mapping[str, Animal]
Every fix is the same move: narrow the interface to what you actually use, or correct the element type.

Two further traps are worth calling out. Using List/Dict imported from typing changes nothing about variance — typing.List is just the deprecated spelling of list and is equally invariant; the covariance you want comes from choosing Sequence/Mapping, not from a different import. Likewise, reaching for Any (animals: list[Any]) does silence the error, but it silences all element checking inside the function, so a genuine bug — indexing the wrong attribute on an element — now goes unreported too. Prefer a precise read-only type: it accepts the subtype collections you care about while keeping the element type fully checked. When you truly need a concrete list of the wider type, build one explicitly with [a for a in animals] or list(animals), which copies rather than aliasing, so no later append can reach the caller’s original object.

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