DEV Community

Cover image for The Complete Guide to Python's for Loop Internals
Ameer Abdullah
Ameer Abdullah

Posted on

The Complete Guide to Python's for Loop Internals

Every Python for loop you have ever written relies on a two-step protocol that most developers never see because Python hides it behind clean syntax.

Understanding the protocol turns for loops from black boxes into predictable, traceable mechanisms.


What Python Does With Every For Loop

for item in collection:
    process(item)
Enter fullscreen mode Exit fullscreen mode

Python expands this to:

iterator = iter(collection)
while True:
    try:
        item = next(iterator)
        process(item)
    except StopIteration:
        break
Enter fullscreen mode Exit fullscreen mode

iter() calls collection.__iter__() and returns an iterator object.
next() calls iterator.__next__() and returns the next value.
When the iterator is exhausted, __next__ raises StopIteration and the loop ends.

This is the iteration protocol. Any object that implements __iter__ and __next__ can be iterated.


Why This Matters for Tracing

a = [1, 2, 3]
it = iter(a)

print(next(it))  # 1
print(next(it))  # 2

for item in it:
    print(item)
Enter fullscreen mode Exit fullscreen mode

Output:

1
2
3
Enter fullscreen mode Exit fullscreen mode

The iterator is stateful. After calling next(it) twice manually, the iterator has consumed the first two values. The for loop continues from where the iterator left off and only sees 3.

This is why a generator, once partially consumed, resumes from its current position when you iterate it again.


enumerate: What It Actually Does

colors = ["red", "green", "blue"]
for i, color in enumerate(colors, start=1):
    print(i, color)
Enter fullscreen mode Exit fullscreen mode

Output:

1 red
2 green
3 blue
Enter fullscreen mode Exit fullscreen mode

enumerate(colors, start=1) creates an enumerate object that wraps the original iterator. Each call to next() on the enumerate object calls next() on the inner iterator and returns a tuple of (current_count, value).

The tuple (i, color) in the for loop uses tuple unpacking to assign both values simultaneously.


zip: Combining Two Iterators

names = ["Alice", "Bob", "Carol"]
scores = [85, 92, 78, 95]

for name, score in zip(names, scores):
    print(f"{name}: {score}")
Enter fullscreen mode Exit fullscreen mode

Output:

Alice: 85
Bob: 92
Carol: 78
Enter fullscreen mode Exit fullscreen mode

zip stops when the shortest iterator is exhausted. Carol's 78 is included but the fourth score 95 is dropped because there is no fourth name.

zip_longest from itertools fills with a default value instead of stopping.


The Interview Problem: Custom Iterator

class CountDown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

for n in CountDown(3):
    print(n)
Enter fullscreen mode Exit fullscreen mode

Trace the execution:

iter(CountDown(3)) returns the object itself because __iter__ returns self.
First next(): current is 3, returns 3, current becomes 2.
Second next(): current is 2, returns 2, current becomes 1.
Third next(): current is 1, returns 1, current becomes 0.
Fourth next(): current is 0, raises StopIteration.

Output:

3
2
1
Enter fullscreen mode Exit fullscreen mode

The Exhaustion Problem

numbers = [1, 2, 3, 4, 5]
evens = filter(lambda x: x % 2 == 0, numbers)

print(list(evens))   # [2, 4]
print(list(evens))   # []
Enter fullscreen mode Exit fullscreen mode

filter() returns a lazy iterator, not a list. After the first list(evens) call consumes all its values, the iterator is exhausted. The second call finds nothing remaining.

This surprises developers who treat filter, map, and zip objects as reusable sequences. They are single-pass iterators.

Practice iteration protocol tracing at pycodeit.com.


Top comments (0)