Iterators, Generators & the Dunder Protocols

Python has no interfaces — a syntax works on your object when the object supplies the right dunder. Desugar a for-loop into iter/next/StopIteration, watch a generator freeze mid-frame on yield, and tour the protocol family behind in, len, bool, with, and ==. Computed and animated.

Concept · Languages & Runtimes. The source ↗

A free, interactive, animated visual explainer of Iterators, Generators & the Dunder Protocols — built to be understood, not skimmed.

Questions

What is the difference between an iterable and an iterator in Python?
An iterable is anything you can loop over — it supplies an __iter__() method (or an old-style __getitem__()) that hands back a fresh iterator. An iterator is the one-pass cursor: it has a __next__() method that returns the next item or raises StopIteration, and its own __iter__() returns itself. The practical consequence is reuse: a container "produces a fresh new iterator each time you pass it to the iter() function or use it in a for loop," whereas "attempting this with an iterator will just return the same exhausted iterator object used in the previous iteration pass, making it appear like an empty container."
What does yield do in Python?
A yield expression turns an ordinary function into a generator function: calling it runs no code, it just returns a generator (an iterator). "Each yield temporarily suspends processing, remembering the execution state (including local variables and pending try-statements). When the generator iterator resumes, it picks up where it left off (in contrast to functions which start fresh on every invocation)." So a generator is a function whose frame you can pause and resume, producing one value per yield lazily.
What happens if you iterate a generator twice?
The second loop sees nothing. A generator is a single-pass iterator, not a re-iterable container: the first loop drives it to StopIteration and the frame is gone. The data model requires that "once an iterator’s __next__() method raises StopIteration, it must continue to do so on subsequent calls," so the second for-loop gets StopIteration immediately and runs zero times. To iterate twice, either rebuild the generator (call the function again) or materialise it into a list.
Why must __eq__ and __hash__ be defined together?
Because hash-based collections rely on the invariant that objects which compare equal have the same hash. If you override __eq__ to compare by value but leave __hash__ alone, Python protects that invariant by making your objects unhashable: "if it defines __eq__() but not __hash__(), its instances will not be usable as items in hashable collections" — so they cannot be dict keys or set members. Define __hash__ to match your __eq__ (hashing the same fields), and keep those fields immutable.
What is the difference between duck typing and typing.Protocol?
Duck typing is the runtime style: "the method or attribute is simply called or used" without checking the type — if it has close(), you call close(). typing.Protocol makes that same structural expectation checkable by a static type checker: a function annotated with a Protocol accepts any object that structurally has the required methods, "recognized by static type checkers that recognize structural subtyping (static duck-typing)." Decorate the Protocol with @runtime_checkable and isinstance() works too, though it "will check only the presence of the required methods or attributes, not their type signatures."

Related explainers