skip to content

Why annotate a parameter as Iterable[str] rather than Iterator[str] or list[str]?

level: juniorimportance: must knowfreq 58%

answer

  1. Ask what the body actually does
  2. One pass, or index and re-loop?
  3. Widest annotation the body still supports
  4. Iterator is a spent cursor
  5. Sequence adds len, indexing, re-iteration

basics

~10 s

collections.abc.Iterable[str] says only that the function will loop over the argument, so lists, sets, dict views and generators all fit. Iterator[str] demands a one-shot cursor, and list[str] rejects every other container.

solid answer

~40 s

Pick the annotation from what the body actually does. If it runs one `for` loop, or hands the argument to `sum()` or `str.join()`, then `Iterable[str]` is the honest promise: anything with `__iter__` qualifies, so callers may pass a list, a tuple, a set, a `dict` view or a generator without converting first. `Iterator[str]` is narrower and means something different — it says the argument *is* the cursor (`__iter__` plus `__next__`), that the function may consume it, and that it will be spent afterwards. `Sequence[str]` is what you need when the body indexes, slices or calls `len()`, or loops twice. `list[str]` is the narrowest of all and should be reserved for a body that mutates the caller's list. The rule of thumb: demand the least the body can live with.

code

python · 14 lines
python
from collections.abc import Iterable, Iterator, Sequence

def total(values: Iterable[int]) -> int:
    return sum(values)

def second(values: Sequence[int]) -> int:
    return values[1]

def drain(values: Iterator[int]) -> int:
    return sum(values) + sum(values)

print(total([1, 2, 3]), total({1, 2}), total(x for x in range(4)))
print(second([10, 20, 30]))
print(drain(iter([1, 2, 3])))

go deeper

for a junior

Be ready to say what each name promises: Iterable means iter() works, Iterator means it is the cursor and gets used up, Sequence means indexing and len() also work. Then pick the widest one your loop can live with.

for a middle

Explain the mechanics: iter versus iter plus next, why a generator satisfies Iterable but fails len(), and why a second for loop over an Iterator parameter silently yields nothing instead of raising.

for a senior

Show the production judgement — a function that needs two passes must either materialise the argument itself or widen the annotation, and you should be able to say which you would choose and what memory it costs.

for a principal

Own the API-boundary rule for a codebase: parameters demand the least they can, so callers never wrap arguments in list() to satisfy a signature, and streaming callers are not locked out of shared helpers.

