PYnative

Python Programming

  • Learn Python
    • Python Tutorials
    • Python Basics
    • Python Interview Q&As
  • Exercises
    • Python Exercises
    • C Programming Exercises
    • C++ Exercises
  • Quizzes
  • Code Editor
    • Online Python Code Editor
    • Online C Compiler
    • Online C++ Compiler
Home » Python Exercises » Python Iterators and Generators Exercises: 30 Coding Problems with Solutions

Python Iterators and Generators Exercises: 30 Coding Problems with Solutions

Updated on: May 23, 2026 | Leave a Comment

Python iterators and generators enable memory-efficient programming through lazy evaluation, producing items only when needed instead of loading everything into memory. They are ideal for handling large datasets, processing files, and building scalable data pipelines.

This page includes 30 Python iterator and generator exercises, from beginner to expert level, covering topics from the yield keyword to advanced coroutines and itertools, helping you write more efficient and Pythonic code.

Each coding challenge includes a Practice Problem, Hint, Solution code, and detailed Explanation, ensuring you don’t just copy code, but genuinely practice and understand how and why it works.

  • All solutions have been fully tested on Python 3.
  • Use our Online Code Editor to solve these exercises in real time.
+ Table of Contents (30 Exercises)

Table of contents

  • Exercise 1: The Square Generator
  • Exercise 2: Even Number Iterator
  • Exercise 3: Custom Range
  • Exercise 4: Reverse String Iterator
  • Exercise 5: Vowel Filter
  • Exercise 6: Power of Two
  • Exercise 7: Finite Fibonacci
  • Exercise 8: Infinite Counter
  • Exercise 9: Manual Iteration
  • Exercise 10: Step Iterator
  • Exercise 11: File Line Reader
  • Exercise 12: CSV Row Parser
  • Exercise 13: The Pipeline
  • Exercise 14: List Flattener
  • Exercise 15: Batch Processing
  • Exercise 16: Prime Sieve
  • Exercise 17: Running Average
  • Exercise 18: Sliding Window
  • Exercise 19: Log Filter
  • Exercise 20: Unique Element Filter
  • Exercise 21: The Accumulator (send)
  • Exercise 22: Custom Zip
  • Exercise 23: Binary Tree Traversal
  • Exercise 24: Data Throttler
  • Exercise 25: Generator State Machine
  • Exercise 26: Peekable Iterator
  • Exercise 27: Exception Handler
  • Exercise 28: Recursive Directory Walker
  • Exercise 29: Interleaved Streams
  • Exercise 30: Generator Cleanup

Exercise 1: The Square Generator

Problem Statement: Create a generator function that yields the squares of numbers from 1 to n.

Purpose: This exercise introduces generator functions and the yield keyword. Generators allow you to produce values lazily, one at a time, making them memory-efficient for large sequences – a key concept in Python’s iterator protocol.

Given Input: n = 5

Expected Output: 1 4 9 16 25

▼ Hint
  • Define a function using the def keyword, and use yield instead of return to produce values one at a time.
  • Use range(1, n + 1) to iterate from 1 through n inclusive.
  • Inside the loop, yield i ** 2 or i * i to produce each square.
  • Call the generator in a for loop or pass it to print() with * unpacking to see all values.
▼ Solution & Explanation
def square_generator(n):
    for i in range(1, n + 1):
        yield i ** 2

for square in square_generator(5):
    print(square, end=" ")Code language: Python (python)

Explanation:

  • def square_generator(n): Defines a generator function. The presence of yield anywhere in the body makes it a generator rather than a regular function.
  • yield i ** 2: Pauses execution and sends the squared value to the caller. On the next iteration, execution resumes from the line after yield.
  • range(1, n + 1): Generates integers from 1 to n inclusive. Without + 1, n itself would be excluded.
  • end=" ": Passed to print() to separate values with a space instead of a newline, keeping all output on one line.

Exercise 2: Even Number Iterator

Problem Statement: Write a class-based iterator that returns even numbers up to a specified limit.

Purpose: This exercise teaches you how to implement the iterator protocol manually using __iter__ and __next__ methods. Understanding class-based iterators gives you full control over state and iteration logic.

Given Input: limit = 10

Expected Output: 0 2 4 6 8 10

▼ Hint
  • Create a class with an __init__ method that stores the limit and initializes a current counter to 0.
  • Implement __iter__ to return self – this makes the object itself the iterator.
  • Implement __next__ to return the current value and advance by 2. Raise StopIteration when the current value exceeds the limit.
  • Use a for loop to iterate over an instance of the class – Python calls __next__ automatically until StopIteration is raised.
▼ Solution & Explanation
class EvenIterator:
    def __init__(self, limit):
        self.limit = limit
        self.current = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.limit:
            raise StopIteration
        value = self.current
        self.current += 2
        return value

for num in EvenIterator(10):
    print(num, end=" ")Code language: Python (python)

Explanation:

  • __init__(self, limit): Sets up the iterator’s state. self.current tracks the next value to be returned, starting at 0.
  • __iter__(self): Returns self, which satisfies Python’s requirement that an iterable object return an iterator when iter() is called on it.
  • __next__(self): Returns the current even number and advances self.current by 2. This is called automatically by for loops and other iteration constructs.
  • raise StopIteration: Signals to the for loop that there are no more values, causing iteration to end cleanly.

Exercise 3: Custom Range

Problem Statement: Re-implement a simplified version of the range() function using a generator.

Purpose: This exercise deepens your understanding of how Python’s built-in range() works under the hood. Rebuilding familiar tools from scratch is a powerful way to solidify your grasp of generators and lazy evaluation.

Given Input: start = 2, stop = 10, step = 2

Expected Output: 2 4 6 8

▼ Hint
  • Define a generator function with parameters start, stop, and step=1 (default step of 1).
  • Use a while loop with the condition current < stop to keep yielding values.
  • Inside the loop, yield the current value, then increment it by step.
  • Note that like the built-in range(), the stop value should be excluded from the output.
▼ Solution & Explanation
def custom_range(start, stop, step=1):
    current = start
    while current < stop:
        yield current
        current += step
for num in custom_range(2, 10, 2):
    print(num, end=" ")Code language: Python (python)

Explanation:

  • def custom_range(start, stop, step=1): Defines the generator with a default step of 1, mirroring the signature of Python’s built-in range().
  • current = start: Initializes the running counter outside the loop so it persists across yield calls.
  • while current < stop: Continues yielding as long as the current value has not reached the stop boundary, which is excluded – consistent with built-in range() behaviour.
  • current += step: Advances the counter after each yield. Placing this after yield ensures the current value is produced before being incremented.

Exercise 4: Reverse String Iterator

Problem Statement: Write an iterator class that takes a string and returns its characters in reverse order.

Purpose: This exercise combines class-based iterator design with string indexing. It reinforces how iterators maintain state between calls and how negative indexing works in Python sequences.

Given Input: text = "hello"

Expected Output: o l l e h

▼ Hint
  • In __init__, store the string and set an index starting at len(string) - 1 to begin from the last character.
  • In __next__, check if the index is less than 0. If so, raise StopIteration.
  • Return the character at the current index, then decrement the index by 1.
  • Remember to implement __iter__ returning self so the class works inside a for loop.
▼ Solution & Explanation
class ReverseStringIterator:
    def __init__(self, text):
        self.text = text
        self.index = len(text) - 1
    def __iter__(self):
        return self
    def __next__(self):
        if self.index < 0:
            raise StopIteration
        char = self.text[self.index]
        self.index -= 1
        return char
