PYTHON / LOOPS
for loops and iterables
Explain how a for loop drives the iterator protocol, and predict which objects can be looped over twice and which are spent after one pass.
What you will learn
- Describe the iter()/next()/StopIteration sequence a for loop performs internally
- Tell a re-iterable iterable apart from a single-use iterator
- Use iter() and next() by hand to see where an iterator is positioned
- Make your own class loopable by defining __iter__
Understanding for loops and iterables
A for loop in Python does not count indices. It calls iter(obj) once to get an iterator, then calls next() on that iterator repeatedly, binding each returned value to the loop name and running the body. When the iterator raises StopIteration, the for statement catches that exception and exits normally. This is why lists, strings, sets, dicts, open files and generators all work in the same for statement: none of them are indexed by the loop, they just have to answer iter() and next().
That protocol splits objects into two groups that behave very differently. A list is an iterable: iter(list) builds a brand new cursor each time, so you can loop over the same list as often as you like. A generator, a map or filter object, or an open file is already an iterator, and its __iter__ returns itself, so a second loop resumes where the first one stopped, which is at the end. Nothing raises an error in that case; the body simply never runs, which is what makes the bug hard to see.
The useful mental model is a cursor over a source rather than a copy of the source. The loop name is a fresh binding to each object the iterator hands out, so assigning to it inside the body changes only the binding and never the underlying container. For the same reason, inserting or deleting items in the object you are currently iterating moves data out from under the cursor and produces skipped or repeated elements. The loop name also outlives the loop, still holding whatever value came last.
colors = ["red", "green", "blue"]
it = iter(colors)
print(type(it).__name__)
print(next(it))
print(next(it))
for remaining in it:
print("loop got", remaining)
for c in colors:
print("again", c)
A for loop is sugar for calling iter() once and next() until StopIteration, so re-iterability depends entirely on what iter() returns.
Worked examples
One-shot iterator versus reusable iterable
Shows that a generator is consumed by the first pass while the list behind it can be read again.
squares = (n * n for n in [1, 2, 3])
print(sum(squares))
print(sum(squares))
nums = [1, 2, 3]
print(sum(nums))
print(sum(nums))
Example explained
Line 1sum() runs a for loop internally, so the first call drains the generator to 1 + 4 + 9.
Line 2The second sum() gets the same exhausted generator back from iter() and adds nothing, giving 0 instead of an error.
Line 3nums is an iterable, not an iterator, so each sum() starts from a fresh cursor and both print 6.
Line 4Wrapping the generator with list(...) once would give you a reusable object.
What counts as iterable
Demonstrates that strings iterate character by character while an int cannot be iterated at all.
for ch in "hey":
print(ch)
try:
for x in 42:
print(x)
except TypeError as e:
print("TypeError:", e)
Example explained
Line 1str defines __iter__, and each next() yields a one-character string, not a code point number.
Line 2int has no __iter__, so iter(42) fails before the loop body ever runs.
Line 3The error comes from the iter() call at loop setup, which is why the body is never entered.
Line 4Catching TypeError here proves the failure is a protocol lookup, not a value problem.
Making your own class loopable
Defines __iter__ by delegating to an inner list so the object can be iterated repeatedly.
class Hand:
def __init__(self, cards):
self.cards = cards
def __iter__(self):
return iter(self.cards)
hand = Hand(["A", "K", "Q"])
for card in hand:
print(card)
print(list(hand))
print("K" in hand)
Example explained
Line 1__iter__ returns a new list_iterator on every call, so Hand is re-iterable rather than one-shot.
Line 2list(hand) works for free because list() consumes the same protocol the for loop uses.
Line 3The in operator falls back to iterating when no __contains__ is defined, so "K" in hand is True.
Line 4Returning self without a next() method would raise TypeError: iter() returned non-iterator.
Important notes
The loop name survives the loop and keeps the last value; if the iterable was empty the name was never bound, so using it afterwards raises NameError.
A string is iterable, so for ch in name when you meant a list of names runs happily one character at a time instead of failing.
Common mistakes
Deleting items from a list while looping over it: the cursor position shifts past the shortened list and elements are silently skipped.
Writing for x in nums: x = x * 2 and expecting nums to change; only the loop name is rebound, and nums is untouched.
Looping a second time over a generator, map or file object and getting an empty body with no exception to explain it.
Try it yourself
Change, predict, then run
Build a list of three words, loop over it printing each word and its length, then create it = iter(words), consume one item with next(it), and loop over it to confirm only two items are left.
Open the Python workspaceCheck your understanding
A function returns a generator. Code loops over the result, then loops over the same variable again; the second loop prints nothing and raises no error. What explains this?
- Generators cache their results, so the second loop reads the cache instead of the values
- The first loop deleted the elements from the underlying data
- The generator is its own iterator and was exhausted, so iter() hands back the same spent iterator with nothing left to yield
- The second for loop needs an explicit iter() call to restart the generator
Show answer
A generator's __iter__ returns self, so the second for loop resumes the same exhausted iterator, which raises StopIteration immediately and the body never runs. Option 4 is tempting because for really does call iter(), but that call is already happening and returns the same spent object; only re-creating the generator or storing list(...) gives you a second pass.