Typing Collections and collections.abc in Modern Python
Annotating collections well is where most type-hint hygiene pays off: pick Sequence[str] and your function accepts lists, tuples, and any read-only sequence; pick list[str] and you have quietly rejected half your callers. This cluster maps the built-in generics against the collections.abc abstract base classes, explains PEP 585, and shows how variance decides which annotation is safe on a parameter versus a return.
Built-in generics replace typing.List (PEP 585)
Since Python 3.9, the standard containers subscript directly: list[str], dict[str, int], set[bytes], tuple[int, ...]. PEP 585 deprecated the aliased typing.List, typing.Dict, and typing.Set — they still work but exist only for backward compatibility. Ruff’s UP006 rewrites List[str] to list[str] automatically, and UP035 flags the deprecated imports.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
def load_records(rows: list[dict[str, int]]) -> set[str]:
return {str(row["id"]) for row in rows}
# ruff UP006: use `list` not `typing.List`
# ruff UP035: `typing.Dict` is deprecated, import removed
On Python 3.7 and 3.8 the bare list[str] form only works inside a string annotation, which is what from __future__ import annotations gives you. If a library reads annotations at runtime — a dataclass calling get_type_hints(), for example — subscripting a builtin without that future import raises TypeError before 3.9.
collections.abc: the abstract vocabulary
collections.abc supplies the abstract base classes you annotate against when you care about capability, not concrete class. The ones you reach for daily:
Iterable[T]— supportsforiteration (__iter__).Iterator[T]— supportsnext()and single-pass consumption (__next__).Collection[T]— iterable, sized, and supportsin.Sequence[T]— indexable and reversible;str,tuple,list,rangeall qualify.Mapping[K, V]— keyed lookup without mutation;dictandMappingProxyTypequalify.MutableSequence[T]/MutableMapping[K, V]— addappend/__setitem__and friends.
Since Python 3.9 you import these from collections.abc, not typing; the typing.Sequence re-exports are deprecated aliases that ruff UP035 will flag. Use collections.abc for annotations and reserve the runtime ABCs (same objects) for isinstance checks.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Mapping, Sequence
def render(config: Mapping[str, str], order: Sequence[int]) -> str:
return f"{len(config)} keys over {len(order)} items"
render({"env": "prod"}, (1, 2, 3)) # dict + tuple both accepted
typing.* versus collections.abc.*
The rule is mechanical: for a plain container annotation, import the runtime class from collections.abc. Keep typing only for constructs that have no collections.abc equivalent — Optional, Any, TypeVar, ClassVar, and pre-3.9 aliases. Mixing typing.Sequence with collections.abc.Mapping in one file is a common inconsistency; standardize on the collections.abc source so ruff has nothing to rewrite.
TypeError: 'ABCMeta' object is not subscriptable unless the annotation is deferred with from __future__ import annotations. Type checkers accept the subscript regardless of version.
Abstract to accept more, concrete to guarantee methods
The choice between abstract and concrete is a contract decision. On a parameter, prefer the widest abstract type that supplies the methods you actually call. If you only iterate and index, Sequence[str] lets callers pass a list, a tuple, or a custom read-only view. If you call .append(), you genuinely need MutableSequence[str] — or a concrete list[str].
On a return or attribute, be as concrete as the caller needs. Returning list[str] promises indexing and mutation; returning Sequence[str] promises only read access and lets you swap the backing store later. A repository method is the classic example:
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable, Sequence
class OrderRepository:
def bulk_insert(self, orders: Iterable[dict[str, int]]) -> None:
for order in orders: # only iterates → Iterable is widest safe type
self._store.append(order)
def recent(self, limit: int) -> Sequence[dict[str, int]]:
return self._store[-limit:] # read-only view for callers
Variance: why the wide types are safe on parameters
Read-only abstract types are covariant, and that is exactly why they accept subtypes. Sequence[T] and Mapping[K, V] (in V) are covariant, so Sequence[bool] is accepted where Sequence[int] is expected. list and dict are invariant: list[bool] is not a list[int], because a function that receives a mutable list[int] could append a plain int and corrupt the caller’s list[bool]. That single safety rule — mutability forces invariance — is why widening a parameter to Sequence/Mapping removes a whole class of [arg-type] errors. The deep explanation lives in why list is invariant but Sequence is covariant and the broader variance and type parameters guide.
# Python 3.9+, mypy 1.10 — invariance in action
def total(values: list[int]) -> int:
return sum(values)
flags: list[bool] = [True, False]
total(flags) # error: Argument 1 has incompatible type "list[bool]"; expected "list[int]" [arg-type]
# Widen the parameter to Sequence[int] and the same call type-checks cleanly.
Sets, tuples, and the fixed-versus-variadic tuple
Two containers deserve their own note. set[T] and frozenset[T] have an abstract counterpart in AbstractSet[T] (covariant, read-only) and MutableSet[T]; annotate a read-only set parameter AbstractSet[T] for the same reason you prefer Sequence over list. Tuples are the exception to the “abstract is wider” rule because they carry positional type information. tuple[int, str, bytes] is a fixed three-element heterogeneous tuple; tuple[int, ...] is a homogeneous variadic tuple of any length. Widening a fixed tuple to Sequence[object] throws away the per-position types, so keep the explicit tuple[...] form when the arity matters — for example a coordinate tuple[float, float] returned from parse_response.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import AbstractSet
def known_ids(ids: AbstractSet[int]) -> int:
return max(ids) # accepts set and frozenset, read-only
def bounds() -> tuple[float, float]:
return (0.0, 100.0) # fixed arity — do not widen to Sequence
Wire the collections.abc preference into CI so it stays consistent: enable ruff’s pyupgrade rules and let mypy strictness settings reject the untyped-container escapes that hide behind a bare list or dict.
Common mistakes
- Annotating a parameter
list[T]when you only read it. Rejects tuple andlist-of-subtype callers with mypy[arg-type]/ pyrightreportArgumentType. UseSequence[T]. - Importing containers from
typing.from typing import List, Dicttriggers ruffUP035; the subscripted use triggersUP006. Importlist/dictandcollections.abc. - Passing a
strwhere you meant a sequence of tokens.stris aSequence[str], soSequence[str]silently accepts a bare string and iterates it character by character — no analyzer error at all. Annotatelist[str]or validate length when a real collection is required. - Using
MutableMappingon a read-only argument. Forces callers to hand you a mutabledictand blocksMappingProxyType; downgrade toMapping[K, V].
FAQ
Should I import Sequence from typing or collections.abc?
From collections.abc on Python 3.9+. The typing.Sequence alias is deprecated (ruff UP035) and only kept for older code. Both point at the same runtime ABC.
Is tuple[int, ...] a Sequence[int]?
Yes. Any tuple, list, range, or str-like object satisfies Sequence[T] when its elements match T. That is precisely why Sequence is the right parameter type for read-only access.
When is list[str] actually the correct annotation?
When you mutate the argument in place (append, sort, slice assignment) or when a caller must be handed something they can mutate. For pure reads, Sequence[str] is strictly more permissive.