2025, Sep 28 13:00
List vs Sequence in Python typing: variance, bidirectional inference, and flow analysis explained
Why list[SubA] as Sequence[BaseItem] blocks append: how Python type checkers apply variance, bidirectional inference, and flow analysis to list vs Sequence.
Type checkers are strict for a reason, and sometimes that strictness feels counterintuitive. A classic example is mixing subtypes in lists versus working with covariant views like Sequence. Below is a walkthrough of where the confusion comes from and how features like bidirectional inference and flow analysis shape the result.
Reproducing the situation
from typing import Sequence
class BaseItem: ...
class SubA(BaseItem): ...
class SubB(BaseItem): ...
items: list[BaseItem] = [SubA()]
items.append(SubB())  # OK: a list of BaseItem can hold both SubA and SubB
So far so good. Now factor out a function that returns a list of a specific subtype and try to assign it to a list of the base type.
def build_suba_list() -> list[SubA]:
    return [SubA(), SubA()]
items2: list[BaseItem] = build_suba_list()  # Invariance error for list
That error makes sense because list is invariant. A covariant view like Sequence looks tempting:
more_items: Sequence[BaseItem] = build_suba_list()
more_items.append(SubB())  # Type-checker error
Why does the append call fail here, and why does this variation pass?
extra_items: Sequence[BaseItem] = []
extra_items.extend(build_suba_list())  # OK
extra_items.append(SubB())              # OK
What’s really happening
Two features are at play: bidirectional inference and flow analysis. They are enough to explain the behavior end-to-end.
Bidirectional inference uses an expected type to disambiguate normal inference. Consider this minimal case:
from typing import Iterable, Literal
xs = [3]
# On its own, this could be many things: list[int], Iterable[object], etc.
ys: Iterable[Literal[3]] = [3]
reveal_type(ys)  # list[Literal[3]] thanks to the expected Iterable[Literal[3]]
Flow analysis can narrow a symbol to a more precise type than its declared or inferred one when it’s sound to do so:
num: int = 3
reveal_type(num)  # Literal[3], narrowed from int
res = num + 2
reveal_type(res)  # Literal[5], not just int
Back to the list vs Sequence puzzle
Assigning the return value of a function that produces a concrete list subtype to a variable annotated as Sequence produces a precise type for the actual value, not merely the annotated view. Flow analysis kicks in and preserves what the expression really is.
from typing import Sequence
class BaseItem: ...
class SubA(BaseItem): ...
class SubB(BaseItem): ...
def build_suba_list() -> list[SubA]:
    return [SubA(), SubA()]
view1: Sequence[BaseItem] = build_suba_list()
reveal_type(view1)  # list[SubA] via flow analysis of the right-hand side
# The method resolution therefore targets list[SubA].append,
# which cannot accept SubB.
view1.append(SubB())  # Error: not compatible with list[SubA].append
In contrast, constructing an empty list where the expected type is Sequence[BaseItem] steers inference differently. The empty literal plus the expected type and subsequent mutations lead the type checker to treat the underlying list as list[BaseItem], which then behaves as intended.
view2: Sequence[BaseItem] = []
reveal_type(view2)  # list[BaseItem] due to expected type plus flow analysis
# Extending a list[BaseItem] with an Iterable[SubA] is fine,
# since list[SubA] is a subtype of Iterable[BaseItem].
view2.extend(build_suba_list())  # OK
# Appending SubB to list[BaseItem] is also valid.
view2.append(SubB())  # OK
Why the difference is subtle but important
When you bind a value to a name, the checker carries the most precise type it can for that value. If the right-hand side is a list[SubA], the name is effectively treated as list[SubA] for method resolution, even if the annotation mentions Sequence[BaseItem]. That’s why an append call fails in the first case: the underlying container is still analyzed as list[SubA], and SubB doesn’t fit.
When you build the container in place with an empty list and an expected base view, the checker treats the evolved container as list[BaseItem], and subsequent mutations are checked against that target type. Extending with a list[SubA] works because extending a list[BaseItem] consumes Iterable[BaseItem], and a list[SubA] qualifies. Appending SubB also works because it’s a subtype of BaseItem.
Practical resolution
If you need a covariant view but also want to perform mutations compatible with the base type, initialize the container in the context that sets the expected base type first, then populate it. Assigning a prebuilt list[SubA] directly to a Sequence[BaseItem] name narrows the effective type to list[SubA], and method resolution follows that narrower type, blocking appends of other subtypes.
Takeaways
This behavior follows directly from bidirectional inference and flow analysis. Understanding how the checker carries precise information about actual values explains why Sequence with a prebuilt list doesn’t magically relax method types, and why constructing the container under the expected base type produces the result you want. Use reveal_type during development to confirm what the checker has inferred at each step and avoid surprises.