Python Iterators and Loops — for, while, Generators, and Beyond
Advertisement
Introduction
Why This Matters
Loops are everywhere in Python, but most developers use them without understanding what powers them under the hood. Python's iteration protocol — the __iter__ and __next__ methods — is what makes for loops work on lists, strings, files, dicts, and any custom object you create. Understanding this protocol unlocks the ability to build memory-efficient generators, lazy pipelines, and infinite sequences.
Generators, introduced in Python 2.2 and significantly enhanced since, are one of Python's most important tools for processing large data. They allow you to iterate over sequences that may be too large to fit in memory, process data streams, and create pipelines of transformations without building intermediate lists.
This knowledge is particularly valuable for data engineering, backend development, and any Python role where performance and scalability matter.
How for Loops Work
Python's for loop calls iter() on the object, then repeatedly calls next() until StopIteration.
numbers = [1, 2, 3]
# What Python does internally:
iterator = iter(numbers) # calls numbers.__iter__()
print(next(iterator)) # 1 — calls iterator.__next__()
print(next(iterator)) # 2
print(next(iterator)) # 3
# next(iterator) # raises StopIteration
# The for loop does all of this automatically:
for n in numbers:
print(n)The Iterator Protocol
An object is iterable if it implements __iter__. An iterator implements both __iter__ and __next__.
class CountUp:
"""Iterator that counts from start to stop."""
def __init__(self, start: int, stop: int):
self.current = start
self.stop = stop
def __iter__(self):
return self
def __next__(self) -> int:
if self.current >= self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
for num in CountUp(1, 6):
print(num) # 1 2 3 4 5Generators — The Simple Way
Generator functions use yield to lazily produce values.
def count_up(start: int, stop: int):
"""Generator version of CountUp."""
current = start
while current < stop:
yield current
current += 1
for num in count_up(1, 6):
print(num) # 1 2 3 4 5
# Generator expressions
squares = (x ** 2 for x in range(10)) # lazy, no list created
total = sum(x ** 2 for x in range(1_000_000)) # memory efficientInfinite Generators
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
# Use itertools.islice to take first N values
import itertools
first_10 = list(itertools.islice(fibonacci(), 10))
print(first_10) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]itertools — Essential Loop Utilities
import itertools
# chain: iterate multiple iterables as one
for item in itertools.chain([1, 2], [3, 4], [5]):
print(item) # 1 2 3 4 5
# islice: take first N from any iterable
for n in itertools.islice(fibonacci(), 5):
print(n) # 0 1 1 2 3
# enumerate with start
for i, val in enumerate(["a", "b", "c"], start=1):
print(i, val) # 1 a, 2 b, 3 c
# zip_longest
from itertools import zip_longest
for a, b in zip_longest([1, 2, 3], ["x", "y"], fillvalue="-"):
print(a, b) # 1 x, 2 y, 3 -
# groupby
data = sorted([{"dept": "eng", "name": "Alice"}, {"dept": "eng", "name": "Bob"}, {"dept": "hr", "name": "Carol"}], key=lambda x: x["dept"])
for dept, group in itertools.groupby(data, key=lambda x: x["dept"]):
print(dept, [p["name"] for p in group])Generator Pipelines
Generators can be chained into lazy data pipelines:
def read_lines(path: str):
with open(path) as f:
yield from f
def parse_ints(lines):
for line in lines:
stripped = line.strip()
if stripped.isdigit():
yield int(stripped)
def filter_large(nums, threshold: int):
for n in nums:
if n > threshold:
yield n
# Chain into a pipeline — no intermediate lists
pipeline = filter_large(parse_ints(read_lines("numbers.txt")), threshold=100)
for value in pipeline:
print(value)yield from
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item) # recursively yield from sub-list
else:
yield item
print(list(flatten([1, [2, [3, 4]], 5]))) # [1, 2, 3, 4, 5]Common Mistakes
- Modifying a list while iterating over it — causes skipped elements
- Consuming a generator twice — it is exhausted after the first iteration
- Using a list where a generator expression would suffice
- Forgetting that
zip()stops at the shortest iterable — usezip_longest()if needed - Not using
enumerate()and manually tracking an index variable
Best Practices
- Use generator expressions for memory-efficient transformations
- Use
yield fromto delegate to sub-generators cleanly - Prefer
itertoolsover hand-rolled loops for common iteration patterns - Iterate files line by line (
for line in f) instead off.readlines() - Use
any()andall()with generator expressions for short-circuit evaluation
Key Takeaways
- Python's
forloop uses the iterator protocol:__iter__()and__next__() - Any object implementing
__iter__and__next__is an iterator - Generator functions use
yieldto lazily produce values one at a time - Generator expressions
(expr for x in iterable)are memory-efficient alternatives to list comprehensions itertoolsprovides powerful tools:chain,islice,groupby,zip_longest,product- Generators are exhausted after one pass — store results in a list if you need to iterate again
yield fromdelegates iteration to another iterable, simplifying recursive generators
Advertisement