### Three names, three different promises `collections.abc.Iterable[T]` promises exactly one thing: `iter(x)` succeeds. The object implements `__iter__` and returns a **fresh** cursor each time it is called. `collections.abc.Iterator[T]` promises `__iter__` **and** `__next__`: the object *is* the cursor, it advances as it is read, its `__iter__` returns itself, and once it raises `StopIteration` it stays exhausted. `collections.abc.Sequence[T]` promises far more — integer indexing, slicing, `__len__`, `in`, `index()`, `count()` and reverse iteration — and every sequence is also an iterable. A parameter annotation is a promise in two directions at once. It tells the type checker what the caller is allowed to pass, and it tells the caller what the body is allowed to assume. Choosing a wider annotation than the body needs is a bug waiting to happen; choosing a narrower one than the body needs turns callers into converters. ### Derive the annotation from the body - One pass, no length, no indexing, and the values are only read: **`Iterable[T]`**. - The body calls `len()` or uses `in` repeatedly but does not care about order or position: **`Collection[T]`** (`Iterable` plus `Container` plus `Sized`). - The body indexes, slices, or iterates more than once: **`Sequence[T]`**. - The body deliberately drives a stream — interleaving `next()` calls, pulling a header off the front and passing the rest along: **`Iterator[T]`**. - The body mutates the caller's object with `append` or `sort`: the concrete **`list[T]`**, because that is genuinely what it requires. The most common mistake in real code is annotating `list[str]` because that is what the first caller happened to pass. Every later caller then writes `list(...)` at the call site, materialising data the function was never going to keep, and a caller who genuinely streams cannot use the function at all. ### The one-shot trap The interesting failure lives at the `Iterable` / `Iterator` boundary. Imagine a translation-memory updater whose helper both counts the segments it received and counts how many were stale. If it iterates the parameter twice, a list caller gets the right answer and a generator caller gets a correct total and a stale count of zero — no exception, no traceback, just a wrong number that a four-person team will chase for an afternoon, because the second loop is walking an already-exhausted iterator. That is why `Iterable[T]` is a promise about the *caller*, not a licence for the body. A function that annotates `Iterable[T]` must still touch the argument once. If it needs a second pass, it has two honest options: materialise at the top (`segments = list(segments)`), paying the memory explicitly, or widen the annotation to `Sequence[T]` or `Collection[T]` and let the type checker refuse the generator at the call site. `itertools.tee` is a third option for forking a foreign iterator, but it buffers whatever the slower branch has not consumed, so it trades the same memory less visibly. ### None of it is enforced at runtime CPython does not check annotations. A parameter annotated `Sequence[str]` will happily receive a generator, and the failure appears only when the body reaches for `len()` and gets `TypeError`. Since Python 3.14, annotations are not even evaluated at definition time by default (PEP 649/749) — they are computed lazily, so a wrong annotation costs nothing at import. The value comes from a type checker reading the same source, which is why the annotation should describe the contract precisely rather than describe the first argument you imagined. ### Spelling and history Since Python 3.9 (PEP 585) the `collections.abc` classes are subscriptable directly, so `Iterable[str]` imported from `collections.abc` is the modern spelling; `typing.Iterable`, `typing.Iterator` and `typing.Sequence` are deprecated aliases kept for compatibility. Both are the same runtime objects for checking purposes, and mixing them in one codebase is only a consistency problem, not a correctness one. ### The runtime cousin: isinstance against these ABCs The same classes double as abstract base classes, and two of them answer `isinstance` structurally. `isinstance(x, collections.abc.Iterable)` is `True` for any object whose type defines `__iter__`, because the ABC installs a `__subclasshook__` that looks for exactly that method; `isinstance(x, collections.abc.Iterator)` similarly checks for `__iter__` and `__next__`, so a generator object passes. `Sequence` has no such hook: a class that defines `__len__` and `__getitem__` is *not* an instance of `Sequence` unless it inherits from it or is registered, even though `list`, `tuple`, `str` and `range` all are. Two consequences matter for signatures. First, a runtime `isinstance(value, Iterable)` guard and the static annotation agree for `Iterable` and `Iterator` but not for `Sequence`, so do not reach for `isinstance(value, Sequence)` as a validation of an annotation. Second, `isinstance(value, Iterable)` says nothing about the element type — the `[str]` part is erased at runtime, and checking it would mean consuming the very iterator you are inspecting. ### The one signature that traps everybody `str` is itself an `Iterable[str]`, so a function annotated `def load(names: Iterable[str])` accepts a bare string and quietly iterates it character by character. The type checker cannot help: the annotation is satisfied. When that distinction matters, take `Sequence[str]` and validate, or take `list[str] | tuple[str, ...]`, or check `isinstance(names, str)` at the top and raise. It is the single most common way a correctly-annotated `Iterable[str]` parameter still produces nonsense.

  • What happens if a function annotated Sequence[str] is handed a generator anyway?
    The type checker rejects the call, but CPython does not: annotations are not enforced at runtime. The failure appears later, when the body reaches for `len()`, an index or a slice and the generator raises `TypeError`. That is the whole argument for annotating what the body needs rather than what the first caller happened to pass.
  • How do you accept an iterable and still iterate it twice?
    Materialise it explicitly at the top of the function — `values = list(values)` or `tuple(values)` — so the memory cost is visible and deliberate. If the second pass is essential to the contract, annotate `Sequence[T]` or `Collection[T]` instead and let the checker force a concrete container. `itertools.tee` forks a foreign iterator but buffers whatever the slower branch has not read yet.
  • When is Collection[str] a better parameter annotation than Iterable[str]?
    When the body calls `len()` or tests membership with `in` more than once. `collections.abc.Collection` is `Iterable` plus `Sized` plus `Container`, so it rules out generators without also demanding indexing and ordering the way `Sequence` does. It is the right middle rung when a set, a dict view or a frozenset is a perfectly good argument.

An Iterable is a book you can reopen at page one; an Iterator is the bookmark, and once it reaches the back cover it does not rewind.

saying these in an interview costs you the question

  • Treats Iterable and Iterator as interchangeable annotations
  • Annotates list[str] whenever the body merely loops
  • Thinks annotating Iterable makes Python buffer the argument
  • Iterates an Iterator[str] parameter twice and expects the same values
  • Believes CPython enforces parameter annotations at call time

context