for char in ReverseStringIterator("hello"):
    print(char, end=" ")Code language: Python (python)

Explanation:

  • self.index = len(text) - 1: Sets the starting position to the last valid index of the string. For "hello" (length 5), this is index 4, pointing to 'o'.
  • if self.index < 0: Once the index goes below zero, all characters have been yielded. Raising StopIteration cleanly ends the loop.
  • char = self.text[self.index]: Retrieves the character at the current position before decrementing, ensuring the correct character is returned.
  • self.index -= 1: Moves the pointer one step toward the beginning of the string on each call to __next__.

Exercise 5: Vowel Filter

Problem Statement: Create a generator that takes a string and yields only the vowels found in it.

Purpose: This exercise shows how generators can act as filters, processing a stream of data and selectively yielding items that meet a condition. This pattern is widely used in data pipelines and text processing.

Given Input: text = "Hello, World!"

Expected Output: e o o

▼ Hint
  • Define a generator function that accepts a string parameter.
  • Use a for loop to iterate over each character in the string.
  • Check membership using char.lower() in "aeiou" to handle both uppercase and lowercase vowels.
  • Use yield inside the if block to produce only the matching characters.
▼ Solution & Explanation
def vowel_filter(text):
    vowels = "aeiou"
    for char in text:
        if char.lower() in vowels:
            yield char

for vowel in vowel_filter("Hello, World!"):
    print(vowel, end=" ")Code language: Python (python)

Explanation:

  • vowels = "aeiou": Stores all lowercase vowels in a string. Using a string for membership testing with in is concise and readable for small character sets.
  • char.lower() in vowels: Converts the character to lowercase before checking, so both 'H' and 'h' are handled correctly without needing to list uppercase vowels separately.
  • yield char: Produces only the characters that pass the filter condition. Non-vowel characters are silently skipped, demonstrating the generator-as-filter pattern.
  • Generator advantage: Because this is a generator, it processes the string one character at a time without building an intermediate list – making it efficient for very long strings or streamed input.

Exercise 6: Power of Two

Problem Statement: Write a generator expression (one-liner) that yields powers of 2 (e.g., 1, 2, 4, 8…).

Purpose: This exercise introduces generator expressions as a compact alternative to generator functions. They follow the same lazy evaluation model but use a concise syntax similar to list comprehensions, making them ideal for simple transformations.

Given Input: n = 8

Expected Output: 1 2 4 8 16 32 64 128

▼ Hint
  • A generator expression uses parentheses instead of square brackets: (expr for x in iterable). It is lazy, unlike a list comprehension.
  • Use 2 ** i as the expression and range(n) as the iterable, where i goes from 0 to n – 1.
  • Assign the generator expression to a variable, then iterate over it with a for loop to print each value.
▼ Solution & Explanation
n = 8
powers = (2 ** i for i in range(n))

for value in powers:
    print(value, end=" ")Code language: Python (python)

Explanation:

  • (2 ** i for i in range(n)): A generator expression enclosed in parentheses. It behaves like a generator function with yield, but is written as a single expression. No list is built in memory.
  • range(n): Produces exponents 0 through n – 1. Since 2 ** 0 = 1, the sequence correctly starts at 1.
  • powers: Holds a generator object. The values are not computed until the for loop requests them, one at a time.
  • Generator vs. list comprehension: Replacing the outer parentheses with square brackets would produce a list immediately. The generator version defers computation, which matters when n is very large.

Exercise 7: Finite Fibonacci

Problem Statement: Write a generator to produce the first n numbers in the Fibonacci sequence, where each number is the sum of the two preceding ones.

Purpose: This exercise demonstrates how generators can maintain multiple pieces of state across yields. The Fibonacci sequence is a classic example where generating values on demand is more elegant than building the full list upfront.

Given Input: n = 8

Expected Output: 0 1 1 2 3 5 8 13

▼ Hint
  • Initialize two variables a, b = 0, 1 before the loop. On each iteration, yield a, then update both: a, b = b, a + b.
  • Use for _ in range(n) to control how many values are produced, where _ signals the loop variable is unused.
  • The simultaneous assignment a, b = b, a + b avoids needing a temporary variable – Python evaluates the right-hand side fully before assigning.
▼ Solution & Explanation
def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

for num in fibonacci(8):
    print(num, end=" ")Code language: Python (python)

Explanation:

  • a, b = 0, 1: Seeds the sequence with the first two Fibonacci values. Both variables persist in the generator’s local scope across every yield.
  • yield a: Produces the current Fibonacci number and pauses execution. The next call to the generator resumes on the line immediately after this statement.
  • a, b = b, a + b: Advances the sequence in one step. Python evaluates b and a + b simultaneously before assignment, so the old value of a is used in computing the new b.
  • for _ in range(n): Limits output to exactly n values. The underscore is a convention indicating the loop variable itself is not needed inside the body.

Exercise 8: Infinite Counter

Problem Statement: Create a generator that starts at 1 and counts up infinitely (don’t run this without a break condition!).

Purpose: This exercise shows that generators are not required to terminate. Infinite generators are a powerful pattern in Python, commonly used alongside itertools or consumed with a break once enough values have been produced.

Given Input: Print values until the counter reaches 5.

Expected Output: 1 2 3 4 5

▼ Hint
  • Use a while True loop inside the generator to yield values indefinitely. Increment the counter after each yield.
  • In the consuming for loop, use an if statement with break to stop once the desired limit is reached.
▼ Solution & Explanation
def infinite_counter(start=1):
    current = start
    while True:
        yield current
        current += 1

for num in infinite_counter():
    if num > 5:
        break
    print(num, end=" ")Code language: Python (python)

Explanation:

  • while True: Creates an unbounded loop inside the generator. Because execution pauses at yield each time, this does not cause an infinite loop in the traditional sense – the generator only advances when the caller requests the next value.
  • yield current: Suspends the generator and hands the current count to the caller. The while True loop resumes only when next() is called again.
  • if num > 5: break: Exits the for loop and closes the generator. Without a guard like this, the loop would run indefinitely and the program would never terminate.
  • start=1: A default parameter makes the generator flexible. You can start counting from any number by passing a different value, e.g. infinite_counter(10).

Exercise 9: Manual Iteration

Problem Statement: Given a list [10, 20, 30], use the iter() and next() functions to print each element manually, catching the StopIteration exception.

Purpose: This exercise pulls back the curtain on what a for loop does internally. Understanding iter() and next() directly gives you precise control over iteration and helps you reason about how Python’s iterator protocol works at a low level.

Given Input: numbers = [10, 20, 30]

Expected Output: 10 20 30

▼ Hint
  • Call iter(numbers) to get an iterator object from the list, then call next() on it repeatedly to retrieve each element one by one.
  • Wrap each next() call in a try/except StopIteration block, or use a while True loop and break inside the except clause when the list is exhausted.
▼ Solution & Explanation
numbers = [10, 20, 30]
iterator = iter(numbers)

while True:
    try:
        value = next(iterator)
        print(value, end=" ")
    except StopIteration:
        breakCode language: Python (python)

Explanation:

  • iter(numbers): Calls numbers.__iter__() internally and returns an iterator object. Lists are iterable but not iterators themselves – this step produces the iterator that tracks position.
  • next(iterator): Calls iterator.__next__() and returns the next value. Each call advances the internal pointer by one position.
  • except StopIteration: break: When the list is exhausted, next() raises StopIteration. Catching it here exits the loop cleanly, which is exactly what a for loop does under the hood.
  • What a for loop really does: The pattern iter() then repeated next() until StopIteration is precisely how Python implements every for loop. Writing it manually makes that mechanism explicit.

