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 section 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.

collections.abc protocol hierarchy for annotations Iterable is the root; Collection adds container and sized; Sequence and Mapping refine Collection; MutableSequence and MutableMapping add write methods; list and dict are the concrete leaves. Iterable[T] __iter__ only Collection[T] + len, in, sized Sequence[T] indexed, covariant Mapping[K, V] keyed, V covariant MutableSequence[T] list — invariant MutableMapping[K, V] dict — invariant
Read-only abstract types near the top accept more callers; mutable concrete leaves at the bottom guarantee write methods but are invariant.

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.

PEP 585 before and after The deprecated typing.List of str becomes the built-in list of str in Python 3.9, and a version timeline marks when each form works. typing.List[str] PEP 484 alias — deprecated ruff UP006 / UP035 list[str] PEP 585 — built-in generic Python 3.9+ 3.7 / 3.8 string annotation only 3.9 builtins subscript at runtime 3.9+ typing aliases deprecated
PEP 585 (Python 3.9) lets built-in containers act as generics, retiring the typing.List-style aliases.
# 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.

PEP 585 did not change the type system — list[str] and typing.List[str] mean exactly the same thing to mypy and pyright — it only removed the need for parallel aliases. Those aliases are soft-deprecated: importing typing.List emits no DeprecationWarning at runtime, so tooling is the only signal you get. PEP 585 deliberately set no removal date and promised the aliases would survive “at least five years,” which is why typing.List still resolves on Python 3.13. That means a codebase can mix List[str] and list[str] without any runtime consequence; the pressure to converge is purely stylistic and CI-driven.

The runtime mechanics bite when annotations are evaluated eagerly. By default a function annotation is evaluated as the def executes, so on Python 3.8 def f(x: list[str]) -> None: ... raises TypeError: 'type' object is not subscriptable at import time. from __future__ import annotations (PEP 563) turns every annotation into an unevaluated string, sidestepping the error — but a framework that later calls typing.get_type_hints() forces evaluation again, and the TypeError returns unless the interpreter is 3.9+. For code that must still run on 3.8, keep the typing.List form or quote the annotation as "list[str]"; from 3.9 on, prefer built-in generics everywhere and let ruff’s pyupgrade rules rewrite the legacy imports in bulk.

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] — supports for iteration (__iter__).
  • Iterator[T] — supports next() and single-pass consumption (__next__).
  • Collection[T] — iterable, sized, and supports in.
  • Sequence[T] — indexable and reversible; str, tuple, list, range all qualify.
  • Mapping[K, V] — keyed lookup without mutation; dict and MappingProxyType qualify.
  • MutableSequence[T] / MutableMapping[K, V] — add append/__setitem__ and friends.
collections.abc capability matrix A table marking which capabilities — iteration, length and membership, integer indexing, keyed lookup and in-place mutation — each abstract base class supplies. ABC iter len·in index keyed mutate Iterable[T] Collection[T] Sequence[T] Mapping[K, V] MutableSequence[T]
Each ABC guarantees a fixed set of methods; richer types add capabilities as you move down the table.

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

Every ABC maps to a small set of dunder methods, and most support structural isinstance checks through __subclasshook__: isinstance(obj, collections.abc.Iterable) is True for anything defining __iter__, with no explicit registration. That makes them useful at runtime as well as in annotations — but the check is shallow. isinstance(x, Sequence) verifies only that x implements the Sequence interface, never the element type, so it cannot distinguish a Sequence[int] from a Sequence[str] at runtime. Beyond the daily set, narrower protocols are worth knowing: Iterator[T] is single-pass — __next__ consumes it, so a return annotated Iterator[T] tells the caller they may walk it only once — while Reversible[T] adds __reversed__, Hashable marks objects usable as dict keys or set members, Sized is just __len__, and Container[T] is just __contains__. Callable[[int], str] also lives in collections.abc from 3.9 (the typing.Callable alias is likewise deprecated). Because these ABCs are themselves defined by method presence, they are the standard library’s Protocols in all but name; for generator-specific return types see Iterable vs Iterator vs Generator.

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.

Where does the import belong A container annotation is imported from collections.abc or a builtin, while typing-only constructs like Optional, TypeVar and Protocol stay in typing. What am I annotating? a container list, dict, set, Sequence, Mapping, Callable a typing-only construct Optional, Union, Any, TypeVar, ClassVar, Protocol collections.abc / builtin typing
Containers come from collections.abc (or the builtin); everything with no runtime container stays in typing.

Concretely, most typing aliases for containers now have a collections.abc or builtin home: typing.List/Dict/Set/FrozenSet/Tuple/Typelist/dict/set/frozenset/tuple/type; typing.Deque/DefaultDict/OrderedDict/Counter → their collections equivalents; typing.Sequence/Mapping/Iterable/Iterator/Callable/Setcollections.abc. What legitimately stays in typing (or typing_extensions on older runtimes) are the constructs with no runtime container behind them: Optional, Union, Any, Literal, Annotated, Final, ClassVar, TypeVar, ParamSpec, Protocol, TypedDict, NoReturn, Never, and Self. A single well-formed import line usually looks like from collections.abc import Iterable, Mapping, Sequence next to from typing import Optional, Protocol, TypeVar — mixing typing.Sequence into that first group is exactly the inconsistency UP035 exists to catch.

Runtime vs static analysis `collections.abc.Sequence` is subscriptable for annotations at runtime only on Python 3.9+. Before that, `from collections.abc import Sequence` imports the ABC fine, but `Sequence[int]` at runtime raises 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].

