Generators in Python | Python Course | Dataplexa

Generators in Python

In Lesson 25, you built iterators by writing a full class with __iter__ and __next__. It worked — but it required a lot of boilerplate for something conceptually simple. Generators are Python's answer to that problem. With a single keyword — yield — you can turn any function into an iterator instantly, with no class, no manual state tracking, and no StopIteration management needed.

Generators are one of the most powerful and memory-efficient tools in Python. Data science pipelines, web servers, file processors, and async frameworks all rely on them. This lesson covers generator functions, generator expressions, infinite generators, pipelines, yield from, and the send() method.

The yield Keyword — How it Works

A regular function runs to completion and returns one value. A generator function uses yield instead of (or alongside) return. Each time yield is reached, the function pauses, hands the value to the caller, and freezes its entire state — local variables, the instruction pointer, and the call stack. The next call to next() resumes exactly where it left off.

def count_up(start, end):
    print(f"  [generator] starting at {start}")
    n = start
    while n <= end:
        print(f"  [generator] about to yield {n}")
        yield n           # pause here — freeze state and hand n to caller
        print(f"  [generator] resumed after yield {n}")
        n += 1
    print("  [generator] done")

gen = count_up(1, 3)
print("Generator created — no code has run yet")
print()

print("Calling next() #1:")
val = next(gen)
print(f"  Caller received: {val}")
print()

print("Calling next() #2:")
val = next(gen)
print(f"  Caller received: {val}")
print()

print("For loop (gets the rest):")
for n in gen:
    print(f"  Loop received: {n}")
Generator created — no code has run yet Calling next() #1: [generator] starting at 1 [generator] about to yield 1 Caller received: 1 Calling next() #2: [generator] resumed after yield 1 [generator] about to yield 2 Caller received: 2 For loop (gets the rest): [generator] resumed after yield 2 [generator] about to yield 3 Loop received: 3 [generator] resumed after yield 3 [generator] done
  • Calling a generator function does not run any of its code — it returns a generator object immediately.
  • Code runs only when next() is called — one step at a time, always resuming from the exact pause point.
  • When the function body ends naturally (or hits a bare return), StopIteration is raised automatically.
  • The generator object satisfies the full iterator protocol — use it anywhere an iterator is expected.

Generator vs Regular Function — The Memory Difference

The most important practical advantage of generators is memory. A list comprehension builds every value and stores them all in RAM at once. A generator produces values one at a time and discards each after use.

import sys

# List comprehension — builds ALL 1 million values immediately
big_list = [x ** 2 for x in range(1_000_000)]
print("List size  :", sys.getsizeof(big_list), "bytes")

# Generator expression — holds only ONE value at a time
big_gen = (x ** 2 for x in range(1_000_000))
print("Gen size   :", sys.getsizeof(big_gen), "bytes")

# Both produce identical results when consumed
print("First 5 from list:", big_list[:5])

big_gen2 = (x ** 2 for x in range(5))
print("First 5 from gen :", list(big_gen2))

# Generator is ideal when you only need to process once
total = sum(x ** 2 for x in range(1_000_000))   # never builds a list
print("Sum (generator)  :", total)
List size : 8448728 bytes Gen size : 104 bytes First 5 from list: [0, 1, 4, 9, 16] First 5 from gen : [0, 1, 4, 9, 16] Sum (generator) : 333332833333500000
  • The list uses ~8MB. The generator uses ~104 bytes — regardless of how many values it could produce.
  • When you only need to iterate once (compute a sum, find a max, process line by line), a generator saves all that memory.
  • sum(x**2 for x in range(1_000_000)) — the generator expression goes directly inside sum() with no list ever built.

Generator Expressions

Generator expressions are the lazy equivalent of list comprehensions — same syntax, but with parentheses instead of square brackets. They are ideal for one-shot processing.

# Generator expressions — one-liner lazy sequences

squares_list = [x ** 2 for x in range(1, 6)]   # list — builds now
squares_gen  = (x ** 2 for x in range(1, 6))   # generator — lazy

print("List :", squares_list)
print("Gen  :", list(squares_gen))

# Pass directly to functions — no extra () when it's the only argument
print("Sum  :", sum(x ** 2 for x in range(1, 6)))
print("Max  :", max(x ** 2 for x in range(1, 6)))

# Filter inline
evens = (x for x in range(1, 20) if x % 2 == 0)
print("Evens:", list(evens))

# Chain transformations inline
result = sum(x ** 2 for x in range(1, 100) if x % 3 == 0)
print("Sum of squares of multiples of 3 (1-99):", result)
List : [1, 4, 9, 16, 25] Gen : [1, 4, 9, 16, 25] Sum : 55 Max : 25 Evens: [2, 4, 6, 8, 10, 12, 14, 16, 18] Sum of squares of multiples of 3 (1-99): 32835
  • Use () for a generator expression, [] for a list comprehension, {} for set/dict comprehensions.
  • When a generator expression is the only argument to a function, the outer () can be dropped: sum(x for x in range(5)).
  • Generator expressions are consumed once — if you need to iterate multiple times, use a list or a generator function that can be called again.