Exercise 10: Step Iterator

Problem Statement: Create an iterator that takes a start, stop, and step, returning values just like range() but allowing for float steps.

Purpose: This exercise extends the custom range concept from Exercise 3 to support floating-point steps – something Python’s built-in range() deliberately excludes. It reinforces class-based iterator design while highlighting a practical limitation of the standard library.

Given Input: start = 0.0, stop = 1.0, step = 0.25

Expected Output: 0.0 0.25 0.5 0.75

▼ Hint
  • Build a class with __init__, __iter__, and __next__. Store start, stop, and step in __init__, and use start as the initial value of a current counter.
  • In __next__, raise StopIteration when current >= stop, otherwise return the current value and advance by step. Use round() to avoid floating-point accumulation errors.
▼ Solution & Explanation
class FloatStepIterator:
    def __init__(self, start, stop, step):
        self.current = start
        self.stop = stop
        self.step = step

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.stop:
            raise StopIteration
        value = self.current
        self.current = round(self.current + self.step, 10)
        return value

for num in FloatStepIterator(0.0, 1.0, 0.25):
    print(num, end=" ")Code language: Python (python)

Explanation:

  • self.current = start: Initializes the running position. Using start directly (rather than a separate index) makes float arithmetic straightforward and keeps the logic close to the custom range from Exercise 3.
  • if self.current >= self.stop: Ends iteration when the current value reaches or passes stop, matching the exclusive upper bound of Python’s built-in range().
  • round(self.current + self.step, 10): Floating-point addition can accumulate tiny errors (e.g. 0.1 + 0.2 = 0.30000000000000004). Rounding to 10 decimal places corrects this without affecting precision at normal step sizes.
  • Why not use range() here: Python’s range() only accepts integers. This class fills that gap, and the same pattern can be extended to support negative float steps by adjusting the stop condition accordingly.

Exercise 11: File Line Reader

Problem Statement: Write a generator that reads a large text file line-by-line to avoid loading the entire file into memory.

Purpose: This exercise demonstrates one of the most practical real-world uses of generators: memory-efficient file processing. Instead of reading all lines into a list at once, the generator yields one line at a time, keeping memory usage constant regardless of file size.

Given Input: A text file sample.txt containing three lines: Hello, World!, Python is great., Generators save memory.

Expected Output:
Hello, World!
Python is great.
Generators save memory.
▼ Hint
  • Open the file using a with statement inside the generator, then use a for loop over the file object – file objects in Python are themselves iterable, yielding one line at a time.
  • Call line.rstrip("\n") before yielding to strip the trailing newline character from each line.
  • Create the sample file first using a with open(..., "w") block so the generator has something to read.
▼ Solution & Explanation
# Create the sample file
with open("sample.txt", "w") as f:
    f.write("Hello, World!\nPython is great.\nGenerators save memory.\n")

# Generator that reads the file line by line
def read_lines(filepath):
    with open(filepath, "r") as f:
        for line in f:
            yield line.rstrip("\n")

for line in read_lines("sample.txt"):
    print(line)Code language: Python (python)

Explanation:

  • with open(filepath, "r") as f: Opens the file inside the generator using a context manager. The with block ensures the file is closed automatically, even if the caller stops iterating early.
  • for line in f: File objects implement the iterator protocol natively. Python reads one line from disk per iteration, so only a single line occupies memory at any given moment.
  • line.rstrip("\n"): Removes the trailing newline character that the file includes at the end of each line. Without this, each printed line would be followed by a blank line.
  • Memory advantage: Calling f.readlines() would load every line into a list at once. This generator approach keeps memory usage flat – a 10 GB log file consumes no more memory than a 1 KB file.

Exercise 12: CSV Row Parser

Problem Statement: Create a generator that yields rows from a CSV file as dictionaries, using the first row as keys.

Purpose: This exercise combines file reading with dictionary construction to produce a practical data-ingestion pattern. Yielding rows as dictionaries makes downstream code more readable, and the generator keeps memory consumption low for large CSV files.

Given Input: A CSV file data.csv with the following content:

name,age,city
Alice,30,New York
Bob,25,London
Carol,35,Sydney

Expected Output:

{'name': 'Alice', 'age': '30', 'city': 'New York'}
{'name': 'Bob', 'age': '25', 'city': 'London'}
{'name': 'Carol', 'age': '35', 'city': 'Sydney'}
▼ Hint
  • Read the first line separately to extract the header keys using next(f), then split it by comma. For each subsequent line, split by comma and use zip(headers, values) to pair keys with values.
  • Pass the zipped pairs to dict() to build each row dictionary, then yield it. Use .strip() on values to remove any surrounding whitespace or newline characters.
▼ Solution & Explanation
# Create the sample CSV file
with open("data.csv", "w") as f:
    f.write("name,age,city\nAlice,30,New York\nBob,25,London\nCarol,35,Sydney\n")

# Generator that parses CSV rows as dictionaries
def csv_row_parser(filepath):
    with open(filepath, "r") as f:
        headers = next(f).strip().split(",")
        for line in f:
            values = line.strip().split(",")
            yield dict(zip(headers, values))

for row in csv_row_parser("data.csv"):
    print(row)Code language: Python (python)

Explanation:

  • next(f): Advances the file iterator by one line and returns it. Calling this before the for loop consumes the header row so subsequent iterations only see data rows.
  • zip(headers, values): Pairs each header with the corresponding value by position, producing tuples such as ('name', 'Alice'). Passing this to dict() builds the row dictionary in one step.
  • .strip().split(","): strip() removes the trailing newline and any surrounding whitespace before split() separates the fields. Skipping strip() would leave "\n" attached to the last value of every row.
  • Note on the standard library: Python’s csv.DictReader handles edge cases such as quoted commas and escaped characters. This manual implementation is intentionally simplified to illustrate the underlying pattern rather than replace csv.DictReader in production code.

Exercise 13: The Pipeline

Problem Statement: Create two generators: one that yields numbers and another that squares them. Pipe the first into the second.

Purpose: This exercise introduces generator pipelines, a composable pattern where the output of one generator feeds directly into the next. Pipelines are a cornerstone of data processing in Python, allowing complex transformations to be built from small, single-purpose generators.

Given Input: numbers = [1, 2, 3, 4, 5]

Expected Output: 1 4 9 16 25

▼ Hint
  • Write a number_producer generator that yields each item from an iterable, and a separate squarer generator that accepts an iterable, iterates over it, and yields each value squared.
  • Chain them by passing the first generator directly as the argument to the second: squarer(number_producer(numbers)). Neither generator runs until the final for loop pulls values.
▼ Solution & Explanation
def number_producer(iterable):
    for item in iterable:
        yield item

def squarer(iterable):
    for num in iterable:
        yield num ** 2

numbers = [1, 2, 3, 4, 5]
pipeline = squarer(number_producer(numbers))

for value in pipeline:
    print(value, end=" ")Code language: Python (python)

Explanation:

  • number_producer(iterable): A pass-through generator that yields each item from any iterable unchanged. In a real pipeline this stage would typically filter or transform the source data before passing it on.
  • squarer(iterable): Accepts any iterable – including another generator – and yields the square of each value. Because it takes an iterable as input, it is decoupled from the source and can be reused with any producer.
  • squarer(number_producer(numbers)): Composes the two generators without executing either. This is lazy composition: no computation happens until the for loop calls next() on pipeline.
  • Pipeline scalability: Additional transformation stages can be added by wrapping further generators around pipeline. Each stage processes one value at a time, so the total memory used stays constant regardless of how many stages exist.

