Iterators and the Iteration Protocol

Python’s for loop works on any iterable โ€” lists, tuples, strings, dicts, files, database cursors, and custom objects. The magic that makes this possible is the iteration protocol: two dunder methods, __iter__ and __next__, that any object can implement to become iterable. Understanding the protocol explains why generators, itertools, and database cursor objects all work seamlessly in for loops โ€” and gives you the knowledge to create your own iterable data structures for FastAPI pagination, streaming, and custom query result types.

Iterables vs Iterators

# An ITERABLE has __iter__() โ€” returns an iterator
# An ITERATOR has __next__() โ€” returns the next value (and __iter__ returning self)

# Lists are iterables (not iterators)
numbers = [1, 2, 3]
iter_obj = iter(numbers)       # calls numbers.__iter__()
print(type(iter_obj))          # <class 'list_iterator'>

# Call next() to get each value
print(next(iter_obj))   # 1  โ€” calls iter_obj.__next__()
print(next(iter_obj))   # 2
print(next(iter_obj))   # 3
try:
    print(next(iter_obj))   # StopIteration โ€” signals exhaustion
except StopIteration:
    print("Iterator exhausted")

# A for loop does this automatically:
for n in [1, 2, 3]:
    print(n)
# Equivalent to:
_iter = iter([1, 2, 3])
while True:
    try:
        n = next(_iter)
        print(n)
    except StopIteration:
        break

# Iterators are single-use โ€” they cannot be rewound
it = iter([1, 2, 3])
list(it)   # [1, 2, 3]
list(it)   # [] โ€” already exhausted!

# Iterables can be iterated multiple times
lst = [1, 2, 3]
list(lst)   # [1, 2, 3]
list(lst)   # [1, 2, 3] โ€” still works
Note: The distinction between iterables and iterators matters in FastAPI. A list is an iterable โ€” you can iterate it multiple times and it is always “ready.” A generator (iterator) is single-use โ€” once exhausted, it returns nothing. If you pass a generator as a FastAPI response body, it will stream correctly on the first request but return empty on subsequent accesses to the same generator object. Always create a new generator per request.
Tip: Use iter() and next() directly when you need fine-grained control over iteration โ€” for example, peeking at the first item to check if a result set is empty before processing, or reading a CSV file’s header row separately from its data rows. first = next(iter(iterable), None) safely returns the first item or None if the iterable is empty, without raising StopIteration.
Warning: Never pass an exhausted iterator to a function expecting a non-empty sequence. After list(it) or iterating through a generator in a for loop, the iterator is exhausted โ€” len() returns 0, bool() returns False for generators (confusingly, they are not falsy until you try to iterate). Always convert to a list first if you need to iterate multiple times or check length: items = list(generator).

Implementing a Custom Iterator

class CountUp:
    """An iterator that counts from start to stop (inclusive)."""

    def __init__(self, start: int, stop: int):
        self.current = start
        self.stop    = stop

    def __iter__(self):
        """Return the iterator object (self, since we implement __next__)."""
        return self

    def __next__(self):
        """Return the next value or raise StopIteration."""
        if self.current > self.stop:
            raise StopIteration
        value = self.current
        self.current += 1
        return value

# Usage
counter = CountUp(1, 5)
for n in counter:
    print(n)   # 1, 2, 3, 4, 5

# Also works with sum(), list(), etc.
print(list(CountUp(1, 5)))   # [1, 2, 3, 4, 5]
print(sum(CountUp(1, 5)))    # 15

# โ”€โ”€ Iterable class (separate iterator state) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class NumberRange:
    """An ITERABLE (not iterator) โ€” can be iterated multiple times."""

    def __init__(self, start: int, stop: int):
        self.start = start
        self.stop  = stop

    def __iter__(self):
        """Return a NEW iterator each time โ€” allows multiple iterations."""
        return CountUp(self.start, self.stop)

r = NumberRange(1, 3)
print(list(r))   # [1, 2, 3]
print(list(r))   # [1, 2, 3] โ€” still works, __iter__ creates new CountUp each time

Built-in Iterables

# Everything iterable in Python implements __iter__
# Lists, tuples, sets, dicts, strings, files, ranges, generators...

# Check if something is iterable
from collections.abc import Iterable, Iterator

isinstance([1, 2, 3], Iterable)      # True
isinstance("hello",   Iterable)      # True
isinstance(42,        Iterable)      # False

isinstance(iter([1, 2, 3]), Iterator)  # True โ€” list_iterator IS an iterator
isinstance([1, 2, 3],       Iterator)  # False โ€” list is only an iterable

# Common patterns
first = next(iter(some_list), None)   # first item or None if empty
has_any = any(True for _ in iterable)  # True if any item exists
count = sum(1 for _ in iterable)        # count items without building a list

Common Mistakes

Mistake 1 โ€” Iterating an exhausted iterator a second time

โŒ Wrong โ€” second iteration gives nothing:

it = iter([1, 2, 3])
for x in it: print(x)   # 1, 2, 3
for x in it: print(x)   # nothing โ€” iterator exhausted!

โœ… Correct โ€” use the original iterable, or reset:

data = [1, 2, 3]
for x in data: print(x)   # 1, 2, 3
for x in data: print(x)   # 1, 2, 3 โœ“ โ€” iterable, not iterator

Mistake 2 โ€” Confusing iter() and next() error handling

โŒ Wrong โ€” no default, raises StopIteration on empty:

first = next(iter([]))   # StopIteration!

โœ… Correct โ€” provide a default:

first = next(iter([]), None)   # None โœ“ โ€” no exception

Mistake 3 โ€” Testing truthiness of a generator before iterating

โŒ Wrong โ€” generators are always truthy even if they yield nothing:

gen = (x for x in [])
if gen:   # True! Empty generators are still truthy objects
    print("has items")   # wrongly prints!

โœ… Correct โ€” use next() with a sentinel to check emptiness:

gen = (x for x in [])
first = next(gen, None)
if first is not None:
    # process first, then continue with gen
    pass

Quick Reference

Concept Protocol / Code
Make class iterable Implement __iter__ returning iterator
Make class an iterator Implement __iter__ + __next__
Get iterator it = iter(obj)
Get next value next(it) or next(it, default)
Check iterable isinstance(obj, Iterable)
First item safely next(iter(obj), None)
Exhaustion signal raise StopIteration in __next__

🧠 Test Yourself

What is the difference between an iterable and an iterator, and why does it matter for a for loop?