02 — The Python Data Model, Iterators & $O(1)$-Memory Generators

At a glance

After completing this lesson you can:

Time: ~50 minutes · Prerequisites: 01 — References, Mutability & Memory

Reading Companion

Why this matters in production

If an endpoint or background job queries 1,000,000 events and executes [event.dict() for event in query], Python must allocate all 1,000,000 objects in RAM simultaneously, causing massive GC pauses and Linux OOM kills. Using streaming generators (yield) keeps memory usage flat at a few kilobytes regardless of whether the dataset is 10 rows or 100,000,000 rows.

Deep dive

1. Iterable vs Iterator Protocol

Container (Iterable) ────__iter__()────► State Machine (Iterator)
                                            │
                                            ├── __next__() ──► Item 1
                                            ├── __next__() ──► Item 2
                                            └── __next__() ──► raise StopIteration

2. Generator Pipelines

Generators lazily pull data downstream one element at a time. raw_lines -> parse_json -> filter_errors -> batch_chunks

Hands on

python3 phase0-foundations/03-python-internals/02-data-model-and-iterables/examples/01_demo.py
python3 phase0-foundations/03-python-internals/02-data-model-and-iterables/examples/02_trap.py

AI Trap & Code Review

# BAD AI-GENERATED CODE — ACCIDENTAL EXHAUSTION OF GENERATOR
def log_and_process(stream):
    print("Total items:", len(list(stream)))  # EXHAUSTS the generator completely!
    for item in stream:                       # Loop runs ZERO times!
        save(item)

Exercises

  1. Rung 1 (RangeStepIterator): Implement a custom iterator class RangeStepIterator(start, stop, step) implementing __iter__ and __next__.
  2. Rung 2 (stream_csv_records — Fix the AI Bug): Fix an AI function that loaded an entire file into memory with .readlines(), converting it to yield cleaned records lazily line-by-line.
  3. Rung 3 (chunk_stream): Build a generator function chunk_stream(stream, chunk_size) that yields lists of at most chunk_size items from any iterable without reading ahead.
  4. Rung 4 (sliding_window_stream): Generator yielding fixed-size tuples (x[i], x[i+1], ...) over an iterable.
  5. Rung 5 (fanout_stream): Tee an iterable into $N$ independent generators without buffering all past elements in memory unnecessarily.

Wrapping up

Can you explain?

  1. Why does an Iterator raise StopIteration instead of returning None?
  2. What happens to a generator function's local variables when execution reaches yield?
  3. Why is calling list(gen) twice in a row dangerous?

Further Reading & References

Show reference solution notes

Solution & Engineering Notes — 02 Data Model & Iterables

  1. Why StopIteration instead of None: None is a perfectly valid value to yield from an iterator (e.g. an iterator of optional results). Raising StopIteration cleanly differentiates between returning None and terminating iteration.
  2. Generator State: When execution hits yield, CPython suspends the stack frame and preserves all local variables in heap memory until __next__() is called again.

Self-check

Run python3 -m unittest phase0-foundations/03-python-internals/02-data-model-and-iterables/test_exercises.py -v until green, then record it here. Saved to progress/rungs.json; commit it.

Confidence:

Completion edits PROGRESS.md; commit it with the lesson.