Exercise 14: List Flattener

Problem Statement: Use yield from to create a recursive generator that flattens a nested list (e.g., [1, [2, 3], 4]).

Purpose: This exercise introduces yield from, a keyword that delegates generation to a sub-iterable. Combining it with recursion produces an elegant solution for traversing arbitrarily deep nested structures – a pattern that appears frequently in tree processing and JSON parsing.

Given Input: nested = [1, [2, [3, 4], 5], [6, 7], 8]

Expected Output: 1 2 3 4 5 6 7 8

▼ Hint
  • Loop over each item in the input. If the item is a list, use yield from flatten(item) to recurse into it. Otherwise, yield the item directly.
  • yield from transparently forwards every value produced by the recursive call to the caller, so you do not need a nested loop to collect and re-yield results.
▼ Solution & Explanation
def flatten(nested):
    for item in nested:
        if isinstance(item, list):
            yield from flatten(item)
        else:
            yield item

nested = [1, [2, [3, 4], 5], [6, 7], 8]

for value in flatten(nested):
    print(value, end=" ")Code language: Python (python)

Explanation:

  • isinstance(item, list): Checks whether the current item is itself a list. If it is, the function recurses into it. If it is not, the item is a plain value and is yielded directly.
  • yield from flatten(item): Delegates to a recursive generator call. Every value the inner call yields is passed straight through to the outermost caller, without needing a loop to collect and re-yield each one manually.
  • Arbitrary depth: Because each nested list triggers a new recursive call, the function handles any level of nesting automatically. A three-level nest such as [1, [2, [3, 4]]] is flattened with the same code as a one-level nest.
  • yield from vs. a manual loop: Without yield from, you would need for v in flatten(item): yield v. The yield from syntax is not just shorter – it also correctly forwards send() values and exceptions into the sub-generator, which the manual loop cannot do.

Exercise 15: Batch Processing

Problem Statement: Write a generator that takes an iterable and yields chunks of a specific size (e.g., process a list of 100 items in batches of 10).

Purpose: Batch processing is essential when working with APIs, databases, or large datasets where processing all items at once is impractical. This exercise shows how a generator can group an arbitrary stream of values into fixed-size chunks without loading the full dataset into memory.

Given Input: items = list(range(1, 11)), batch_size = 3

Expected Output:

[1, 2, 3]
[4, 5, 6]
[7, 8, 9]
[10]
▼ Hint
  • Convert the iterable to a list, then use a for loop with range(0, len(items), batch_size) to step through it in increments of batch_size.
  • On each iteration, slice the list with items[i : i + batch_size] and yield the slice. The last slice will naturally be shorter if the total count is not evenly divisible.
▼ Solution & Explanation
def batch(iterable, batch_size):
    items = list(iterable)
    for i in range(0, len(items), batch_size):
        yield items[i : i + batch_size]

for chunk in batch(range(1, 11), 3):
    print(chunk)Code language: Python (python)

Explanation:

  • items = list(iterable): Materialises the iterable into a list so it can be sliced by index. This is necessary because generators and other one-shot iterables do not support slicing directly.
  • range(0, len(items), batch_size): Produces starting indices 0, 3, 6, 9, … stepping by batch_size. Each index marks the beginning of one chunk.
  • items[i : i + batch_size]: Python slicing never raises an IndexError when the upper bound exceeds the list length. The final chunk ([10] in this example) is returned as a shorter slice automatically.
  • Practical use: In real applications, each yielded chunk might be sent as a single bulk API request, inserted as one database transaction, or written as one file segment – making the batch size easy to tune without changing the processing logic.

Exercise 16: Prime Sieve

Problem Statement: Implement a generator that yields prime numbers using the Sieve of Eratosthenes logic.

Purpose: This exercise shows how a classical algorithm can be expressed as a lazy generator. The sieve approach is significantly more efficient than trial division for finding all primes up to a limit, and mapping it to a generator makes the result easy to consume incrementally.

Given Input: limit = 30

Expected Output: 2 3 5 7 11 13 17 19 23 29

▼ Hint
  • Create a boolean list is_prime of length limit + 1, initialised to True. Set indices 0 and 1 to False, then loop from 2 upward: when you find a True entry, mark all its multiples as False using a slice assignment.
  • After the sieve is built, loop over the list and yield every index whose value is still True. The inner marking loop only needs to run while i * i <= limit, since any composite number must have a factor at or below its square root.
  • Use the slice assignment is_prime[i*i::i] = [False] * len(is_prime[i*i::i]) to mark all multiples of i starting from i² in one step.
▼ Solution & Explanation
def prime_sieve(limit):
    is_prime = [True] * (limit + 1)
    is_prime[0] = is_prime[1] = False

    for i in range(2, int(limit ** 0.5) + 1):
        if is_prime[i]:
            is_prime[i * i :: i] = [False] * len(is_prime[i * i :: i])

    for i in range(2, limit + 1):
        if is_prime[i]:
            yield i

for prime in prime_sieve(30):
    print(prime, end=" ")Code language: Python (python)

Explanation:

  • is_prime = [True] * (limit + 1): Allocates a boolean lookup table indexed by number. Every candidate starts as assumed prime; the sieve progressively marks composites as False.
  • range(2, int(limit ** 0.5) + 1): The outer loop only needs to run up to the square root of the limit. Any composite number must have at least one factor at or below its square root, so all composites are guaranteed to be marked before this loop ends.
  • is_prime[i * i :: i] = [False] * ...: Slice assignment marks every multiple of i starting from i² in a single operation. Smaller multiples such as 2i and 3i were already marked by earlier primes, so starting at i² avoids redundant work.
  • Two-phase design: The sieve marks composites first, then a second loop yields surviving primes. The yield appears only in the second loop, so the generator produces output only after the full table is built – a deliberate trade-off of latency for correctness.

Exercise 17: Running Average

Problem Statement: Create a generator that maintains and yields the running average of a stream of numbers.

Purpose: This exercise shows how a generator can carry internal state between yields to perform stateful stream processing. A running average is a common operation in monitoring, analytics, and signal processing, and computing it lazily avoids storing the entire history of values.

Given Input: numbers = [10, 20, 30, 40, 50]

Expected Output: 10.0 15.0 20.0 25.0 30.0

▼ Hint
  • Track two variables across iterations: a running total (sum so far) and a count (number of values seen). Update both on each iteration, then yield total / count.
  • Both variables are initialised to 0 before the loop and persist between yield calls because they live in the generator’s local scope.
▼ Solution & Explanation
def running_average(iterable):
    total = 0
    count = 0
    for value in iterable:
        total += value
        count += 1
        yield total / count

numbers = [10, 20, 30, 40, 50]

for avg in running_average(numbers):
    print(avg, end=" ")Code language: Python (python)

Explanation:

  • total = 0 and count = 0: Both accumulators are declared before the loop. Because they live in the generator’s local scope, their values survive across yield calls and are available on every subsequent iteration.
  • total += value and count += 1: Updated before yielding so the average includes the current value. Swapping the order – yielding before updating – would produce a one-step-behind average.
  • yield total / count: Python 3’s / operator always returns a float, so the output is 10.0 rather than 10 even for whole-number averages. This is consistent with the expected output and avoids surprises when the average is not a whole number.
  • Stateful processing without storage: Only two scalar variables are kept in memory regardless of how many numbers have been processed. Storing all values to recalculate the average from scratch each time would use O(n) memory and O(n) time per step.

Exercise 18: Sliding Window

