Layer 8 Session 46 Beyond the Fundamentals

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.

yield one value at a time, never the whole list at once

One value at a time, produced only when asked for.

Estimated time: 35โ€“40 minutes  ยท  Pre-quiz โ†’ Concept โ†’ Lab โ†’ Commit โ†’ Post-quiz

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.

Question 1 of 5

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?

Anything that implements the iterator protocol (broadly: something Python can repeatedly ask "what's the next value?") can be looped over โ€” lists, dicts, strings, files, and generators, which this session introduces, all qualify.
Question 2 of 5

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.
Question 3 of 5

Why would a generator be preferable to building and returning a full list, for a function that processes a very large dataset?

This connects directly to Session 41's "read a huge file line by line instead of loading it all at once" lesson โ€” a generator applies that exact same memory-efficiency idea to computed sequences, not just file reading.
Question 4 of 5

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.
Question 5 of 5

How does open(path) as f: (used since Session 37/41) relate to this session's custom context managers?

This is the payoff of the whole session: the file object 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

CALL ITcountdown(3)YOU GETgenerator object(nothing run yet)

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

Lab objective: Write a generator function for lazily processing countries, and a custom context manager for timing a block of code.

What you will build

A file called generators_lab.py.

Step-by-step instructions

1

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"])
2

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
3

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
4

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)
5

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

FileActionWhy
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.
If you find yourself editing any other file, stop. This session touches exactly 2 files.

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"
Do not commit until you can answer out loud: "Why does Timer.__exit__ still run and print the elapsed time even when a ValueError is raised inside the with block?"

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.

Question 1 of 5

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?

Anything that implements the iterator protocol (broadly: something Python can repeatedly ask "what's the next value?") can be looped over โ€” lists, dicts, strings, files, and generators, which this session introduces, all qualify.
Question 2 of 5

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.
Question 3 of 5

Why would a generator be preferable to building and returning a full list, for a function that processes a very large dataset?

This connects directly to Session 41's "read a huge file line by line instead of loading it all at once" lesson โ€” a generator applies that exact same memory-efficiency idea to computed sequences, not just file reading.
Question 4 of 5

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.
Question 5 of 5

How does open(path) as f: (used since Session 37/41) relate to this session's custom context managers?

This is the payoff of the whole session: the file object 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.

  1. 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?
  2. 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?
  3. 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?
  4. 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.