Most people choose between Iterable, Iterator, Sequence and list by asking “what will callers pass me?”. That is the wrong question, and it is why the answer usually ends up being list.
The right question is: what am I going to do to this argument? The annotation is a promise about the body, not a description of the caller.
The four promises
| You write | You are promising |
|---|---|
Iterable[T] |
I will iterate it — possibly zero times, at most once |
Iterator[T] |
I will call next() on it; I know it is single-shot and I own its position |
Collection[T] |
I will iterate it, and I may take len() and use in |
Sequence[T] |
I may index it, slice it, reverse it, and traverse it more than once |
Generator[Y, S, R] |
I may send() into it and I care about its return value |
Read down that list and notice it is ordered by how much you are demanding of the caller. Iterable demands almost nothing, so almost everything satisfies it. Sequence demands a lot, so a generator cannot be passed at all — which is exactly what you want if your body indexes.
The structural definitions, for reference:
-
Iterablerequires__iter__. -
Iteratorrequires__iter__and__next__. Every iterator is an iterable, and returns itself from__iter__. -
CollectionisSized+Iterable+Container:__len__,__iter__,__contains__. -
Sequenceadds__getitem__with integer and slice support, plusindex,count,__reversed__.
Rule one: accept broad, return narrow
def parse(lines: Iterable[str]) -> Iterator[Record]:
for line in lines:
yield Record.from_line(line)
The parameter is Iterable — the broadest thing the body can honestly work with, so a list, a tuple, a file object, a generator, a dict.keys() view and a set all work. The return is Iterator — the narrowest honest description of what comes back, so the caller learns two things: it is lazy, and it is single-shot.
Annotating the return as Iterable[Record] throws away that second fact and callers will read it twice. Annotating the parameter as Iterator[str] refuses lists for no reason whatsoever.
💡If Iterator is a subtype of Iterable, why is returning Iterator "narrower and better" while accepting Iterator is "needlessly restrictive"? Isn't that the same relationship twice?
click to reveal
It is the same relationship, applied in opposite directions — which is exactly what variance means for function types.
A function is a provider of its return value and a consumer of its parameters. Being more specific about what you provide is a strengthening of your guarantee: any caller who could handle an Iterable can also handle an Iterator, so narrowing the return breaks nobody and tells them more.
Being more specific about what you consume is a weakening of your applicability: callers who have a list can no longer call you. That is the covariance/contravariance rule for callables, and “accept broad, return narrow” is just its everyday phrasing. It is also why Callable[[Iterable[str]], Iterator[R]] is a subtype of Callable[[list[str]], Iterable[R]], not the other way around.
The exception is when the narrowness is load-bearing on the parameter — if your body genuinely calls len(), demand Sized; if it genuinely indexes, demand Sequence. Demanding less than your body needs is not generosity, it is a type error waiting to be found at runtime.
Rule two: Iterable means at most one pass
This is the promise people break without noticing, because breaking it raises nothing.
def summarise(rows: Iterable[Row]) -> Summary:
total = sum(r.amount for r in rows) # pass 1
count = sum(1 for _ in rows) # pass 2: zero for a generator
return Summary(total=total, mean=total / count) # ZeroDivisionError, eventually
With a list argument this works and every test passes. With a generator argument the second pass sees an exhausted iterator, count is 0, and the failure surfaces as a division error that has nothing to do with the actual bug.
If you need two passes, say so:
def summarise(rows: Sequence[Row]) -> Summary: # now a generator is a type error
or materialise on purpose, at the top, with the reason written down:
def summarise(rows: Iterable[Row]) -> Summary:
# Two passes; caller may hand us a generator, so buffer once.
buffered = list(rows)
Both are fine. What is not fine is Iterable in the signature and two passes in the body.
Rule three: str satisfies everything
def render(names: Iterable[str]) -> str:
return ", ".join(names)
render("alice") # -> "a, l, i, c, e"
No type error. str is an Iterable[str], and it is a Sequence[str], and it is a Collection[str] — a string is a sequence of one-character strings, so it satisfies every container ABC parameterised by str. bytes has the same problem shaped slightly differently: it is Iterable[int].
There is no annotation that says “a collection of strings but not a string”. The practical defences are: make the parameter keyword-only so the call site reads render(names=...) and the mistake is visible, or take a list[str] when you genuinely want a container, or wrap the type in a NewType. Mostly, know that it happens.
💡Would Sequence[str] have caught the render("alice") bug?
click to reveal
No. str is a Sequence[str] too — it has __len__, __getitem__, slicing, index and count, all of which return or accept str. Every container ABC in the hierarchy is satisfied.
This is not a hole in the type system so much as a consequence of Python’s decision that there is no character type. str[0] is a str, so str is genuinely a sequence of str, and the type checker is telling the truth.
What does catch it: list[str] as the parameter type (invariant, so str is not a list[str]), or tuple[str, ...], or a runtime guard. Some teams keep a tiny helper:
def _not_a_string(value: Iterable[str]) -> Iterable[str]:
if isinstance(value, str):
raise TypeError("expected a collection of strings, got a single string")
return value
which is ugly and, at boundaries that take user configuration, has repeatedly been worth it.
Generator and its three parameters
Generator[Yield, Send, Return]:
def counter() -> Generator[int, str, bool]:
total = 0
while True:
command = yield total # `command` is the Send type: str
if command == "stop":
return True # the Return type: bool
total += 1
Almost no generator uses send() or a return value, so almost every generator should be annotated Iterator[T] and be done with it. Write Generator when the extra parameters are real; otherwise the two Nones are noise that hides which one is which.
The async pair drops one parameter: AsyncGenerator[Yield, Send], because an async generator cannot return a value.
A version note that matters if you ship a library. PEP 696 (3.13) gave type parameters defaults, and 3.13’s typing applied them here — in 3.14’s source the line reads Generator = _alias(collections.abc.Generator, 3, defaults=(types.NoneType, types.NoneType)). So Generator[int] now means Generator[int, None, None].
The trap is that the short form type-checks regardless of the python_version you configure, because the defaults live in the stubs, not in the interpreter you are targeting. mypy will happily sign off on Generator[int] in a package whose metadata claims requires-python = ">=3.12". At runtime on 3.12, typing.Generator[int] raises TypeError: Too few arguments — while collections.abc.Generator[int] does not, because types.GenericAlias never checks arity. So whether you get a clean error or silent divergence depends on which import you used. If you support 3.12, write the full three-parameter form. Same reasoning for AsyncGenerator[int].
Import from collections.abc
typing.Iterable, typing.Iterator, typing.Sequence and friends have been deprecated since 3.9 in favour of the collections.abc originals. Removal is not currently planned, so nothing will break — but new code should not use them.
Note which tool enforces this: mypy --strict does not flag from typing import List, Dict, Optional, not even with --enable-error-code deprecated. Modernisation is entirely ruff’s job (UP035, UP006, UP007). If your CI runs mypy and no linter, deprecated aliases will accumulate forever and the type checker will never say a word.
Choosing, in one paragraph
Start at Iterable[T] for every parameter you only loop over once. Move to Collection[T] the moment you need len() or in. Move to Sequence[T] the moment you index, slice, or need a second pass. Use Iterator[T] as a parameter only when you deliberately want the caller to hand over ownership of a position — a tokeniser’s cursor, a merge join’s cursor — and say so in the docstring, because the type alone cannot express “and do not use this afterwards”. Return Iterator[T] from anything lazy. Reach for list[T] in a signature only when mutation is part of the contract.