Multiple Yield Points and Early Return

A generator function can have multiple yield statements, conditional logic, and a return statement to stop early. Each yield is a pause point.

def fibonacci(limit):
    """Yields Fibonacci numbers up to limit."""
    a, b = 0, 1
    while a <= limit:
        yield a
        a, b = b, a + b   # advance both values

print("Fibonacci up to 100:", list(fibonacci(100)))

def classify_numbers(numbers):
    """Yields (number, label) tuples."""
    for n in numbers:
        if n < 0:
            yield n, "negative"
        elif n == 0:
            yield n, "zero"
        elif n % 2 == 0:
            yield n, "even"
        else:
            yield n, "odd"

data = [-3, 0, 4, 7, -1, 12, 5]
for n, label in classify_numbers(data):
    print(f"  {n:>4} → {label}")
Fibonacci up to 100: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89] -3 → negative 0 → zero 4 → even 7 → odd -1 → negative 12 → even 5 → odd
  • A generator can hold any logic — conditionals, loops, multiple yields — it is a regular function that pauses.
  • The Fibonacci generator is the classic example — the state (a, b) is naturally preserved between yields.
  • A bare return inside a generator raises StopIteration immediately.

Infinite Generators

Because generators produce values lazily, they can represent sequences that never end — impossible with a list. The caller controls when to stop.

import itertools

def integers_from(n):
    """Yields every integer from n upward, forever."""
    while True:
        yield n
        n += 1

def primes():
    """Yields prime numbers, forever."""
    def is_prime(n):
        if n < 2: return False
        for i in range(2, int(n**0.5)+1):
            if n % i == 0: return False
        return True
    n = 2
    while True:
        if is_prime(n):
            yield n
        n += 1

# islice — take first N values safely
first_10 = list(itertools.islice(integers_from(1), 10))
print("First 10 integers:", first_10)

first_10_primes = list(itertools.islice(primes(), 10))
print("First 10 primes  :", first_10_primes)

# takewhile — take values while a condition holds
small_primes = list(itertools.takewhile(lambda x: x < 50, primes()))
print("Primes under 50  :", small_primes)
First 10 integers: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] First 10 primes : [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] Primes under 50 : [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
  • Never call list() or sum() on an infinite generator — it will loop forever.
  • itertools.islice(gen, n) — take exactly n values. itertools.takewhile(pred, gen) — take values while condition holds.
  • The prime generator is a real example of where the iterator-class approach from Lesson 25 would be far messier.

Generator Pipelines

Generators compose naturally into pipelines — a chain of lazy transformations where each stage yields to the next. No intermediate lists are created, and memory stays flat no matter how many stages you add.

# Generator pipeline — zero intermediate lists

def read_lines(lines):
    """Stage 1: emit each line (simulates reading from a file)."""
    for line in lines:
        yield line

def strip_lines(lines):
    """Stage 2: strip whitespace from each line."""
    for line in lines:
        yield line.strip()

def filter_empty(lines):
    """Stage 3: drop empty lines."""
    for line in lines:
        if line:
            yield line

def parse_csv_row(lines):
    """Stage 4: split each line into a list of fields."""
    for line in lines:
        yield line.split(",")

# Simulate raw data from a file
raw = [
    "  Alice, 28, Engineer  \n",
    "  \n",
    "Bob, 35, Designer\n",
    "  \n",
    "  Carol, 22, Developer  \n",
]

# Build the pipeline — nothing runs yet
stage1 = read_lines(raw)
stage2 = strip_lines(stage1)
stage3 = filter_empty(stage2)
stage4 = parse_csv_row(stage3)

# Pull values through — computation happens only here
print(f"{'Name':<10} {'Age':>5} {'Role'}")
print("-" * 28)
for row in stage4:
    name, age, role = [f.strip() for f in row]
    print(f"{name:<10} {age:>5} {role}")
Name Age Role ---------------------------- Alice 28 Engineer Bob 35 Designer Carol 22 Developer
  • Each generator pulls from the previous one — values flow through lazily one at a time.
  • Only the final for loop triggers actual computation — all four stages execute together per row.
  • This pattern handles files of any size with constant memory — the exact approach used in ETL pipelines and log processors.

yield from — Delegating to a Sub-generator

yield from delegates part of a generator's work to another iterable or generator. Instead of looping and yielding each value manually, you hand control to the sub-generator entirely — cleaner and more efficient.

# yield from — delegate to another iterable

def flatten(nested):
    """Recursively flattens nested lists of any depth."""
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)   # recurse into sub-list
        else:
            yield item

# Works at any depth
print(list(flatten([1, [2, 3], [4, [5, [6, 7]]]]]))

# yield from with multiple iterables in sequence
def combined():
    yield from range(1, 4)     # yields 1, 2, 3
    yield from "abc"           # yields 'a', 'b', 'c'
    yield from [10, 20, 30]    # yields 10, 20, 30

