Skip to main content

Python Generators Practice Problems & Exercises

Practice: Generators and yield — Suspended Execution at Engineering Depth

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Count Up GeneratorEasy
generatoryieldbasics

Write a generator count_up(start, stop) that yields integers from start to stop inclusive.

Python
def count_up(start, stop):
    n = start
    while n <= stop:
        yield n
        n += 1

for n in count_up(3, 7):
    print(n)
Solution
def count_up(start, stop):
n = start
while n <= stop:
yield n
n += 1

for n in count_up(3, 7):
print(n)
# 3 4 5 6 7

Explanation: Any function containing yield becomes a generator function. Calling it returns a generator object — no code runs until the first next() call (or the first loop iteration). Each yield suspends execution and hands the value to the caller; the frame resumes at the next line when the caller requests the next value.

def count_up(start, stop):
  # Yield integers from start to stop (inclusive)
  pass

for n in count_up(3, 7):
  print(n)
Expected Output
3\n4\n5\n6\n7
Hints

Hint 1: Use a while loop and yield each value before incrementing.

Hint 2: The function becomes a generator function the moment it contains a yield statement.


#2Infinite CounterEasy
generatorinfiniteitertools

Write an infinite generator infinite_counter(start, step) that yields values forever. Use itertools.islice to take only the first 5.

Python
import itertools

def infinite_counter(start=0, step=1):
    n = start
    while True:
        yield n
        n += step

result = list(itertools.islice(infinite_counter(10, 3), 5))
print(result)
Solution
import itertools

def infinite_counter(start=0, step=1):
n = start
while True:
yield n
n += step

result = list(itertools.islice(infinite_counter(10, 3), 5))
print(result) # [10, 13, 16, 19, 22]

Explanation: An infinite while True loop is perfectly safe in a generator because execution suspends at each yield. The generator only advances when the caller calls next(). itertools.islice closes the generator after consuming n items, triggering GeneratorExit inside the generator.

def infinite_counter(start=0, step=1):
  # Yield start, start+step, start+2*step, ... forever
  pass

import itertools
# Take only the first 5 values starting at 10, step 3
result = list(itertools.islice(infinite_counter(10, 3), 5))
print(result)
Expected Output
[10, 13, 16, 19, 22]
Hints

Hint 1: Use while True: to loop forever — safe because generators are lazy.

Hint 2: yield the current value, then increment by step.


#3Generator Expression vs List ComprehensionEasy
generator expressionmemorylazy

Run this code to observe the memory difference between a list comprehension and a generator expression over 1,000,000 elements. Note that exact byte counts may vary by Python version.

Python
import sys

list_comp = [x * x for x in range(1_000_000)]
gen_expr  = (x * x for x in range(1_000_000))

print("List size (bytes):", sys.getsizeof(list_comp))
print("Generator size (bytes):", sys.getsizeof(gen_expr))
print("First 5 from generator:", [next(gen_expr) for _ in range(5)])
Solution
import sys

list_comp = [x * x for x in range(1_000_000)]
gen_expr = (x * x for x in range(1_000_000))

print("List size (bytes):", sys.getsizeof(list_comp)) # ~8 MB
print("Generator size (bytes):", sys.getsizeof(gen_expr)) # ~104 bytes
print("First 5 from generator:", [next(gen_expr) for _ in range(5)])

Explanation: The list allocates memory for all 1,000,000 integers immediately. The generator object is ~104 bytes regardless of how many values it will eventually produce — it stores only the frame pointer, the current execution state, and the local variables. This constant-memory property makes generators essential for large-scale data processing.

import sys

# Compare memory usage of a list comprehension vs a generator expression
# for the first 1,000,000 squares

list_comp = [x * x for x in range(1_000_000)]
gen_expr  = (x * x for x in range(1_000_000))

print("List size (bytes):", sys.getsizeof(list_comp))
print("Generator size (bytes):", sys.getsizeof(gen_expr))
print("First 5 from generator:", [next(gen_expr) for _ in range(5)])
Expected Output
List size (bytes): 8000056\nGenerator size (bytes): 104\nFirst 5 from generator: [0, 1, 4, 9, 16]
Hints

Hint 1: This problem is observational — run it and note the size difference.

Hint 2: Generator expressions use () instead of [].

Hint 3: A generator object is a fixed small size regardless of how many values it will yield.


#4Fibonacci GeneratorEasy
generatorfibonaccistateful

Write an infinite generator fibonacci() that yields the Fibonacci sequence starting from 0.

Python
import itertools

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

print(list(itertools.islice(fibonacci(), 10)))
Solution
import itertools