Problem Statement: Write a generator that yields a sliding window of size n over a sequence (e.g., for [1, 2, 3, 4] with window size 2, it yields (1, 2), (2, 3), (3, 4)).

Purpose: Sliding windows appear in time-series analysis, text n-gram generation, and signal smoothing. This exercise demonstrates how a generator can maintain a small buffer of recent values and expose it as a structured output, combining slicing with lazy production.

Given Input: sequence = [1, 2, 3, 4, 5], n = 3

Expected Output: (1, 2, 3) (2, 3, 4) (3, 4, 5)

▼ Hint
  • Convert the sequence to a list, then loop with range(len(items) - n + 1). On each iteration, slice items[i : i + n] and yield it as a tuple.
  • The upper bound len(items) - n + 1 ensures the last window is exactly size n and does not extend beyond the end of the sequence.
▼ Solution & Explanation
def sliding_window(sequence, n):
    items = list(sequence)
    for i in range(len(items) - n + 1):
        yield tuple(items[i : i + n])

sequence = [1, 2, 3, 4, 5]

for window in sliding_window(sequence, 3):
    print(window, end=" ")Code language: Python (python)

Explanation:

  • items = list(sequence): Materialises the input into a list so it can be accessed by index and sliced. Without this step, a one-shot iterable such as a generator passed as sequence would be exhausted after the first access.
  • range(len(items) - n + 1): Calculates exactly how many full windows fit. For a 5-element list with window size 3, this gives range(3), producing starting indices 0, 1, and 2 – corresponding to the three valid windows.
  • tuple(items[i : i + n]): Slices out the window and converts it to a tuple. Tuples signal that the window is a fixed-size, ordered snapshot rather than a mutable collection, which is the conventional output type for windowed data.
  • Standard library alternative: From Python 3.12 onward, itertools.sliding_window(iterable, n) provides the same behaviour. This implementation makes the underlying index arithmetic explicit and works on all Python 3 versions.

Exercise 19: Log Filter

Problem Statement: Create a generator that parses a log file and only yields lines containing the keyword ERROR.

Purpose: This exercise combines file reading with conditional filtering in a single generator, demonstrating a pattern central to log analysis and data pipelines. Yielding only matching lines keeps memory usage constant and makes the generator composable with downstream stages.

Given Input: A log file app.log with the following content:

INFO: Server started
ERROR: Disk quota exceeded
INFO: Request received
ERROR: Database connection failed
WARNING: High memory usage

Expected Output:

ERROR: Disk quota exceeded
ERROR: Database connection failed
▼ Hint
  • Read the file line by line inside a with open(...) block. For each line, call line.strip() to remove surrounding whitespace, then check if "ERROR" in line before yielding.
  • This is the same file-reading pattern from Exercise 11, extended with a single conditional filter before the yield statement.
▼ Solution & Explanation
# Create the sample log file
log_content = (
    "INFO: Server started\n"
    "ERROR: Disk quota exceeded\n"
    "INFO: Request received\n"
    "ERROR: Database connection failed\n"
    "WARNING: High memory usage\n"
)
with open("app.log", "w") as f:
    f.write(log_content)

# Generator that yields only ERROR lines
def error_filter(filepath, keyword="ERROR"):
    with open(filepath, "r") as f:
        for line in f:
            line = line.strip()
            if keyword in line:
                yield line

for entry in error_filter("app.log"):
    print(entry)Code language: Python (python)

Explanation:

  • keyword="ERROR": A default parameter makes the generator reusable for any keyword – pass keyword="WARNING" and the same function filters for warnings instead. No code changes are needed for different log levels.
  • line = line.strip(): Removes both the trailing newline and any leading or trailing whitespace before the membership check and before yielding. Without this, printed output would include blank lines between entries.
  • if keyword in line: A simple substring check. Only lines that contain the keyword are yielded; all others are silently skipped. This is the generator-as-filter pattern from Exercise 5 applied to file input.
  • Pipeline readiness: Because the generator yields individual strings, it can be piped directly into another generator – for example, one that extracts timestamps or counts occurrences – without any changes to this stage.

Exercise 20: Unique Element Filter

Problem Statement: Write a generator that takes an iterable with duplicates and yields only unique elements in the order they appear.

Purpose: Deduplication while preserving insertion order is a common requirement in data cleaning, URL deduplication, and event stream processing. This exercise shows how a generator can maintain a small auxiliary data structure to track state without exposing it to the caller.

Given Input: items = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]

Expected Output: 3 1 4 5 9 2 6

▼ Hint
  • Maintain a seen set inside the generator. For each item, check if item not in seen: if so, add it to seen and yield it; otherwise skip it.
  • A set is used rather than a list because membership testing with in is O(1) for sets and O(n) for lists, which matters when the iterable is large.
▼ Solution & Explanation
def unique(iterable):
    seen = set()
    for item in iterable:
        if item not in seen:
            seen.add(item)
            yield item

items = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]

for value in unique(items):
    print(value, end=" ")Code language: Python (python)

Explanation:

  • seen = set(): An empty set initialised before the loop. It persists across yields in the generator’s local scope, accumulating every element that has been produced so far.
  • if item not in seen: Set membership testing runs in O(1) average time because sets use hash-based lookup. Checking the same condition against a list would take O(n) time per element, making the overall algorithm O(n²).
  • seen.add(item) then yield item: The item is recorded before being yielded. If the caller stopped iterating early (e.g., with break), any items added to seen up to that point would still correctly block duplicates if iteration resumed later via next().
  • Order preservation: Unlike converting to a set directly – which loses insertion order – this generator yields items in the order they first appear. From Python 3.7 onward, dict.fromkeys(iterable) also preserves order, but the generator approach works element by element without materialising any intermediate collection.

Exercise 21: The Accumulator (send)

Problem Statement: Create a generator (coroutine) that uses yield to receive values via .send() and keeps a running total.

Purpose: This exercise introduces the coroutine side of generators – the ability to push values into a generator using .send(), not just pull values out of it. Coroutines are the foundation of Python’s async/await syntax and are widely used for cooperative multitasking and data pipelines that need to accept input at each step.

Given Input: Send the values 10, 20, 30 one at a time.

Expected Output: 10 30 60

▼ Hint
  • Write a generator with total = 0 before a while True loop. Inside the loop, assign value = yield total: the right-hand side sends the current total out, and the left-hand side receives the next value sent in via .send(). Add value to total after each receive.
  • Before calling .send() with a real value, you must first advance the generator to its first yield by calling next(acc) or acc.send(None). Sending a non-None value to an unprimed generator raises a TypeError.
  • Each .send(value) call resumes the generator, assigns the sent value to value, adds it to total, then loops back to yield total, pausing again and returning the new running total to the caller.
▼ Solution & Explanation
def accumulator():
    total = 0
    while True:
        value = yield total
        if value is None:
            break
        total += value

acc = accumulator()
next(acc)  # prime the coroutine

print(acc.send(10), end=" ")  # 10
print(acc.send(20), end=" ")  # 30
print(acc.send(30), end=" ")  # 60Code language: Python (python)

Explanation:

  • value = yield total: A two-directional yield. The expression to the right of yield is sent out to the caller as the generator’s current output. The value sent back in via .send() is assigned to value on the left-hand side when the generator resumes.
  • next(acc): Primes the coroutine by advancing it to the first yield. At this point total is 0, so the generator pauses and returns 0. The return value is discarded here because we have not sent any data yet.
  • acc.send(10): Resumes the generator and assigns 10 to value. The loop adds it to total, making total = 10, then reaches yield total again, pausing and returning 10 to the caller.
  • if value is None: break: A clean shutdown guard. Calling acc.send(None) or next(acc) after the coroutine is primed sets value to None, which exits the loop and allows the generator to terminate gracefully via StopIteration.