Abstract-to-concrete spectrum Widest abstract types like Iterable belong on parameters to accept more callers; concrete types like list belong on returns to guarantee methods. Iterable[T] Collection[T] Sequence[T] MutableSequence list[T] ◄ abstract & wide accept more → parameters concrete ► guarantee methods → returns
Pick the leftmost type that still supplies the methods you call on a parameter; move right only when the caller must be guaranteed those methods.

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

The symmetry is deliberate: be liberal in what a parameter accepts and precise in what a return promises. Over-abstracting a return backfires. If recent() returned Iterable[dict[str, int]], callers could no longer call len(result) or result[0] without a mypy [arg-type] / pyright reportArgumentType error, and if the backing object were a generator they could iterate it only once. Collection[T] is a useful middle ground when you want to promise len and in but not indexing. The one caveat to “widest abstract wins” is that each ABC must actually supply every method you call: annotate a parameter Iterable[T] and then call len() on it, and the checker rejects your own body, because Iterable has no __len__. Choose the type by the operations in the function, not by habit — and remember that returning Sequence[T] instead of list[T] also frees you to swap the backing store (to a tuple, a lazy view, or a MappingProxyType) later without breaking a single caller.

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.

The unsafe write that forces list invariance Passing a list of bool where a list of int is expected would let the function append a plain int and corrupt the caller, so list is invariant; a read-only Sequence has no such write and is covariant. UNSAFE — if list[bool] were a list[int] caller: list[bool] [True, False] def f(x: list[int]) expects mutable ints x.append(7) writes a plain int caller's list[bool] now holds 7 → invariant, so list[bool] ⊄ list[int] SAFE — read-only view has no write caller: list[bool] def g(x: Sequence[int]) read only ✓ covariant
The hypothetical append is the whole reason list is invariant; remove the write and Sequence is safely covariant.
# 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.

Formally, a generic C[T] is covariant if C[Sub] is usable where C[Super] is expected (read-only producers), contravariant if the direction reverses (write-only consumers), and invariant if neither substitution is safe. Sequence, Iterable, Mapping (in its value V), and AbstractSet are covariant because you only read out of them; list, dict, set, and MutableSequence are invariant because you can both read and write. Mapping[K, V] is a useful subtlety: it is covariant in V but invariant in K, since key lookup constrains the key type. When you author your own generic, TypeVar defaults to invariant, and you opt in with TypeVar("T_co", covariant=True) or TypeVar("T_contra", contravariant=True) (Python 3.13’s PEP 695 class Box[T]: syntax infers variance automatically). Callable[[int], str] shows both directions at once — it is contravariant in its argument types and covariant in its return — which is why a function taking object can stand in for one taking int. In day-to-day code you rarely declare variance yourself; you simply widen mutable parameters to the covariant read-only ABCs and the [arg-type] errors disappear.

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.

Sets to abstract bases, and fixed versus variadic tuples set and frozenset map to AbstractSet and MutableSet; a fixed tuple has one type per slot while a variadic tuple repeats one element type. set[T] frozenset[T] AbstractSet[T] — read-only, covariant MutableSet[T] — add / discard, invariant tuple[int, str, bytes] — fixed arity, one type per slot int str bytes tuple[int, ...] — variadic, same type repeated int int int … any length
Sets follow the read-only/mutable split like sequences; a fixed tuple types each position, a variadic tuple types one repeated element.
# 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

A few tuple corners are worth internalizing. The empty tuple is spelled tuple[()] — an empty-parens form the checkers require, since tuple[] is a syntax error. From Python 3.11 (PEP 646) you can splice a variadic segment into a fixed shape with tuple[int, *tuple[str, ...]], expressing “an int followed by any number of str,” and *Ts unpacks a TypeVarTuple. When you want a named, self-documenting fixed tuple, reach for typing.NamedTuple instead: class Point(NamedTuple): x: float; y: float yields a real tuple subtype whose fields are also attributes, gaining Point(1.0, 2.0).x without giving up tuple behavior. On the set side, remember frozenset[T] is Hashable (so it can be a dict key or live inside another set) while set[T] is not, and MutableSet[T] is invariant for the same append-style reason list is. Finally, str, bytes, and bytearray all satisfy Sequence[str]/Sequence[int], so a bare Sequence parameter silently accepts them — validate or annotate more tightly when a genuine multi-element collection is required.

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

These wrong-annotation patterns cause most container-typing churn; each has a mechanical fix that widens or narrows the type to match how the value is actually used.

Container annotation mistakes and fixes Each over- or under-specified container annotation on the left maps to a corrected annotation on the right. Mistake Fix list[str] param you only read Sequence[str] from typing import List, Dict list, dict + collections.abc MutableMapping on read-only arg Mapping[K, V]
Every container-typing slip has a one-line correction that realigns the annotation with how the value is used.
  • Annotating a parameter list[T] when you only read it. Rejects tuple and list-of-subtype callers with mypy [arg-type] / pyright reportArgumentType. Use Sequence[T].
  • Importing containers from typing. from typing import List, Dict triggers ruff UP035; the subscripted use triggers UP006. Import list/dict and collections.abc.
  • Passing a str where you meant a sequence of tokens. str is a Sequence[str], so Sequence[str] silently accepts a bare string and iterates it character by character — no analyzer error at all. Annotate list[str] or validate length when a real collection is required.
  • Using MutableMapping on a read-only argument. Forces callers to hand you a mutable dict and blocks MappingProxyType; downgrade to Mapping[K, V].
  • Returning Iterable[T] from a generator callers expect to reuse. A generator is single-pass, so a second for loop over the same result silently sees nothing. Either materialize and return list[T], or annotate Iterator[T] to signal the return is consumed once.
  • Reaching for MutableSequence when a plain Sequence will do. MutableSequence[T] demands append, insert, and slice assignment; if your body never mutates, it needlessly rejects tuple and range callers with [arg-type].

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.

Back to Core Type Hints Fundamentals