def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b

print(list(itertools.islice(fibonacci(), 10)))
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Explanation: The generator maintains state between yields via its local variables a and b. The tuple swap a, b = b, a + b is atomic (the right side is evaluated first), so no temporary variable is needed. This pattern — an infinite stateful sequence — is the canonical use case for generators.

def fibonacci():
  # Yield the Fibonacci sequence: 0, 1, 1, 2, 3, 5, 8, 13, ...
  pass

import itertools
print(list(itertools.islice(fibonacci(), 10)))
Expected Output
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Hints

Hint 1: Track two variables: a and b.

Hint 2: Each step: yield a, then update a, b = b, a+b.


Medium

#5Generator Pipeline: Read, Filter, TransformMedium
generatorpipelinechaining

Implement three generator functions and chain them into a pipeline. Each generator should yield from its predecessor — no intermediate lists.

Python
def read_numbers(lst):
    yield from lst

def filter_positive(numbers):
    for n in numbers:
        if n > 0:
            yield n

def square_values(numbers):
    for n in numbers:
        yield n * n

data = [-3, -1, 0, 2, 5, -2, 8]
pipeline = square_values(filter_positive(read_numbers(data)))
print(list(pipeline))
Solution
def read_numbers(lst):
yield from lst

def filter_positive(numbers):
for n in numbers:
if n > 0:
yield n

def square_values(numbers):
for n in numbers:
yield n * n

data = [-3, -1, 0, 2, 5, -2, 8]
pipeline = square_values(filter_positive(read_numbers(data)))
print(list(pipeline)) # [4, 25, 64]

Explanation: Each generator wraps the previous one. When list() calls next() on square_values, it calls next() on filter_positive, which calls next() on read_numbers. Data flows upward one element at a time — the entire chain executes in a single pass with O(1) memory regardless of input size.

def read_numbers(lst):
  """Yield each number from the list."""
  pass

def filter_positive(numbers):
  """Yield only positive numbers from the input generator."""
  pass

def square_values(numbers):
  """Yield the square of each number from the input generator."""
  pass

data = [-3, -1, 0, 2, 5, -2, 8]

pipeline = square_values(filter_positive(read_numbers(data)))
print(list(pipeline))
Expected Output
[4, 25, 64]
Hints

Hint 1: Each function is a generator — use yield inside each one.

Hint 2: Chain them: square_values wraps filter_positive wraps read_numbers.

Hint 3: Each generator pulls from the one below it, element by element.


#6yield from — Delegating to a Sub-GeneratorMedium
yield fromdelegationnested generators

Implement a recursive flatten generator that handles arbitrarily nested lists using yield from.

Python
def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

data = [1, [2, 3], [4, [5, 6]], 7, [[8, 9], 10]]
print(list(flatten(data)))
Solution
def flatten(nested):
for item in nested:
if isinstance(item, list):
yield from flatten(item)
else:
yield item

data = [1, [2, 3], [4, [5, 6]], 7, [[8, 9], 10]]
print(list(flatten(data))) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Explanation: yield from flatten(item) delegates to the recursive generator and transparently passes every yielded value up the call chain. Without yield from, you would need for x in flatten(item): yield x — more verbose and slower because each yield goes through an extra generator frame. yield from also forwards send(), throw(), and close() correctly, which matters for coroutine use cases.

def flatten(nested):
  # Use yield from to recursively flatten an arbitrarily nested list.
  pass

data = [1, [2, 3], [4, [5, 6]], 7, [[8, 9], 10]]
print(list(flatten(data)))
Expected Output
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Hints

Hint 1: Check if each item is a list (or more generally, an Iterable that is not a string).

Hint 2: If it is a list, yield from flatten(item) — this delegates recursively.

Hint 3: If it is a plain value, yield it directly.


#7Two-Way Communication with send()Medium
generatorsendcoroutine protocol

Implement a running_average() coroutine. After priming with next(), each gen.send(value) should return the updated running average.

Python
def running_average():
    total = 0
    count = 0
    while True:
        value = yield total / count if count else 0.0
        total += value
        count += 1

gen = running_average()
next(gen)  # prime: advance to first yield

print(gen.send(10))   # 10.0
print(gen.send(20))   # 15.0
print(gen.send(30))   # 20.0
print(gen.send(40))   # 25.0
Solution
def running_average():
total = 0
count = 0
while True:
value = yield total / count if count else 0.0
total += value
count += 1

gen = running_average()
next(gen)

print(gen.send(10)) # 10.0
print(gen.send(20)) # 15.0
print(gen.send(30)) # 20.0
print(gen.send(40)) # 25.0