Exercise 22: Custom Zip

Problem Statement: Re-implement the zip() function using iter() and next() to combine two iterables.

Purpose: This exercise deepens your understanding of the iterator protocol by rebuilding a familiar built-in from first principles. It also demonstrates how zip() stops at the shortest iterable and how StopIteration can be used as a natural exit signal from within a generator.

Given Input: a = [1, 2, 3], b = ["a", "b", "c"]

Expected Output: (1, 'a') (2, 'b') (3, 'c')

▼ Hint
  • Call iter() on both inputs to get two iterator objects. Inside a while True loop, call next() on each iterator in sequence and yield a tuple of both results.
  • Wrap the next() calls in a try/except StopIteration block and return inside the except clause. When either iterator is exhausted, StopIteration is raised and the generator exits cleanly, mirroring built-in zip() behaviour.
▼ Solution & Explanation
def custom_zip(iterable_a, iterable_b):
    iter_a = iter(iterable_a)
    iter_b = iter(iterable_b)
    while True:
        try:
            item_a = next(iter_a)
            item_b = next(iter_b)
        except StopIteration:
            return
        yield (item_a, item_b)

a = [1, 2, 3]
b = ["a", "b", "c"]

for pair in custom_zip(a, b):
    print(pair, end=" ")Code language: Python (python)

Explanation:

  • iter_a = iter(iterable_a): Converts each input to a stateful iterator. This ensures the function accepts any iterable – lists, generators, strings, or custom classes – not just sequences that support indexing.
  • try / except StopIteration: return: When either iterator runs out of values, next() raises StopIteration. Catching it and using return inside a generator is the idiomatic way to end iteration early – Python converts the return statement into a StopIteration that the caller receives.
  • yield (item_a, item_b): Produces one paired tuple per loop iteration. The yield is placed after both next() calls succeed, guaranteeing that a partial tuple is never emitted if the second iterator is shorter than the first.
  • Shortest-iterable behaviour: If a has 3 elements and b has 2, the generator stops after 2 pairs – exactly like built-in zip(). For longest-iterable behaviour, Python offers itertools.zip_longest(), which fills missing values with a configurable default.

Exercise 23: Binary Tree Traversal

Problem Statement: Implement an in-order traversal of a Binary Search Tree using a recursive generator.

Purpose: This exercise applies yield from to a recursive data structure, showing how generators can traverse trees without the caller needing to manage a stack or collect results into a list first. The pattern generalises to any recursive structure such as file system trees or JSON documents.

Given Input: A BST built by inserting values in this order: 5, 3, 7, 2, 4, 6, 8

Expected Output: 2 3 4 5 6 7 8

▼ Hint
  • Define a Node class with value, left, and right attributes. Build the BST with an insert method that places smaller values to the left and larger values to the right recursively.
  • In the traversal generator, follow the in-order pattern: yield from inorder(node.left), then yield node.value, then yield from inorder(node.right). Guard each recursive call with if node is not None – or check at the top of the function and return immediately if the node is None.
▼ Solution & Explanation
class Node:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None
    def insert(self, value):
        if value < self.value:
            if self.left is None:
                self.left = Node(value)
            else:
                self.left.insert(value)
        else:
            if self.right is None:
                self.right = Node(value)
            else:
                self.right.insert(value)
def inorder(node):
    if node is None:
        return
    yield from inorder(node.left)
    yield node.value
    yield from inorder(node.right)
root = Node(5)
for val in [3, 7, 2, 4, 6, 8]:
    root.insert(val)
for value in inorder(root):
    print(value, end=" ")Code language: Python (python)

Explanation:

  • if node is None: return: The base case of the recursion. A plain return inside a generator raises StopIteration silently, so the caller never sees the None leaves – iteration simply ends for that branch.
  • yield from inorder(node.left): Delegates all values from the left subtree directly to the outermost caller. Without yield from, a manual loop would be needed to re-yield each value, and send() and throw() semantics would be lost.
  • yield node.value: Produces the current node’s value between the two recursive calls. In-order traversal visits left subtree first, then the node itself, then the right subtree – which for a BST always produces values in ascending sorted order.
  • Why a generator, not a list: A list-based approach collects all values before returning, requiring O(n) extra memory. The generator yields each value as the recursion unwinds, meaning only the call stack depth (O(log n) for a balanced BST) is needed beyond the tree itself.

Exercise 24: Data Throttler

Problem Statement: Create a generator that wraps another iterable but only yields every n-th item.

Purpose: Downsampling a stream by taking every n-th element is a common technique in signal processing, data visualisation, and performance testing. This exercise shows how a thin generator wrapper can add filtering logic to any existing iterable without modifying it.

Given Input: data = list(range(1, 21)), n = 4

Expected Output: 4 8 12 16 20

▼ Hint
  • Use enumerate(iterable, start=1) to pair each item with a 1-based index. Inside the loop, yield the item only when index % n == 0.
  • Starting enumerate at 1 (rather than the default 0) ensures the first yielded item is the n-th element of the input, not the (n-1)-th.
▼ Solution & Explanation
def throttle(iterable, n):
    for index, item in enumerate(iterable, start=1):
        if index % n == 0:
            yield item

data = list(range(1, 21))

for value in throttle(data, 4):
    print(value, end=" ")Code language: Python (python)

Explanation:

  • enumerate(iterable, start=1): Pairs each item with a 1-based counter. Using start=1 means the first item has index 1, so the modulo check index % n == 0 correctly identifies the 4th, 8th, 12th, … elements rather than the 3rd, 7th, 11th.
  • index % n == 0: The modulo operator returns the remainder of dividing index by n. A remainder of zero means the current position is an exact multiple of n, making it a keep position.
  • Composability: Because throttle accepts any iterable, it can wrap a live sensor feed, a file reader, or another generator just as easily as a list. The upstream iterable is never fully materialised, keeping memory usage constant.
  • Standard library alternative: itertools.islice(iterable, n-1, None, n) achieves the same result. The custom generator is more readable for this specific case and avoids importing itertools, but islice is preferable when working within a broader itertools pipeline.

Exercise 25: Generator State Machine

Problem Statement: Use a generator to represent a simple state machine (e.g., a Traffic Light that cycles between Green, Yellow, and Red).

Purpose: State machines model systems where behaviour depends on the current state and transitions follow defined rules. Encoding one as a generator is elegant because yield naturally represents a pause between state transitions, and the generator’s internal scope holds the current state without any external variables.

Given Input: Advance the traffic light through 6 transitions.

Expected Output: Green Yellow Red Green Yellow Red

▼ Hint
  • Store the states in a list or tuple such as ["Green", "Yellow", "Red"]. Use a while True loop with an inner for loop that iterates over the states and yields each one in turn.
  • In the consuming code, call next() on the generator or iterate with a for loop combined with break or itertools.islice to stop after a fixed number of transitions.
▼ Solution & Explanation
def traffic_light():
    states = ["Green", "Yellow", "Red"]
    while True:
        for state in states:
            yield state

light = traffic_light()

for _ in range(6):
    print(next(light), end=" ")Code language: Python (python)

