Generators are the easy way to create an iterator, but they're built on a simpler, more explicit protocol: any object with __iter__ and __next__ methods is an iterator. Understanding that protocol directly ā not just through yield ā lets you build custom iterable classes and correctly reason about exactly what 'iterable' means in Python.
1Two Related but Distinct Protocols: Iterable and Iterator
It's easy to conflate 'iterable' and 'iterator' because both words describe things you can loop over, but Python defines them as two distinct, related protocols. An iterable is any object with an __iter__ method that returns an iterator. An iterator is any object with both __iter__ (which, by convention, returns itself) *and* __next__, which produces the next value or raises StopIteration when exhausted.
A list is the clearest example of the distinction: [1, 2, 3] is iterable ā calling iter([1, 2, 3]) succeeds and returns a list_iterator object ā but the list itself has no __next__ method (hasattr([1,2,3], '__next__') is False). The list *delegates* the actual step-by-step iteration state (which position we're currently at) to a separate, freshly-created iterator object each time iter() is called on it.
This separation is exactly why you can nest two independent for loops over the same list (for a in numbers: for b in numbers:) without them interfering ā each for statement calls iter(numbers) independently, getting its own fresh list_iterator with its own independent position counter. A generator object, by contrast, is both iterable and its own iterator (__iter__ returns self), which is exactly why it has no independent position to reset ā once exhausted, it's exhausted for every reference to that same object.
class CountUpTo:
def __init__(self, n: int):
self.n = n
self.current = 0
def __iter__(self):
return self # the iterator returns itself
def __next__(self):
if self.current >= self.n:
raise StopIteration
self.current += 1
return self.currentFalse ā list is iterable, not an iterator itself
2Implementing the Protocol by Hand
CountUpTo demonstrates the full protocol explicitly: __iter__ returns self, satisfying the requirement that an iterator be iterable, and __next__ holds the actual state (self.current) and the logic for producing the next value or signaling exhaustion via raise StopIteration. This is precisely what a generator function does implicitly ā yield is syntax that generates an object implementing this exact protocol for you, managing the paused execution state automatically instead of requiring you to track it in explicit instance attributes like self.current.
Writing the protocol by hand, at least once, clarifies exactly what a for loop is doing: for value in CountUpTo(3): calls iter(CountUpTo(3)) (which, per the class's __iter__, returns the same object), then calls next() on that object repeatedly, binding each result to value, until StopIteration is raised ā at which point the loop exits cleanly.
The main reason to hand-write an iterator instead of a generator is when the object needs to expose more behavior than just iteration ā a custom iterable data structure (a tree, a linked list, a windowed buffer) might need methods like .reset() or properties reflecting its current state, none of which a plain generator object supports, since a generator's only real interface is __iter__/__next__/.send()/.close().
for value in CountUpTo(3):
print(value)
# 1
# 2
# 3
# Manually: iter(obj) then repeated next(obj)
counter = CountUpTo(3)
it = iter(counter)
print(next(it), next(it), next(it)) # 1 2 31, 2, 3
3Why This Protocol Underlies Nearly Everything in Python
Once you know the iterable/iterator distinction, a large swath of Python's design becomes legible as one consistent protocol reused everywhere: for loops, list/dict/set comprehensions, * unpacking, zip(), enumerate(), sum(), sorted(), tuple assignment (a, b, c = some_iterable), and the in operator on non-mapping containers all work by calling iter() on their argument and consuming it via next() until StopIteration. Any object that implements __iter__ correctly automatically works with all of these, for free, without special-casing.
This is also the practical reason custom classes should implement __iter__ rather than, say, an ad-hoc .get_next_item() method: doing so makes the object a first-class citizen of every language construct that expects an iterable, rather than requiring users of your class to learn a bespoke, non-standard API.
Understanding the protocol also demystifies a specific gotcha that trips up even experienced developers: an exhausted iterator (or generator) doesn't raise an error when you try to iterate it a second time in a for loop ā it simply produces zero iterations silently, since the very first next() call immediately raises StopIteration, which the for loop catches and interprets as 'nothing to do', not as a failure worth reporting.
numbers = [1, 2, 3]
print(hasattr(numbers, '__next__')) # False ā a list has no __next__
it = iter(numbers)
print(hasattr(it, '__next__')) # True ā the LIST ITERATOR doesAll consume any object implementing __iter__/__next__
4Step-by-Step Breakdown
Every for loop in Python is calling two dunder methods behind the scenes. Let's implement them ourselves and see exactly what's happening.
An iterable is anything with __iter__, which must return an iterator. An iterator is anything with both __iter__ (returning itself) and __next__.
Checkpoint: What must a class implement to satisfy the iterator protocol (not just be iterable, but be its own iterator)?
- āBoth __iter__ (returning self) and __next__
- āOnly __next__ ā __iter__ is optional
Once both dunders are implemented, the object works in a for loop exactly like a list or a generator does.
A subtle but important distinction: an iterable and an iterator aren't always the same object. A list is iterable but is NOT itself an iterator.
Checkpoint: Why does hasattr([1,2,3], "__next__") return False even though a list works fine in a for loop?
- āA list is iterable (has __iter__) but is not itself an iterator (no __next__)
- āThis is a bug in the list implementation
That's why you can have two independent loops over the same list running at once, but not two independent loops over the same generator.
With the iteration protocol explicit, context managers show a similar pattern ā dunder methods that let your objects hook into a Python control-flow statement, this time 'with'.
Implement a Real Iterator Protocol. Finish Countdown.__next__(): raise StopIteration exactly once the sequence is exhausted.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Prefer yield-based generators over hand-written iterator classes when possible
A generator function is shorter, less error-prone (no manual state tracking, no risk of forgetting StopIteration), and is functionally equivalent for the common case. Hand-write __iter__/__next__ only when the object needs additional methods or state beyond plain iteration.
Make __iter__ return a fresh iterator when you want an object to be safely re-iterable
If __iter__ returns self, the object becomes single-use, like a generator. If your class should support multiple independent loops (like a list does), have __iter__ return a new iterator object each time instead of self.
Frequent Bugs
Writing a custom iterator's __iter__ to return self, then being surprised that a second for loop over the same object instance produces no results because it was already exhausted by the first loop.
If re-iteration should be supported, separate the iterable (holds the data, __iter__ returns a NEW iterator instance each call) from the iterator (holds only the current position, has __next__), mirroring how list and list_iterator are separate objects.
Real-World Examples
A Re-Iterable Custom Collection
A custom Deck class representing a deck of cards should support being iterated over multiple times (e.g. once to display, once to validate), unlike a single-use generator.
class Deck:
def __init__(self, cards: list[str]):
self.cards = cards
def __iter__(self):
return iter(self.cards) # delegates to a fresh list_iterator each call
deck = Deck(["2H", "3H", "4H"])
for card in deck:
print(card)
for card in deck: # works again ā a NEW iterator was created
print(card)