Explanation: value = yield expr is the two-way protocol. The expression expr is the value yielded out (returned by send() to the caller). The variable value receives whatever the caller passes into the next send() call. next(gen) is equivalent to gen.send(None) and advances the generator to the first yield — this "priming" step is required before any real send().

def running_average():
  # A coroutine that maintains a running average.
  # Each send(value) should update the average and yield the new average.
  # Prime it with next() before sending values.
  total = 0
  count = 0
  while True:
      value = yield  # receives the sent value, yields the average
      # your code here

gen = running_average()
next(gen)  # prime the coroutine

print(gen.send(10))   # 10.0
print(gen.send(20))   # 15.0
print(gen.send(30))   # 20.0
print(gen.send(40))   # 25.0
Expected Output
10.0\n15.0\n20.0\n25.0
Hints

Hint 1: value = yield suspends and waits for a .send(x) call; x is assigned to value.

Hint 2: After receiving the value, update total and count, then yield the average.

Hint 3: The yield expression both sends out a value and receives the next sent value.

Hint 4: yield avg_so_far will make the next send() receive this value as the result of yield.


#8Sliding Window GeneratorMedium
generatorcollections.dequewindow

Implement sliding_window(iterable, n) as a generator using collections.deque with maxlen.

Python
from collections import deque

def sliding_window(iterable, n):
    it = iter(iterable)
    window = deque(maxlen=n)
    # fill initial window
    for _ in range(n):
        try:
            window.append(next(it))
        except StopIteration:
            return
    yield tuple(window)
    for item in it:
        window.append(item)
        yield tuple(window)

print(list(sliding_window([1, 2, 3, 4, 5], 3)))
Solution
from collections import deque

def sliding_window(iterable, n):
it = iter(iterable)
window = deque(maxlen=n)
for _ in range(n):
try:
window.append(next(it))
except StopIteration:
return
yield tuple(window)
for item in it:
window.append(item)
yield tuple(window)

print(list(sliding_window([1, 2, 3, 4, 5], 3)))
# [(1, 2, 3), (2, 3, 4), (3, 4, 5)]

Explanation: A deque with maxlen=n automatically discards the oldest element when a new one is appended — O(1) append and eviction. Pre-filling the first n elements handles the "not enough data" edge case. Each subsequent element shifts the window by one. itertools.pairwise in Python 3.10+ handles n=2; for arbitrary n, more-itertools.sliding_window implements the same pattern.

from collections import deque

def sliding_window(iterable, n):
  # Yield tuples of n consecutive elements.
  # sliding_window([1,2,3,4,5], 3) -> (1,2,3), (2,3,4), (3,4,5)
  pass

print(list(sliding_window([1, 2, 3, 4, 5], 3)))
Expected Output
[(1, 2, 3), (2, 3, 4), (3, 4, 5)]
Hints

Hint 1: Use a deque with maxlen=n to maintain the current window.

Hint 2: Pre-fill the deque with the first n elements, then yield a tuple of it.

Hint 3: For each subsequent element: append to deque (auto-evicts oldest), yield tuple.


Hard

#9Generator-Based State MachineHard
generatorstate machinesendcoroutine

Implement traffic_light() as an infinite generator that cycles through GREEN, YELLOW, RED. The generator's execution position encodes the state — no state variable required.

Python
def traffic_light():
    while True:
        yield 'GREEN'
        yield 'YELLOW'
        yield 'RED'

light = traffic_light()
for _ in range(6):
    print(next(light))
Solution
def traffic_light():
while True:
yield 'GREEN'
yield 'YELLOW'
yield 'RED'

light = traffic_light()
for _ in range(6):
print(next(light))
# GREEN YELLOW RED GREEN YELLOW RED

Explanation: This demonstrates the key insight about generators: the execution position is the state. There is no state variable, no enum, no match statement. The generator suspends at each yield, and when resumed it continues from exactly that point. For complex protocols with many states, this makes the code far more readable than a hand-rolled state machine with an explicit state enum.

def traffic_light():
  # A generator coroutine modelling a traffic light.
  # States cycle: GREEN -> YELLOW -> RED -> GREEN -> ...
  # Each next() or send() call advances to the next state and yields it.
  pass

light = traffic_light()
for _ in range(6):
  print(next(light))
Expected Output
GREEN\nYELLOW\nRED\nGREEN\nYELLOW\nRED
Hints

Hint 1: Use an infinite while True loop with three yield statements.

Hint 2: The generator suspends at each yield, remembering where it is in the cycle.

Hint 3: No explicit state variable is needed — the position in the code IS the state.