Explanation:

  • states = ["Green", "Yellow", "Red"]: The ordered list of states defines the transition sequence. Changing this list is all that is needed to modify the machine’s behaviour – no logic elsewhere needs to change.
  • while True wrapping for state in states: The inner for loop cycles through all states once; the outer while True restarts it immediately after the last state, producing an endless cycle. The generator never reaches a natural end.
  • yield state: Pauses the generator and hands the current state to the caller. Execution resumes from this exact point on the next next() call, advancing to the following state in the sequence.
  • for _ in range(6): next(light): Pulls exactly 6 states from the infinite generator. The caller controls how many transitions occur; the generator itself has no knowledge of when it will be stopped, which is the clean separation of concerns that makes this pattern reusable.

Exercise 26: Peekable Iterator

Problem Statement: Wrap an iterator in a class that allows you to “peek” at the next value without actually consuming it.

Purpose: Peeking is a fundamental technique in parsers, lexers, and lookahead algorithms, where a decision about the current token depends on what comes next. This exercise teaches you how to extend the standard iterator protocol with additional capabilities by wrapping an existing iterator in a class.

Given Input: data = [10, 20, 30, 40]

Expected Output:

Peek: 10
Consumed: 10
Peek: 20
Consumed: 20
Consumed: 30
Consumed: 40
▼ Hint
  • In __init__, call iter() on the input and eagerly fetch the first value into a _next attribute using next(). Use a sentinel such as a unique object() instance to distinguish “no value cached” from a cached value of None.
  • Implement a peek() method that returns _next without advancing the iterator. In __next__, return the cached _next value and immediately fetch the following value from the underlying iterator into _next to keep it primed. Raise StopIteration when the underlying iterator is exhausted.
▼ Solution & Explanation
_SENTINEL = object()

class PeekableIterator:
    def __init__(self, iterable):
        self._iterator = iter(iterable)
        self._next = next(self._iterator, _SENTINEL)

    def __iter__(self):
        return self

    def peek(self):
        if self._next is _SENTINEL:
            raise StopIteration
        return self._next

    def __next__(self):
        if self._next is _SENTINEL:
            raise StopIteration
        value = self._next
        self._next = next(self._iterator, _SENTINEL)
        return value

data = [10, 20, 30, 40]
pit = PeekableIterator(data)

print("Peek:", pit.peek())
print("Consumed:", next(pit))
print("Peek:", pit.peek())
print("Consumed:", next(pit))
print("Consumed:", next(pit))
print("Consumed:", next(pit))Code language: Python (python)

Explanation:

  • _SENTINEL = object(): A module-level sentinel is a unique object that can never appear as a legitimate value in the data stream. Comparing with is _SENTINEL is safe even when the iterable contains None, 0, or False, which would all cause a naive None-based sentinel to mis-fire.
  • self._next = next(self._iterator, _SENTINEL): The two-argument form of next() returns the default value instead of raising StopIteration when the iterator is exhausted. This keeps the caching logic inside __init__ and __next__ free of try/except blocks.
  • peek(): Returns self._next without advancing the iterator. Calling peek() any number of times between two next() calls always returns the same value, because nothing is consumed.
  • __next__ re-primes immediately: After saving the current _next into value, the method fetches the following item from the underlying iterator before returning. This ensures _next always holds the upcoming value so peek() is always accurate.

Exercise 27: Exception Handler

Problem Statement: Create a generator that uses .throw() to handle specific errors passed into it during execution.

Purpose: The .throw() method lets the caller inject an exception into a paused generator at the point of its current yield. This enables cooperative error handling between a generator and its driver, a pattern used in async frameworks and testing tools to simulate failure conditions mid-stream.

Given Input: A sequence of values 1, 2, 3, with a ValueError injected after the second value.

Expected Output:

Yielded: 1
Yielded: 2
Caught ValueError: simulated error - skipping
Yielded: 3
Generator finished
▼ Hint
  • Wrap the yield statement inside a try/except ValueError block inside the generator. When .throw(ValueError, "message") is called from outside, the exception is raised at the yield point and caught by the except clause, allowing the generator to resume normally.
  • In the driver code, call next(gen) to advance normally, then call gen.throw(ValueError, "message") at the point you want to inject the error. The return value of .throw() is the next yielded value after the exception is handled.
▼ Solution & Explanation
def resilient_generator(values):
    for value in values:
        try:
            yield value
        except ValueError as e:
            print(f"Caught ValueError: {e} - skipping")

gen = resilient_generator([1, 2, 3])

print("Yielded:", next(gen))
print("Yielded:", next(gen))
print("Yielded:", gen.throw(ValueError, "simulated error"))
print("Generator finished")Code language: Python (python)

Explanation:

  • try / except ValueError around yield: Wrapping the yield in a try block is what makes the generator resilient to .throw(). Without this, a thrown exception would propagate uncaught, terminating the generator immediately and raising the exception in the caller’s frame instead.
  • gen.throw(ValueError, "simulated error"): Raises ValueError("simulated error") at the exact line where the generator is paused – inside the try block. The generator’s except clause handles it, prints the message, and the loop continues to the next iteration, yielding 3.
  • Return value of .throw(): Like next(), .throw() returns the next value yielded by the generator after the exception is handled. Printing its return value directly captures 3 without needing a separate next() call.
  • Practical use: In async frameworks such as asyncio, .throw() is used internally to cancel tasks by injecting a CancelledError into a suspended coroutine. Understanding .throw() demystifies how cancellation and timeout logic work beneath the async/await surface.

Exercise 28: Recursive Directory Walker

Problem Statement: Write a generator that yields all file paths in a directory and its subdirectories using yield from.

Purpose: Recursive directory traversal is one of the most common real-world applications of recursive generators. Using yield from keeps the code concise while avoiding the need to accumulate paths in a list, making it suitable for very large directory trees.

Given Input: A temporary directory tree created at runtime:

test_dir/
    file1.txt
    file2.txt
    subdir/
        file3.txt
        file4.txt

Expected Output:

test_dir/file1.txt
test_dir/file2.txt
test_dir/subdir/file3.txt
test_dir/subdir/file4.txt
▼ Hint
  • Use os.listdir() to list entries in the current directory. For each entry, build its full path with os.path.join(), then check with os.path.isfile() or os.path.isdir() to decide whether to yield the path or recurse into it with yield from.
  • Create the test directory structure before running the generator using os.makedirs() and open(..., "w").close(), so the snippet is self-contained and runnable without any manual setup.
▼ Solution & Explanation
import os

# Build the test directory tree
os.makedirs("test_dir/subdir", exist_ok=True)
for filename in ["test_dir/file1.txt", "test_dir/file2.txt",
                  "test_dir/subdir/file3.txt", "test_dir/subdir/file4.txt"]:
    open(filename, "w").close()

# Recursive generator
def walk_files(directory):
    for entry in sorted(os.listdir(directory)):
        full_path = os.path.join(directory, entry)
        if os.path.isfile(full_path):
            yield full_path
        elif os.path.isdir(full_path):
            yield from walk_files(full_path)

for path in walk_files("test_dir"):
    print(path)Code language: Python (python)

Explanation:

  • os.path.join(directory, entry): Constructs a platform-correct full path by combining the parent directory with the entry name. Using string concatenation instead would break on Windows, where the path separator is a backslash.
  • yield full_path vs. yield from walk_files(full_path): Files are yielded directly; directories trigger a recursive call via yield from, which forwards every path produced by the deeper traversal to the outermost caller without any intermediate collection.
  • sorted(os.listdir(...)): os.listdir() returns entries in filesystem order, which is not guaranteed to be alphabetical. Wrapping it in sorted() produces consistent, predictable output regardless of the underlying OS or filesystem.
  • Standard library alternative: os.walk() and pathlib.Path.rglob("*") both provide recursive directory traversal out of the box. This implementation makes the yield from recursion pattern explicit; in production code, pathlib.Path.rglob() is generally preferred for its cleaner API.

