Iterator Protocol
An iterator implements __iter__() (returns self) and __next__() (returns next item or raises StopIteration).
Iterator protocol = a TV remote with two buttons: __iter__ (turn on) and __next__ (next channel). StopIteration = you hit the last channel. A list is a DVR (rewind anytime); a generator is live TV (one pass only).
An iterable implements __iter__() that returns an iterator (not necessarily self). Lists, dicts, sets are iterables — each call to iter() returns a fresh iterator. The for loop calls iter() then next() in a loop until StopIteration. Generators implement the iterator protocol automatically. iter(obj) can also take a callable and sentinel: iter(f, sentinel) calls f() until it returns sentinel.
Iterables and iterators are distinct: a list is iterable (can produce multiple independent iterators), while a generator is an iterator (single-pass, position is inside the object). This distinction matters for algorithms that need to restart iteration. Implementing a lazy sequence: __iter__ returns self, __next__ computes the next value on demand. The itertools module provides composable iterator tools: chain, islice, product, groupby — all lazy (no memory allocation). StopIteration bubbling inside a generator body is a subtle bug — in Python 3.7+, it's converted to RuntimeError.
The iterator protocol is __iter__ + __next__. Iterables have __iter__ that returns an iterator — they can produce multiple independent iterations. Iterators are single-pass (hold position state). Generators implement the protocol automatically. This protocol powers for loops, list comprehensions, unpacking, and itertools. When building a custom collection, implement __iter__ to return an independent iterator so multiple independent loops can traverse it simultaneously.
A generator is both an iterable AND an iterator — iter(gen) returns gen itself. This means you can't restart a generator by calling iter() on it again — you get the same exhausted object.