#10Batching GeneratorHard
generatorbatchingitertoolsyield from

Implement batch(iterable, size) as a generator that yields chunks of size elements. Use itertools.islice for slicing; handle the last partial batch correctly.

Python
import itertools

def batch(iterable, size):
    it = iter(iterable)
    while True:
        chunk = list(itertools.islice(it, size))
        if not chunk:
            return
        yield chunk

data = range(1, 12)
for chunk in batch(data, 4):
    print(chunk)
Solution
import itertools

def batch(iterable, size):
it = iter(iterable)
while True:
chunk = list(itertools.islice(it, size))
if not chunk:
return
yield chunk

data = range(1, 12)
for chunk in batch(data, 4):
print(chunk)
# [1, 2, 3, 4]
# [5, 6, 7, 8]
# [9, 10, 11]

Explanation: Calling iter() once ensures a single iterator is advanced across all islice calls. Each islice(it, size) advances it by at most size positions without resetting it. The list() materialises the chunk (necessary to check if it is empty). In Python 3.12+, itertools.batched(iterable, n) implements this pattern natively.

import itertools

def batch(iterable, size):
  # Yield successive non-overlapping chunks of exactly 'size' elements.
  # The last batch may be smaller if the iterable length is not divisible by size.
  pass

data = range(1, 12)  # 1..11
for chunk in batch(data, 4):
  print(list(chunk))
Expected Output
[1, 2, 3, 4]\n[5, 6, 7, 8]\n[9, 10, 11]
Hints

Hint 1: Convert the iterable to an iterator once with iter().

Hint 2: itertools.islice(it, size) lazily takes at most size elements from it.

Hint 3: When islice returns an empty iterator, you are done.

Hint 4: You need to peek or materialise each slice to know if it is empty.


#11Coroutine Data Aggregator with throw() and close()Hard
generatorsendthrowclosecoroutine

Implement an aggregator() coroutine that tracks running statistics, handles throw(DataError) gracefully (skip bad value, yield current stats), and prints a message on close().

Python
class DataError(Exception):
    pass

def aggregator():
    count = 0
    total = 0
    try:
        while True:
            try:
                value = yield (count, total, total / count if count else 0.0)
                count += 1
                total += value
            except DataError:
                yield (count, total, total / count if count else 0.0)
    finally:
        print("aggregator closed")

agg = aggregator()
next(agg)  # prime

print(agg.send(10))
print(agg.send(20))
agg.throw(DataError, "bad value")
print(agg.send(30))
agg.close()
Solution
class DataError(Exception):
pass

def aggregator():
count = 0
total = 0
try:
while True:
try:
value = yield (count, total, total / count if count else 0.0)
count += 1
total += value
except DataError:
yield (count, total, total / count if count else 0.0)
finally:
print("aggregator closed")

agg = aggregator()
next(agg)

print(agg.send(10)) # (1, 10, 10.0)
print(agg.send(20)) # (2, 30, 15.0)
agg.throw(DataError, "bad value") # (2, 30, 15.0)
print(agg.send(30)) # (3, 60, 20.0)
agg.close() # aggregator closed

Explanation: throw(exc) injects an exception at the point where the generator is currently suspended (at the yield). The inner except DataError catches it and yields the unchanged stats. close() injects GeneratorExit (a BaseException subclass), which is caught by the outer finally — this is the correct cleanup hook for generator coroutines. This full protocol (send, throw, close) is precisely what async/await builds on under the hood.

class DataError(Exception):
  pass

def aggregator():
  # Coroutine that:
  # - Accepts numbers via send(n)
  # - Yields (count, total, average) after each send
  # - On throw(DataError), skips the bad value and yields current stats unchanged
  # - On close(), cleans up (print "aggregator closed")
  pass

agg = aggregator()
next(agg)  # prime

print(agg.send(10))   # (1, 10, 10.0)
print(agg.send(20))   # (2, 30, 15.0)
agg.throw(DataError, "bad value")  # should print stats without updating
print(agg.send(30))   # (3, 60, 20.0)
agg.close()           # aggregator closed
Expected Output
(1, 10, 10.0)\n(2, 30, 15.0)\n(2, 30, 15.0)\n(3, 60, 20.0)\naggregator closed
Hints

Hint 1: Use a try/except DataError block around the yield to catch throw() calls.

Hint 2: When DataError is caught, yield the current stats without updating totals.

Hint 3: Use try/finally to handle GeneratorExit (triggered by close()).

Hint 4: Structure: outer try/finally for GeneratorExit, inner try/except for DataError.

© 2026 EngineersOfAI. All rights reserved.