What `for x in y` hides from you

What a Python `for` loop is really doing once you look under the hood.

Starting a for loop in Python is easy. You just type for x in y and you're off.

You iterate over a list, or a string, or a range, and Python politely hands you back one item at a time. No index variable and no bounds checks. Compared to i++ from C/C++ or forEach from JavaScript, Python's version just works.

For a long time, I treated for x in y as just a syntax that meant "loop over this thing," and that was enough. Then I started building Memphis, my Python interpreter in Rust, and eventually I had to stop hand-waving and answer an exposing question:

What is a for loop actually doing?

The answer is both simpler and more involved than I expected.

The illusion

Let’s start with a tiny example.

for x in [10, 20, 30]:
print(x)

Run it and you get the familiar behavior: one value at a time, in order.

So far, no real surprises.

If you’re starting out with Python, it’s easy to come away with a mental model like this:

  • Python peers into the list
  • it walks through the elements one at a time
  • it assigns each one to x
  • then it runs the body of the loop

That’s not behaviorally wrong, but it hides the most important part:

Python is not looping over the collection directly. It is looping over an iterator.

That distinction matters because it explains why for works on so many different kinds of objects, and why iteration in Python feels so flexible.

The hidden step

When Python sees this:

for x in [10, 20, 30]:
print(x)

The real story is closer to something like this:

it = iter([10, 20, 30])

while True:
try:
x = next(it)
print(x)
except StopIteration:
break

A for loop is really:

  1. ask for an iterator by calling iter(...)
  2. repeatedly ask for the next value by calling next(...)
  3. stop when next(...) raises StopIteration

The for x in y syntax is just a pretty wrapper.

You can see it directly

We can prove this to ourselves without even touching interpreter internals yet.

nums = [10, 20, 30]
it = iter(nums)

print(next(it))
print(next(it))
print(next(it))

This prints the list elements one at a time.

But if we push one step too far:

nums = [10, 20, 30]
it = iter(nums)

print(next(it))
print(next(it))
print(next(it))
print(next(it))

Run that second snippet too. The last next(it) raises StopIteration, which is exactly how the loop knows it is done.

So the loop is not "reading from the list" in some special one-off way. It is using the same iterator protocol you can use yourself.

Why this matters

This one idea explains why all of these work:

for x in [1, 2, 3]:
print(x)

for ch in "cat":
print(ch)

for n in range(3):
print(n)

Lists, strings, and range are different kinds of objects, yet Python can loop over all of them because each one can produce an iterator.

It also explains why generators work so naturally with for: they are another Python type that participates in the same protocol. We'll see an example of that in a minute.

The part I had to implement

In Memphis, I had to decide what the runtime would actually do.

At a high level, my treewalk interpreter handles a for loop by:

  1. evaluating the expression on the right-hand side
  2. calling iter(...) on the result
  3. repeatedly calling next(...)
  4. binding each returned value to the loop variable
  5. stopping when StopIteration is raised

That sounds almost boring when written out, which I mean as a credit to the Python specification.

The surprise for me was not that for uses iteration. It was how little the loop itself knows.

The for loop does not need special logic for lists, tuples, ranges, strings, generators, or anything else. It doesn't need to know the number of iterations. It just needs the iterator protocol, and the specific object handles the details.

In full Python, this goes one step farther: custom objects can participate too by implementing __iter__() and __next__(). I’m still working on that part in Memphis, but it’s one of the neatest consequences of this design.

Using an iterator twice doesn't work

Consider this:

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

for x in it:
print(x)

for x in it:
print("again:", x)

Try running it yourself. The second loop prints nothing.

That’s because the iterator was already exhausted by the first loop. The for loop didn’t "rewind" anything. It just kept calling next(...) until there was nothing left.

If you want to make the distinction more concrete, inspect the types too:

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

print(type(items))
print(type(it))

This is a subtle but important distinction:

  • an iterable can usually give you a fresh iterator (list, str, range, etc)
  • an iterator is usually a one-way trip

A generator function can create a fresh generator each time you call it, but a generator object itself is already an iterator.

That difference hides behind the word in: it initializes a new iterator for you, unless you already gave it one.

There’s another useful variation here too:

pairs = [(1, 10), (2, 20), (3, 30)]

for x, y in pairs:
print(x, y)

This can look like a different kind of loop, but it really isn’t. The iteration part is the same: Python still asks for an iterator and pulls one value at a time. It’s just that each value happens to be a two-item tuple, and Python then unpacks that tuple into x and y.

So for x, y in z isn't a special flavor, it's ordinary iteration plus unpacking.

Generators help make this visible

If you want to make this stick, it helps to play with a generator.

def countdown():
print("Starting")
yield 3
yield 2
yield 1
print("Done")

for x in countdown():
print("Got", x)

Run it and watch the order carefully. What the for loop is hiding here is more than just repetition.

Each call to next(...) resumes the generator, runs it until the next yield, hands that value back to the loop, and pauses again.

Once I started thinking of iteration this way, for loops stopped feeling flat. They became a protocol between the loop and some other object that knows how to produce values over time.

That is a much more accurate mental model.

And that, more and more, is what I enjoy about building Memphis. Python has all these smooth surfaces that make it pleasant to use. But when I reimplement one of them from scratch, I get to see the levers underneath.

In this case, it was just this surprisingly small mechanism:

  • iter(...)
  • next(...)
  • StopIteration

The end

The nice part of Python’s loop syntax is that it lets beginners be productive before they understand any of this. That’s a powerful abstraction.

But once you hit generators, custom iterables, or bugs involving exhausted iterators, the nicer syntax can become a liability if you never learned what it was hiding.

The phrase I hope to leave you with is this:

for x in y does not mean "loop over y." It means "ask y how to be iterated."

Hey, I'm Tyler

Tyler Green

I help engineers move beyond "it works" and actually understand the systems they’re building.

I’ve logged over 300 hours of 1:1 mentorship in Rust and Python, and I care about helping people feel more at home in their code.

Follow along as I build