Exercise 29: Interleaved Streams

Problem Statement: Write a generator that takes two iterables and yields elements from them alternately.

Purpose: Interleaving streams is a pattern used in merge operations, playlist shuffling, and round-robin task scheduling. This exercise reinforces manual iterator control with iter() and next(), and shows how to handle iterables of unequal length gracefully.

Given Input: a = [1, 2, 3], b = ["a", "b", "c", "d"]

Expected Output: 1 a 2 b 3 c d

▼ Hint
  • Convert both inputs to iterators with iter(). Use a while True loop that alternates between calling next() on each iterator. Wrap each call in a try/except StopIteration block, and when one iterator is exhausted, yield from the remaining iterator to drain it, then return.
  • Use two boolean flags or a single counter to track which iterator ran out first, so you know which one still has remaining items to drain after the loop exits.
▼ Solution & Explanation
def interleave(iterable_a, iterable_b):
    iter_a = iter(iterable_a)
    iter_b = iter(iterable_b)
    while True:
        try:
            yield next(iter_a)
        except StopIteration:
            yield from iter_b
            return
        try:
            yield next(iter_b)
        except StopIteration:
            yield from iter_a
            return

a = [1, 2, 3]
b = ["a", "b", "c", "d"]

for value in interleave(a, b):
    print(value, end=" ")Code language: Python (python)

Explanation:

  • Two separate try/except blocks: Each iterator gets its own guard. This structure ensures that exhaustion of either iterator is caught at the precise moment it occurs – after yielding its last item – rather than at the top of the next loop iteration, which would silently drop one item from the other stream.
  • yield from iter_b then return: When iter_a is exhausted, the remaining items in iter_b are drained in one step. return exits the generator cleanly immediately after, preventing the loop from continuing and attempting to read from the already-exhausted iterator again.
  • Unequal length handling: Because the drain-and-return logic sits inside each except block independently, the generator correctly handles all three cases: both iterables the same length, iter_a shorter, and iter_b shorter – without any length checks upfront.
  • Generalising to n streams: This two-stream approach extends naturally by storing iterators in a list and cycling through them with itertools.cycle, removing exhausted iterators as they raise StopIteration. That pattern underpins round-robin schedulers and multi-source merge utilities.

Exercise 30: Generator Cleanup

Problem Statement: Use a try...finally block within a generator to ensure a resource (like a database connection) is closed even if the generator is closed early.

Purpose: Generators that hold open resources – file handles, network sockets, database cursors – must release them regardless of whether iteration completes normally or is abandoned. This exercise shows how try...finally inside a generator cooperates with the .close() method to guarantee cleanup, mirroring the behaviour of a context manager.

Given Input: A simulated database with rows [("Alice", 30), ("Bob", 25), ("Carol", 35)]. Close the generator after reading only the first row.

Expected Output:

[DB] Connection opened
Row: ('Alice', 30)
[DB] Connection closed
▼ Hint
  • Wrap the entire body of the generator – resource acquisition, the yield loop, and resource release – in a try...finally block. Open (or simulate opening) the resource before the loop, yield rows inside the try, and close the resource in the finally clause.
  • Call gen.close() on the generator object after consuming only the first row. Python responds by throwing a GeneratorExit exception into the paused generator at its current yield point, which causes the finally block to execute before the generator terminates.
▼ Solution & Explanation
class FakeDatabase:
    def __init__(self, rows):
        self.rows = rows

    def connect(self):
        print("[DB] Connection opened")

    def disconnect(self):
        print("[DB] Connection closed")

    def fetch_rows(self):
        return iter(self.rows)

def db_row_generator(database):
    database.connect()
    try:
        for row in database.fetch_rows():
            yield row
    finally:
        database.disconnect()

db = FakeDatabase([("Alice", 30), ("Bob", 25), ("Carol", 35)])
gen = db_row_generator(db)

print("Row:", next(gen))
gen.close()Code language: Python (python)

Explanation:

  • database.connect() before the try: The connection is opened before entering the try block because there is nothing to clean up if the connection itself fails. Only resources that have been successfully acquired need to be released in finally.
  • try / finally around the yield loop: The finally clause runs unconditionally – whether the loop finishes normally, an exception propagates out, or the generator is closed early via .close(). This is the same guarantee provided by a with statement and its __exit__ method.
  • gen.close(): Throws a GeneratorExit exception into the generator at its current yield point. The generator must not yield any further values in response – it should only clean up and return. The finally block executes, disconnect() is called, and the generator terminates.
  • Relation to context managers: A generator decorated with @contextlib.contextmanager uses exactly this try/finally pattern to implement __enter__ and __exit__. Everything before the single yield runs on entry; the finally clause runs on exit, whether the with block raised an exception or completed normally.

Filed Under: Python, Python Exercises

Did you find this page helpful? Let others know about it. Sharing helps me continue to create free Python resources.

TweetF  sharein  shareP  Pin

About Vishal

I’m Vishal Hule, the Founder of PYnative.com. As a Python developer, I enjoy assisting students, developers, and learners. Follow me on Twitter.

Related Tutorial Topics:

Python Python Exercises

All Coding Exercises:

C Exercises
C++ Exercises
Python Exercises

Python Exercises and Quizzes

Free coding exercises and quizzes cover Python basics, data structure, data analytics, and more.

  • 15+ Topic-specific Exercises and Quizzes
  • Each Exercise contains 25+ questions
  • Each Quiz contains 25 MCQ
Exercises
Quizzes

Leave a Reply Cancel reply

your email address will NOT be published. all comments are moderated according to our comment policy.

Use <pre> tag for posting code. E.g. <pre> Your entire code </pre>

In: Python Python Exercises
TweetF  sharein  shareP  Pin

  Python Exercises

  • All Python Exercises
  • Basic Exercises for Beginners
  • Intermediate Python Exercises
  • Input and Output Exercises
  • Loop Exercises
  • Functions Exercises
  • String Exercises
  • List Exercises
  • Dictionary Exercises
  • Set Exercises
  • Tuple Exercises
  • Data Structure Exercises
  • Date and Time Exercises
  • OOP Exercises
  • File Handling Exercises
  • Iterators & Generators Exercises
  • Regex Exercises
  • Python JSON Exercises
  • Random Data Generation Exercises
  • NumPy Exercises
  • Pandas Exercises
  • Matplotlib Exercises
  • Python Database Exercises

 Explore Python

  • Python Tutorials
  • Python Exercises
  • Python Quizzes
  • Python Interview Q&A
  • Python Programs

All Python Topics

Python Basics Python Exercises Python Quizzes Python Interview Python File Handling Python OOP Python Date and Time Python Random Python Regex Python Pandas Python Databases Python MySQL Python PostgreSQL Python SQLite Python JSON

About PYnative

PYnative.com is for Python lovers. Here, You can get Tutorials, Exercises, and Quizzes to practice and improve your Python skills.

Follow Us

To get New Python Tutorials, Exercises, and Quizzes

  • Twitter
  • Facebook
  • Sitemap

Explore Python

  • Learn Python
  • Python Basics
  • Python Databases
  • Python Exercises
  • Python Quizzes
  • Online Python Code Editor
  • Python Tricks

Coding Exercises

  • C Exercises
  • C++ Exercises
  • Python Exercises

Legal Stuff

  • About Us
  • Contact Us

We use cookies to improve your experience. While using PYnative, you agree to have read and accepted our:

  • Terms Of Use
  • Privacy Policy
  • Cookie Policy

Copyright © 2018–2026 pynative.com