šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Python Iterators & the Iterator Protocol

Implement __iter__ and __next__ by hand to fully understand the protocol that powers every for loop, generator, and comprehension in Python.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

What must a class implement to satisfy the iterator protocol (not just be iterable, but be its own iterator)?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

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.current
localhost:3000
Protocol Check
hasattr([1,2,3], '__next__')
False — 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 3
localhost:3000
Manual Iteration
next(it), next(it), next(it)
1, 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 does
localhost:3000
Protocol Reuse
for, sorted(), zip(), *unpacking
All 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

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

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

THE BUG

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.

THE FIX

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)

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Implementing __next__ but forgetting to raise StopIteration when exhausted, causing an infinite loop in any for loop or list() call over the object.

# Wrong: infinite loop — no StopIteration ever raised class BadCounter: def __init__(self, n): self.n = n; self.current = 0 def __iter__(self): return self def __next__(self): self.current += 1 return self.current # never stops! # Correct class Counter: def __init__(self, n): self.n = n; self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration self.current += 1 return self.current

The Solution //

Always add an explicit exhaustion check at the top of __next__ that raises StopIteration once there is nothing left to produce, mirroring what a generator's falling off the end of the function does automatically.

Lesson Glossary

[01]Iterable

An object implementing __iter__, which returns an iterator; can be passed to iter() and used in a for loop.

Code Preview
// Iterable context

[02]Iterator

An object implementing both __iter__ (returning itself) and __next__, which produces successive values and raises StopIteration when exhausted.

Code Preview
// Iterator context

[03]Iterator protocol

The informal contract (__iter__ + __next__ + StopIteration) that for loops, comprehensions, and many built-ins rely on to consume any conforming object.

Code Preview
// Iterator protocol context

[04]list_iterator

The separate iterator object returned by iter() on a list, which holds the current iteration position independently of the list itself.

Code Preview
// list_iterator context

Continue Learning