print(list(combined()))

# Compare: manual vs yield from
def manual_flatten(nested):
    for sublist in nested:
        for item in sublist:    # two loops needed manually
            yield item

def delegated_flatten(nested):
    for sublist in nested:
        yield from sublist      # one line, same result

data = [[1,2,3],[4,5],[6,7,8,9]]
print(list(manual_flatten(data)))
print(list(delegated_flatten(data)))
[1, 2, 3, 4, 5, 6, 7] [1, 2, 3, 'a', 'b', 'c', 10, 20, 30] [1, 2, 3, 4, 5, 6, 7, 8, 9] [1, 2, 3, 4, 5, 6, 7, 8, 9]
  • yield from iterable is equivalent to for item in iterable: yield item — but cleaner and slightly faster internally.
  • Works with any iterable — lists, ranges, strings, or other generators.
  • Recursive flattening with yield from flatten(item) is the canonical Python pattern for deep nesting.

Sending Values into a Generator — send()

Generators are not just one-way. The send() method lets you push a value into a running generator — the sent value becomes the result of the yield expression inside the function. This turns a generator into a coroutine — a function that can both produce and receive values.

def running_average():
    """Receives numbers via send() and yields the running average."""
    total = 0
    count = 0
    average = None
    while True:
        value = yield average        # yield current average, receive next number
        if value is None:
            break
        total += value
        count += 1
        average = total / count

gen = running_average()
next(gen)    # prime the generator — runs to the first yield

print(gen.send(10))    # send 10 → average is 10.0
print(gen.send(20))    # send 20 → average is 15.0
print(gen.send(30))    # send 30 → average is 20.0
print(gen.send(40))    # send 40 → average is 25.0
10.0 15.0 20.0 25.0
  • You must always call next(gen) first (or gen.send(None)) to advance to the first yield — this is called "priming" the generator.
  • yield average does two things: it sends average out to the caller, and it receives the next send() value as its result.
  • This is the foundation of Python's asyncio coroutine system — async def / await are built on top of this send/receive mechanism.

Real-World Example — Large File Processor

import re
from collections import Counter

# Simulate a large log file (in production this reads from disk)
log_data = """
2024-03-15 08:01:22 INFO  Server started
2024-03-15 08:05:31 INFO  User alice logged in
2024-03-15 08:12:44 ERROR Database timeout
2024-03-15 08:15:02 WARN  Disk at 85%
2024-03-15 08:22:18 ERROR SMTP refused
2024-03-15 08:30:05 INFO  User bob logged in
2024-03-15 09:00:01 ERROR Connection reset
2024-03-15 09:05:22 INFO  Backup done
""".strip().splitlines()

# Generator pipeline for log analysis
def parse_log(lines):
    pattern = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\w+)\s+(.*)")
    for line in lines:
        m = pattern.match(line)
        if m:
            yield {"time": m.group(1), "level": m.group(2), "msg": m.group(3)}

def only_level(entries, level):
    for e in entries:
        if e["level"] == level:
            yield e

# Process without loading everything into memory at once
all_entries  = list(parse_log(log_data))
level_counts = Counter(e["level"] for e in parse_log(log_data))
errors       = list(only_level(parse_log(log_data), "ERROR"))

print("Log summary:")
for level, count in sorted(level_counts.items()):
    print(f"  {level:<8}: {count}")

print(f"\nErrors ({len(errors)}):")
for e in errors:
    print(f"  [{e['time']}] {e['msg']}")
Log summary: ERROR : 3 INFO : 4 WARN : 1 Errors (3): [2024-03-15 08:12:44] Database timeout [2024-03-15 08:22:18] SMTP refused [2024-03-15 09:00:01] Connection reset

Quick Reference Table

ConceptSyntaxKey Benefit
Generator functiondef f(): yield valueLazy values, automatic state management
Generator expression(expr for x in it)Inline lazy sequence, minimal memory
Infinite generatorwhile True: yield nEndless sequences at zero memory cost
PipelineChain generator functionsMulti-stage transforms, zero intermediate lists
yield fromyield from iterableDelegate to sub-generator cleanly
send(value)gen.send(x)Push value into generator (coroutine pattern)
isliceitertools.islice(gen, n)Take n values from any generator safely
takewhileitertools.takewhile(pred, gen)Take values while condition holds

Practice

What keyword turns a regular function into a generator function?



What does calling a generator function return before any code inside it runs?



What is the syntax difference between a list comprehension and a generator expression?



What does yield from iterable do?



Which itertools function safely takes a fixed number of values from an infinite generator?



What generator method lets you push a value into a running generator?



Quick Quiz

What happens when you call a generator function?





Why do generators use far less memory than lists for large sequences?





What happens to a generator's state when it reaches a yield statement?





Which of the following is a generator expression?





What is the risk of calling list() on an infinite generator?





What must you call before the first send(value) on a fresh generator?





NEXT UP
Working with JSON in Python
Learn how to read, write, and transform JSON data — the format used by almost every web API and configuration file in modern software.