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

Iterable, Iterator, and Generator capability ladder Iterable provides __iter__ and may be re-iterated; Iterator adds __next__ and is single-pass; Generator adds send, throw, and a return value. each level adds capability → Iterable[T] __iter__ loop; re-iterable Iterator[T] + __next__ single-pass Generator[Y,S,R] + send / throw + return R yields Y, two-way
Each level adds capability: Iterable loops, Iterator is single-pass, Generator adds two-way communication and a return value.

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.

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

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.

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
Runtime vs static analysis Both mypy and pyright accept the double iteration above without complaint, because a generator satisfies 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.

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

Common mistakes

  • Annotating a producer Generator[T, None, None] out of habit. Verbose and over-specified; mypy accepts Iterator[T] for any generator that only yields. Simplify unless send/return are used.
  • Returning Iterator[T] but expecting callers to loop twice. The second loop is empty at runtime; no analyzer error. Return list[T] or Sequence[T] when re-iteration is part of the contract.
  • Importing from typing. from typing import Iterator triggers ruff UP035 on 3.9+; import from collections.abc.
  • Yielding a value from a function typed -> Iterable[T] and then calling .send() on it. Iterable has no send; mypy reports [attr-defined]. Use Generator when send is needed.

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

Back to Typing Collections and collections.abc