Chapter 18 of 24 · Python for Beginners — Complete Free Tutorial
Iterators and Generators
for has appeared in nearly every chapter of the Python for Beginners series, always treated as magic. It is not: loops run on two small methods you can write yourself. Chapter 18 of this 24-chapter series opens up the iteration protocol, then shows generators — the yield keyword that makes an iterator almost effortless — and the lazy pipelines that process huge files without loading them into memory.
The iteration protocol
Anything a for loop accepts is an iterator: an object with __iter__ (returning itself) and __next__ (one value per call, then a polite refusal). Here is a counter built from scratch:
class Counter:
def __init__(self, start, stop):
self.current = start
self.stop = stop
def __iter__(self):
return self
def __next__(self):
if self.current >= self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
for n in Counter(1, 4):
print(n)
Expected output:
1
2
3
The last value is never returned: when there is nothing left, __next__ raises StopIteration and the loop ends quietly. Returning a sentinel such as None would be wrong, because None can be a perfectly good value.
Desugaring the for loop
You have just written the two methods a loop needs, so it is worth seeing what the loop itself is. Python’s for is closer to JavaScript’s for...of than to a counted for(i = 0; ...): it asks for values until the object runs out, exactly as in this manual form.
it = iter(Counter(1, 4))
while True:
try:
n = next(it)
except StopIteration:
break
print(n)
Expected output:
1
2
3
Every for x in y is that block, written for you. The two built-ins are the moving parts: iter(obj) calls __iter__ and next(it) calls __next__, raising StopIteration when the values run out — next(iter([10, 20, 30])) on its own would reply 10. A class with only __next__ is not enough — a loop then refuses it with TypeError: 'Counter' object is not iterable.
Generators: yield instead of return
Hand-writing those methods is still tedious. A function containing yield is a generator function: calling it produces a generator object, and each yield hands one value to the caller and freezes the function’s state until it is asked again.
def countdown(start):
while start > 0:
yield start
start -= 1
gen = countdown(3)
print(next(gen))
print(list(gen))
print(list(gen))
Expected output:
3
[2, 1]
[]
Three lessons in three lines. Nothing happens when countdown(3) is called — the body runs only on demand (put a print() inside and nothing appears until you request a value). Execution resumes exactly where it paused, local variables intact. And a generator is one-shot: once exhausted it answers [] forever, so call it again when you need the values again.
Pipelines and lazy evaluation
Generators shine when composed: each stage takes an iterable and yields a transformed one, so a reader, a filter and an aggregator chain together without building a single intermediate list.
def first_line(text):
yield text.splitlines()[0]
def upper(lines):
for line in lines:
yield line.upper()
doc = "alpha\nbeta\ngamma"
print(list(upper(first_line(doc))))
Expected output: ['ALPHA']. This is the instinct behind mapping over an array in JavaScript, but nothing is computed early — which is the difference between processing a file and surviving it. Compare a generator with the equivalent materialised list:
import sys
def squares(limit):
for i in range(limit):
yield i * i
gen = squares(1_000_000) # one million values, nothing computed yet
values = list(range(100_000, 200_000))
print("generator object:", sys.getsizeof(gen), "bytes")
print("list plus integers:", sys.getsizeof(values) + sum(sys.getsizeof(v) for v in values), "bytes")
Expected output:
generator object: 200 bytes
list plus integers: 3600056 bytes
A generator object is a few hundred bytes whatever it will produce; the list pays for every element before you have seen one. Generator expressions — (x * 2 for x in items) — are lazy in the same way, where list comprehensions are eager. Materialise deliberately, when you need the values more than once or need them sorted.
yield from and infinite sequences
yield from delegates to a sub-generator, keeping composed generators readable: a function that does yield from inner() then yield 3 produces [1, 2, 3] when collected, exactly as if its two yields lived there. And because generators are lazy, they can represent sequences with no end at all — as long as you never ask for all of them.
import itertools
def naturals():
n = 1
while True:
yield n
n += 1
print(list(itertools.islice(naturals(), 5)))
Expected output: [1, 2, 3, 4, 5] — guarded by itertools.islice, which stops cleanly instead of hanging your terminal. The same code with list(naturals()) consumes memory until you interrupt it: the classic first mistake with infinite sequences.
A worked data pipeline
The whole chapter in one script: read a CSV lazily, filter it, count the survivors, and show what that approach costs in memory compared with loading everything.
# top_pages.py
import csv, sys
from pathlib import Path
def rows(path):
with Path(path).open(newline="", encoding="utf-8") as f:
yield from csv.DictReader(f)
def slow(rows_iter, minimum=30):
for row in rows_iter:
if int(row["seconds"]) >= minimum:
yield row
tally = {}
for row in slow(rows("events.csv")):
page = row["page"]
tally[page] = tally.get(page, 0) + 1
for page in sorted(tally):
print(f"{page}: {tally[page]}")
loaded = list(rows("events.csv"))
lazy = rows("events.csv")
total = sys.getsizeof(loaded) + sum(sys.getsizeof(row) for row in loaded)
print("list:", total, "bytes including", len(loaded), "rows")
print("generator:", sys.getsizeof(lazy), "bytes, no rows loaded yet")
Expected output: for a ten-row events.csv of page,seconds records:
/blog: 1
/contact: 1
/docs: 2
/pricing: 1
list: 2024 bytes including 10 rows
generator: 224 bytes, no rows loaded yet
The counter is deliberately blunt: for ten rows the difference is trivial, but the list grows with every row while the generator stays at 224 bytes — on a log file with ten million rows, only one of these approaches finishes.
Common mistakes
- Calling a generator function and expecting values —
countdown(3)only builds a generator; the body has not run yet. - Reusing an exhausted generator — it is one-shot, so the second
list(gen)returns[]; create the generator again. - Implementing
__next__but not__iter__— aforloop then refuses the object withTypeError: 'X' object is not iterable. - Iterating an infinite generator without a bound —
list(naturals())never returns; useitertools.islice, abreakor a sentinel. - Materialising everything out of habit —
sorted()orlist()on a huge pipeline defeats the point; consume as far as you need, and no further.
Practice checklist
- Write the
Counterclass by hand, iterate it, then delete__iter__and read theTypeError. - Unroll any
forloop from an earlier chapter into thewhile/try/except StopIterationform and confirm the output matches. - Add a
print()inside a generator body, call the function, and note that nothing prints until you request the first value. - Build a three-stage pipeline over
events.csv(read, filter, aggregate) and produce a report without ever callinglist()on the whole file. - Feed an infinite generator to
itertools.islice, take ten values, then trylist()on it for one second and interrupt with Ctrl-C.
What's next?
Iteration and laziness are two of Python’s quiet superpowers; the third is the pattern you already used in chapter 15 without explanation — functions that wrap functions and with blocks that manage resources come from one idea. Start with Decorators and Context Managers.
Frequently Asked Questions
Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.
None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.
Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.
More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.
Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.
Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.
A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.
Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.
Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.
JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.