Generators, Iterators & Context Managers
Every for loop in this entire curriculum has relied on Python's iterator protocol without you ever seeing it directly. This session opens that hood, and shows you how with actually works too.
One value at a time, produced only when asked for.
1. Learning Objective
By the end of this session you will be able to:
- Explain what makes an object iterable, at a mechanical level
- Write a generator function using yield and explain how it differs from a normal function
- Explain why a generator is memory-efficient compared to building a full list upfront
- Write a custom context manager using a class with __enter__ and __exit__
- Connect with open(...) as f: (used since Session 37) to the same __enter__/__exit__ protocol
2. Pre-Coding Quiz
Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ that is the point. You need 4/5 to proceed to the lab.
Every for loop since Session 06 has worked on lists, dicts, and strings without you writing any special code. What makes an object usable in a for loop?
Given def countdown(n):\n while n > 0:\n yield n\n n -= 1, what does calling countdown(3) actually return?
yield turns a function into a generator function. Calling it does not run the body at all โ it returns a generator object that produces values lazily, one yield at a time, only when something (like a for loop) asks for the next one.Why would a generator be preferable to building and returning a full list, for a function that processes a very large dataset?
What two methods must a class define to work as a context manager (usable with the with statement)?
__enter__ runs when the with block begins (its return value is what gets bound after as), and __exit__ runs when the block ends โ whether it ended normally or because of an exception, exactly like the file-closing guarantee from Session 41.How does open(path) as f: (used since Session 37/41) relate to this session's custom context managers?
open() returns implements the same __enter__/__exit__ protocol you can implement yourself. with open(path) as f: was never special-cased magic โ it was always an ordinary context manager, working exactly as this session explains.3. The Concept โ Iterators, Generators, and Context Managers
Unlike a normal function, calling a generator function does not run its body โ it returns a lazy object that produces one value per yield, on demand.
What every for loop has secretly relied on
Since Session 06, every for item in collection: has worked because the collection implements the iterator protocol โ broadly, Python can repeatedly ask it "what's next?" until there is nothing left. Lists, dicts, strings, and files all support this. This session shows you the mechanism, and lets you build your own.
yield โ writing a generator function
A function containing yield becomes a generator function. Calling it does not run the body โ it returns a generator object that produces one value at a time, pausing at each yield until the next value is requested.
def countdown(n):
while n > 0:
yield n
n -= 1
for number in countdown(3):
print(number)
# 3
# 2
# 1
# Calling countdown(3) alone does NOT print anything โ it returns a generator object
gen = countdown(3)
print(gen) # <generator object countdown at 0x...>
print(next(gen)) # 3 โ pulls the first value
print(next(gen)) # 2 โ resumes exactly where it left off
Why lazy evaluation matters โ memory efficiency
A function that builds and returns a full list must hold every value in memory before returning anything. A generator produces values one at a time and never holds the whole sequence at once โ the same memory-efficiency idea Session 41 applied to reading huge files line by line.
# Builds the ENTIRE list in memory before returning anything
def all_populations_list(countries):
return [c["population"] for c in countries]
# Produces ONE value at a time โ never holds the whole sequence in memory
def all_populations_gen(countries):
for c in countries:
yield c["population"]
# For a huge dataset, the generator version scales far better
Writing your own context manager
A class becomes usable with with by implementing __enter__ (runs at the start of the block) and __exit__ (runs at the end, guaranteed โ even if an exception occurred inside, exactly like Session 41's file-closing guarantee).
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # this becomes "as timer" in the with statement
def __exit__(self, exc_type, exc_value, traceback):
import time
elapsed = time.perf_counter() - self.start
print(f"Elapsed: {elapsed:.6f}s")
return False # don't suppress any exception that occurred
with Timer() as timer:
total = sum(range(1000000))
print(total)
open() was a context manager all along
Session 37 and 41 used with open(path) as f: without ever seeing why it worked. The file object open() returns implements exactly the __enter__/__exit__ protocol shown above โ it was never special-cased magic.
# This was always just an ordinary context manager, working exactly like Timer above:
with open("countries.json") as f:
data = f.read()
# f.__exit__ runs here automatically โ closing the file, guaranteed
4. Lab
What you will build
A file called generators_lab.py.
Step-by-step instructions
Create the file and write a generator over country data
# generators_lab.py
countries = [
{"name": "Kenya", "region": "Africa", "population": 54000000},
{"name": "Ghana", "region": "Africa", "population": 31000000},
{"name": "Peru", "region": "Americas", "population": 33000000},
]
def large_countries(countries, threshold):
for c in countries:
if c["population"] > threshold:
yield c
for c in large_countries(countries, 40000000):
print(c["name"])
Confirm calling the generator function does not run its body immediately
gen = large_countries(countries, 40000000)
print(gen) # generator object, nothing printed from inside yet
print(next(gen)) # NOW the body runs up to the first yield
Write a Timer context manager
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.elapsed = time.perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.6f}s")
return False
Use Timer to measure a real operation on the country data
with Timer() as t:
total = sum(c["population"] for c in countries)
print("Total:", total)
Confirm __exit__ still runs even when an exception occurs inside the block
try:
with Timer() as t:
raise ValueError("something went wrong inside the block")
except ValueError as e:
print("Caught after Timer printed its elapsed time:", e)
5. Expected Files Changed
| File | Action | Why |
|---|---|---|
generators_lab.py |
Created | A generator over country data and a custom Timer context manager. |
docs/sessions/session-46/index.html |
Created | This session document. |
6. Commit Checkpoint
Once the lab is complete and you can explain every line, make this exact commit:
git add generators_lab.py docs/sessions/session-46/index.html
git commit -m "session-46: write a generator function and a custom __enter__/__exit__ context manager"
7. Code Review Checklist
Go through your code line by line and check each item:
8. Post-Coding Quiz
Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.
Every for loop since Session 06 has worked on lists, dicts, and strings without you writing any special code. What makes an object usable in a for loop?
Given def countdown(n):\n while n > 0:\n yield n\n n -= 1, what does calling countdown(3) actually return?
yield turns a function into a generator function. Calling it does not run the body at all โ it returns a generator object that produces values lazily, one yield at a time, only when something (like a for loop) asks for the next one.Why would a generator be preferable to building and returning a full list, for a function that processes a very large dataset?
What two methods must a class define to work as a context manager (usable with the with statement)?
__enter__ runs when the with block begins (its return value is what gets bound after as), and __exit__ runs when the block ends โ whether it ended normally or because of an exception, exactly like the file-closing guarantee from Session 41.How does open(path) as f: (used since Session 37/41) relate to this session's custom context managers?
open() returns implements the same __enter__/__exit__ protocol you can implement yourself. with open(path) as f: was never special-cased magic โ it was always an ordinary context manager, working exactly as this session explains.9. Reflection Questions
Think through these after the post-quiz. No right answer โ they are for discussion.
- Now that you have written __enter__ and __exit__ yourself, look back at every with open(...) as f: from Sessions 37-44 โ does that pattern feel different to you now?
- Why does a generator "pause" at yield instead of running straight through to the end like a normal function? What would break if it did not pause?
- For which of the labs in Layers 4-7 would rewriting a list-building function as a generator have made a genuine, measurable difference? For which would it not have mattered?
- What is the return value of Timer.__exit__ actually controlling? What would happen if it returned True instead of False when an exception occurred?
10. What Breaks If This Knowledge Is Missing?
- Large datasets exhausting memory: A function that eagerly builds a full list for a truly massive dataset (millions of records) can exhaust available memory before it even finishes running. A generator processes one item at a time and never has this problem, at the cost of not being able to re-iterate without calling the function again.
- "Magic" with statements: Without this session, with open(...) as f: (used constantly since Layer 7) remains unexplained special syntax rather than an ordinary, understandable object implementing a protocol you now know how to implement yourself.
11. What We Learned
Python concept mastered: The iterator protocol underlying every for loop, writing lazy generator functions with yield, and building custom context managers with __enter__/__exit__.
Unlocks: The for loop and with statement โ used in literally every session of this curriculum โ are no longer black boxes. You can now build both kinds of objects yourself.
Next session: Session 47 โ Packaging & Virtual Environments. We close the final practical gap: how to actually set up, isolate, and share a real Python project so it runs the same way on any machine.