Typing Iterable vs Iterator vs Generator Hints
TL;DR — Annotate a return as Iterable[T] when callers only loop over it (possibly more than once); use Iterator[T] when it is single-pass or callers call next() on it; use Generator[Y, S, R] only when send() values or the generator’s return value are part of the contract. Every generator function is an Iterator, so Iterator[T] is the usual, tidiest return annotation for one.
These three names describe increasing specificity, not interchangeable synonyms. Getting the level right tells callers whether they may iterate twice, whether the object is exhausted after one pass, and whether two-way communication is on the table. All three live in collections.abc; typing.Iterable and friends are deprecated aliases (ruff UP035).
iter versus next
An Iterable[T] implements __iter__, which returns a fresh iterator. A list, set, dict, and range are iterables: you can loop over them repeatedly because each for asks for a new iterator. An Iterator[T] implements both __iter__ (returning itself) and __next__, which yields the next item or raises StopIteration. Once an iterator is exhausted, it stays exhausted. This split is the runtime iterator protocol codified in PEP 234: a for statement calls iter(obj) once to obtain an iterator, then calls next() on that iterator until StopIteration is raised.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable, Iterator
def read_lines(source: Iterable[str]) -> Iterator[str]:
for line in source: # only needs __iter__ on the input
stripped = line.strip()
if stripped:
yield stripped # a generator → an Iterator[str]
read_lines accepts an Iterable[str] because it merely loops. It returns an Iterator[str] because the generator it produces is consumed once.
The two ABCs differ by exactly one method, and mypy enforces that difference structurally rather than nominally. You never have to inherit from collections.abc.Iterator; any class that defines __iter__ and __next__ with the right signatures already is an Iterator[T] as far as the checker is concerned.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterator
class Countdown:
def __init__(self, start: int) -> None:
self._n = start
def __iter__(self) -> "Countdown":
return self # an iterator returns itself
def __next__(self) -> int:
if self._n <= 0:
raise StopIteration
self._n -= 1
return self._n + 1
def first(it: Iterator[int]) -> int:
return next(it)
first(Countdown(3)) # structural match, no base class needed
In the ABC hierarchy Iterator is registered as a subclass of Iterable, which is why every Iterator[T] is substitutable for an Iterable[T] — the source of both the flexibility and the exhaustion gotcha below. On Python 3.8 these names are still subscriptable only through typing (typing.Iterator[int]); the collections.abc versions became subscriptable in 3.9 under PEP 585, so a 3.8-compatible codebase either imports from typing or reaches for typing_extensions.
Two runtime consequences follow from the protocol. First, iter(x) on an object that defines only __getitem__ (the old sequence protocol) still succeeds, so a handful of objects are iterable without a literal __iter__; mypy does not accept those as Iterable[T], because the ABC’s __subclasshook__ checks for __iter__ alone. Second, both Iterable and Iterator are @runtime_checkable, so isinstance(x, Iterable) works — but it only verifies the method name exists, never the element type, so isinstance(x, Iterator) can never distinguish an Iterator[int] from an Iterator[str].
When Iterable is the right return
Return Iterable[T] when you hand back something callers may loop over more than once — typically a concrete container you build eagerly. Promising Iterable (not Iterator) signals “you can re-iterate this.”
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable
def parse_response(payload: dict[str, list[int]]) -> Iterable[int]:
return payload.get("ids", []) # a list: re-iterable, sized
A caller can loop twice over this result. If you had returned Iterator[int], doing so would silently yield nothing the second time.
Iterable[T] is covariant in T, so a function typed -> Iterable[int] may legally return list[bool] as well as list[int], and a caller can bind the result to an Iterable[int] variable without complaint. That covariance is exactly what makes it a safe return abstraction: because the type advertises only reading, there is no append that could inject a wrong-typed element, so widening the element type stays sound — the opposite of list, which is invariant precisely because it is mutable.
There is a design tension worth naming. Returning an abstract Iterable[T] keeps your backing store swappable, but it also hides whether the result is cheap to re-iterate. A caller who sees -> Iterable[int] cannot tell a materialized list from a lazily-built generator — both satisfy the annotation — so “you can re-iterate this” is a convention, not something mypy or pyright enforces. When re-iteration and len() genuinely matter, be concrete and return list[int] or Sequence[int]; when laziness is the point, be honest and return Iterator[int]. Reserve bare Iterable[T] for the middle case: an eagerly-built container whose exact type you would rather not pin down.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable, Sequence
def tags_loose() -> Iterable[str]: # "loop over these"; concrete type unpinned
return ("draft", "urgent")
def tags_strict() -> Sequence[str]: # "index, len, re-iterate freely"
return ["draft", "urgent"]
When you need “re-iterable and sized” but do not want to promise indexing, collections.abc.Collection[T] is the precise middle ground: it is Sized, Iterable, and Container combined, so it guarantees len(), in, and repeated iteration without the __getitem__ that Sequence[T] implies. A set satisfies Collection[int] but not Sequence[int], which makes Collection the honest return type when order is not part of the contract.
The general guideline — accept the most abstract type you can, return the most concrete type that is convenient — is the typing form of the robustness principle, and it is why Iterable appears far more often on parameters than on return annotations.
The single-pass exhaustion gotcha
The most expensive mistake is annotating a one-shot stream as Iterable[T] and re-iterating it. Type checkers cannot catch this — an Iterator is an Iterable, so passing one wherever an Iterable is expected is perfectly valid typing. The second loop just runs zero times.
# Python 3.9+, mypy 1.10 — type-checks clean, still a bug
from collections.abc import Iterable
def summarize(values: Iterable[int]) -> tuple[int, int]:
total = sum(values) # first pass consumes a generator argument
count = sum(1 for _ in values) # second pass: empty if values was an Iterator
return total, count
summarize(x * x for x in range(5)) # count comes back 0
Iterable[int]. The emptiness on the second pass is a runtime property of single-use iterators that the type system does not model. If a function must iterate its argument twice, materialize it first (data = list(values)) or annotate the parameter as a re-iterable Sequence[int] so generator callers are at least a deliberate choice.
The trap is wide because so much of the standard library returns single-pass iterators rather than re-iterable containers. zip, map, filter, enumerate, reversed, a generator expression, an open file object, and csv.reader are all Iterators: each is exhausted after one traversal, yet every one of them satisfies Iterable[T]. On Python 3 these builtins became lazy — they were eager, list-returning functions in Python 2 — which is exactly why the annotation and the runtime behaviour can drift apart.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable
def report(rows: Iterable[str]) -> None:
print("count:", sum(1 for _ in rows)) # pass one consumes an Iterator
for row in rows: # pass two: empty for an Iterator
print(row)
report(map(str.upper, ["a", "b"])) # type-checks; second loop prints nothing
If a function must traverse its argument more than once, remove the ambiguity at the boundary. Materialize eagerly with rows = list(rows), or duplicate a lazy iterator with itertools.tee(it, n) when buffering the whole thing is unacceptable — note that tee still buffers whatever the slower branch has not consumed, so it is not free. Better still, narrow the annotation: requiring Sequence[T] instead of Iterable[T] turns a generator caller into a deliberate list(...) at the call site rather than a silent zero-length second pass.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Iterable
def mean(values: Iterable[float]) -> float:
data = list(values) # materialize once; now safe to reuse
return sum(data) / len(data) # two passes over a concrete list
There is no type that means “any iterable except a single-use one”: re-iterability is a runtime property the static system deliberately does not model. Neither mypy nor pyright will ever flag double iteration, so this remains a code-review-and-test concern rather than a type error.
Generator[Y, S, R] when send and return matter
Use the full Generator[YieldType, SendType, ReturnType] only when the extra channels are real. Y is what yield produces, S is what send() injects, and R is the value in the generator’s return. When you never call send() and never use the return value, Iterator[T] is the honest, shorter annotation.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Generator
def running_average() -> Generator[float, float, None]:
total = 0.0
count = 0
value = yield 0.0
while True:
total += value
count += 1
value = yield total / count # receives the next value via send()
avg = running_average()
next(avg) # prime the coroutine
avg.send(10.0) # 10.0
avg.send(20.0) # 15.0
For a plain producer with no send(), prefer Iterator[float]; mypy and pyright both accept a generator function annotated that way. Reserve Generator for coroutine-style two-way flow — the send()/throw() machinery that PEP 342 originally added to make generators usable as coroutines.
The three parameters are order-sensitive and easy to misremember, so anchor them to the syntax: the expression value = yield item puts item on channel Y and binds value from channel S, while a bare return result sets channel R. That R value is not thrown away — it surfaces as the argument of the StopIteration a for loop silently swallows, and it is exactly what yield from subgen evaluates to, which is why R is worth typing in delegating generators.
# Python 3.9+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import Generator
def inner() -> Generator[int, None, str]:
yield 1
yield 2
return "done" # this string is channel R
def outer() -> Generator[int, None, None]:
result = yield from inner() # result: str — the return value of inner
print(result) # "done"
Two version notes matter. First, prefer collections.abc.Generator, subscriptable since Python 3.9 under PEP 585; typing.Generator still works but is a deprecated alias that ruff flags as UP035, and on 3.8 you must import it from typing or typing_extensions. Second, PEP 696 type-parameter defaults arrived in Python 3.13, giving Generator’s SendType and ReturnType a default of None: Generator[int] is legal on 3.13+ and means Generator[int, None, None]. On 3.12 and earlier you must spell all three, and mypy reports "Generator" expects 3 type arguments, got 1 [type-arg] (pyright: reportInvalidTypeArguments) if you do not.
A related everyday case is contextlib.contextmanager: a @contextmanager function is typed as returning Generator[T, None, None], or the convenience alias Iterator[T], because it yields exactly once and neither sends nor returns a meaningful value. Reaching for the full Generator there over Iterator buys nothing.
The async trio mirror these exactly
Async code has a parallel set in collections.abc: AsyncIterable[T], AsyncIterator[T], and AsyncGenerator[Y, S]. The same decision applies — annotate an async def that only yields as returning AsyncIterator[T], and reserve AsyncGenerator[Y, S] for when asend() is used. Note AsyncGenerator takes only two parameters, not three, because an async generator cannot return a value.
# Python 3.10+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import AsyncIterator
async def stream_events(source: AsyncIterator[bytes]) -> AsyncIterator[str]:
async for chunk in source: # consumes with async for
yield chunk.decode() # async generator → AsyncIterator[str]
Mixing the sync and async families is a frequent slip: annotating an async def generator with plain Iterator[T] makes mypy report [misc] because the function returns an AsyncGenerator, not an Iterator.
The runtime protocol is the async mirror of PEP 234: async for calls __aiter__ to get an AsyncIterator, then awaits __anext__ until it raises StopAsyncIteration (not StopIteration). Async generators arrived in Python 3.6 via PEP 525; they support asend, athrow, and aclose, but a return value inside one is a SyntaxError, which is the concrete reason AsyncGenerator has no ReturnType slot. On Python 3.13, PEP 696 defaults let you write AsyncGenerator[int] for AsyncGenerator[int, None]; earlier versions require both arguments.
# Python 3.10+, checked with mypy 1.10 / pyright 1.1.370
from collections.abc import AsyncIterator
async def tail(source: AsyncIterator[str]) -> AsyncIterator[str]:
async for line in source: # consumes with async for
yield line.rstrip() # async generator → AsyncIterator[str]
The single most common async slip is a family mismatch — annotating an async def generator with a sync type. Because the body contains yield, Python makes the function an async generator regardless of the annotation, so mypy reports The return type of an async generator function should be "AsyncGenerator" or one of its supertypes [misc]; pyright issues reportGeneralTypeIssues. The reverse — a plain def generator annotated AsyncIterator[T] — fails for the same structural reason. Mirror the context-manager helper too: contextlib.asynccontextmanager wraps an AsyncGenerator[T, None], the async analogue of the Iterator[T]/Generator pairing above.
# Python 3.10+, mypy 1.10 — the mismatch mypy rejects
from collections.abc import Iterator
async def broken() -> Iterator[str]: # body yields, so it is an async generator
yield "x"
# mypy: error: The return type of an async generator function should be
# "AsyncGenerator" or one of its supertypes [misc]
Common mistakes
Almost every mistake here collapses into one question — what does the caller actually do with the value? — so the fixes follow a short decision tree from “only loops” up to “needs send or the return value.”
- Annotating a producer
Generator[T, None, None]out of habit. Verbose and over-specified; mypy acceptsIterator[T]for any generator that only yields. Simplify unlesssend/returnare used. - Returning
Iterator[T]but expecting callers to loop twice. The second loop is empty at runtime; no analyzer error. Returnlist[T]orSequence[T]when re-iteration is part of the contract. - Importing from
typing.from typing import Iteratortriggers ruffUP035on 3.9+; import fromcollections.abc. - Yielding a value from a function typed
-> Iterable[T]and then calling.send()on it.Iterablehas nosend; mypy reports[attr-defined]. UseGeneratorwhensendis needed. - Writing
Generator[int]on Python ≤ 3.12. The one-argument form only exists from 3.13 via PEP 696 defaults; earlier mypy reports[type-arg]. Spell all three parameters, or drop toIterator[int].
FAQ
Is every generator an Iterator?
Yes. A generator function returns a generator object that implements __iter__ and __next__, so Iterator[T] is always a valid return annotation for one — and usually the clearest.
What does the S in Generator[Y, S, R] do?
It types the value that generator.send(value) injects, which appears as the result of the yield expression inside the generator. If you never call send(), set it to None or drop down to Iterator[Y].