Python Iterators and Generators Practice Questions
Correct
0%
What is the fundamental difference between an Iterable (like a list) and an Iterator in Python?
To master iteration, you must understand these two distinct roles:
1. The Iterable (The Container)
An Iterable is anything you can loop over (a list, a string, a dictionary). It has an __iter__ method that, when called, returns a brand new "cursor" or "bookmarker."
2. The Iterator (The Bookmarker)
The Iterator is that bookmarker. It keeps track of where you are in the sequence. It has a __next__ method that gives you the next item every time you call it.
Analogy: Think of a List as a book (the Iterable) and the Iterator as a physical bookmark. You can have many bookmarks in one book, each at a different page!
Which two "magic" (dunder) methods must a class implement to be considered a full Iterator?
In Python, the Iteration Protocol is defined by two specific methods:
__iter__: This method should return the iterator object itself. it is required so that an iterator can also be used in for loops.
__next__: This method must return the next value in the sequence. If there are no more items, it must raise a StopIteration exception.
# A simple Iterator structure:
class MyIterator:
def __iter__(self):
return self
def __next__(self):
# logic to return next value...
pass
What happens when you call the next() function on an iterator that has already reached the end of its sequence?
Unlike some other languages that return a "null" value, Python uses Exceptions to signal the end of a sequence.
How the Loop Sees It:
When you use a for loop, Python is actually calling next() behind the scenes. When StopIteration is raised, the for loop catches it and stops the loop gracefully.
Key Takeaway:StopIteration is not an "error" in the bad sense; it is a signal that the iterator is exhausted.
Why would you choose to use a Generator (which returns an iterator) instead of a List when dealing with 10 million integers?
This is the "Superpower" of Iterators and Generators: Memory Efficiency.
The Memory Difference:
List: To store 10 million integers, Python must allocate enough RAM to hold every single number simultaneously. This could take hundreds of Megabytes.
Generator: It only stores the formula to create the next number and the current state. It uses the same tiny amount of memory whether it's generating 10 numbers or 10 billion numbers.
This is known as Lazy Evaluation: don't do the work until you absolutely need the result!
Look at the following code. What will be the final output?
nums = [10, 20, 30]
it = iter(nums)
next(it)
print(next(it))
To solve this, you must track the internal state (the cursor) of the iterator.
Step-by-Step Execution:
it = iter(nums): The iterator starts before the first element.
next(it): The cursor moves to the first element (10) and returns it, but we don't print it.
next(it): The cursor moves to the second element (20).
print(...): The value 20 is printed.
Note: Once an iterator moves forward, it cannot move backward. It is a one-way street!
What happens to the local variables inside a generator function when it reaches a yield statement?
This is the "magic" that separates a Generator from a Regular Function.
Suspension vs. Termination
Return: When a function hits return, it is finished. Its local scope is destroyed.
Yield: When a generator hits yield, it "pauses." Python takes a snapshot of the current local variables and where the code stopped. When you call next() again, it "wakes up" and continues from that exact spot.
Key Point: This is why generators can maintain state (like a counter) without using global variables or class attributes.
You are comparing two ways to process 100 million squares. What is the key difference in how Python handles these two lines of code?
# Line A
sq_list = [x**2 for x in range(100_000_000)]
# Line B
sq_gen = (x**2 for x in range(100_000_000))
This exercise highlights the difference between Eager Evaluation and Lazy Evaluation.
Memory Footprint Analysis:
Line A (List Comprehension): Square brackets [] tell Python to execute the loop 100 million times right now and store every result in a list. This will likely cause a MemoryError on most personal computers.
Line B (Generator Expression): Parentheses () create a Generator Object. It doesn't calculate any squares yet. It only stores the "rule" for squaring numbers. It uses the same tiny amount of RAM regardless of the range size.
Key Takeaway: Use parentheses () for large data streams where you only need to look at each item once.
What is the output of the following code? Pay close attention to how many times the data is being accessed.
A critical rule of Iterators and Generators is that they are One-Time Use.
The Concept of Exhaustion:
Think of an iterator as a "stream" of water. Once the water has flowed past a certain point, it is gone. You cannot "reset" a generator to the beginning.
During the First Pass, the generator runs until it raises StopIteration.
During the Second Pass, the generator is already "exhausted." When the for loop asks for the next value, the generator immediately says "I'm done."
Solution: If you need to iterate over the data twice, you must either convert it to a list (data = list(get_data())) or create a fresh generator instance for the second loop.
In Python 3.3+, the yield from syntax was introduced. What is the primary purpose of yield from in the following example?
The yield from expression is a "cleaner" way to handle nested iterators.
Before yield from:
You would have to write a loop inside your generator:
for item in sub_gen():
yield item
With yield from:
Python handles the link between the main_gen and the sub_gen automatically. It is more efficient and allows for advanced features like passing data directly into the sub-generator (used in coroutines).
Output:[0, 1, 2, 3]. Notice how it feels like one single sequence!
Consider a custom iterator class. What is the benefit of defining __iter__ to return self?
class MyRange:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current >= self.end:
raise StopIteration
val = self.current
self.current += 1
return val
In Python, every Iterator should also be an Iterable.
The Logic:
A for loop first calls iter() on whatever object you give it.
By having __iter__ return self, the iterator tells the loop: "I am already my own bookmark/cursor. You can start calling __next__ on me immediately."
Without this method, your class would strictly be an iterator, but it would fail when used in a standard for loop, which is counter-intuitive for most Python developers.
When using a generator to process a large database cursor or file, what happens if you break out of a for loop before the generator has finished?
def stream_data():
try:
yield "Deep data..."
finally:
print("Cleaning up resources!")
for item in stream_data():
print(item)
break
Generators are designed to be "robust," even when they aren't fully exhausted.
Resource Safety:
If you break a loop or an exception occurs, the generator object eventually gets destroyed by the Garbage Collector. When this happens, Python is smart enough to "throw" a GeneratorExit exception into the generator.
This allows the generator to run any finally blocks or with statements it was currently inside.
This is why generators are excellent for managing file handles or network connections.
Tip: Always use try/finally inside complex generators to ensure your "cleanup" code runs, even if the user stops the loop halfway.
The .send() method allows you to pass a value back into a generator. In the following code, why is the first call to next() (or send(None)) mandatory before sending a real value?
def double_inputs():
while True:
received = yield
yield received * 2
gen = double_inputs()
# gen.send(10) <-- This would raise a TypeError
next(gen)
print(gen.send(10))
When a generator function is called, it returns a generator object but no code is executed yet.
The Concept of "Priming":
The .send() method sends a value to the result of the current yield expression. If the generator hasn't reached a yield yet, there is nowhere for the value to go!
The first next(gen) starts the function and runs it until it hits the first yield.
Once it is "suspended" at the yield, it is ready to receive data via .send().
Note: Calling gen.send(None) is equivalent to next(gen) and is often used to prime coroutines.
In modern Python (3.3+), generator functions can actually use the return statement. How is the returned value accessed when the generator finishes?
def task():
yield "Working"
return "Done!"
t = task()
next(t)
try:
next(t)
except StopIteration as e:
print(e.value)
This is a subtle but vital feature used heavily in asyncio and complex generator pipelines.
Return vs Yield:
yield: Produces a value for the iteration sequence.
return: Signals the final result of the generator's computation.
When return "Done!" is executed, Python raises StopIteration as usual to stop any loops, but it attaches the string "Done!" to the exception instance. This allows the caller to retrieve a final status message or calculation result after the stream of data has finished.
What happens to the try...finally block in a generator if the generator object is deleted (e.g., via del gen) while it is still suspended at a yield?
This is how Python ensures Resource Integrity.
Generator Life-Cycle:
If a generator is closed (either explicitly with .close() or via Garbage Collection), Python injects a GeneratorExit exception at the point where the generator was paused.
def my_gen():
try:
yield 1
finally:
print("Cleanup!") # This runs even if we 'del' the generator
This mechanism is why it is safe to use with open(...) statements inside generators; the file will be closed properly even if you stop iterating early!
What is the output of this code, which uses a generator to maintain a "running total" via .send()?
def accumulator():
total = 0
while True:
value = yield total
if value is None: break
total += value
acc = accumulator()
next(acc) # Prime
acc.send(10)
print(acc.send(5))
This exercise tests your ability to track state across multiple suspensions.
The Sequence:
next(acc): Runs until yield total. Returns 0. Suspends.
acc.send(10): 10 becomes the result of yield total. total becomes 10. Loops back to yield total. Returns 10. Suspends.
acc.send(5): 5 becomes the result of yield total. total becomes 15 (10 + 5). Loops back to yield total. Returns 15.
Crucial Logic: The value returned by send() is the next value yielded, not the value you just sent!
When using yield from to delegate to a sub-generator, how can the main generator capture the value returned by the sub-generator's return statement?
This is the "elegant" way Python handles sub-routine results in 3.3+.
The yield from Expression:
The yield from syntax is not just a loop; it's an expression that evaluates to the return value of the sub-generator.
def sub():
yield 1
return "Finished"
def main():
res = yield from sub()
print(f"Sub said: {res}")
# list(main()) will print "Sub said: Finished"
This allows generators to behave like lightweight threads or coroutines that can produce a stream of data and then "hand back" a final status report to their parent.
Quick Recap of Python Iterators and Generators Concepts
If you are not clear on the concepts of Iterators and Generators, you can quickly review them here before practicing the exercises. This recap highlights the essential points and logic to help you solve problems confidently.
Python Iterators and Generators — Definition and Usage
In Python, an Iterator is an object that allows you to traverse through a collection of data, such as a list or a tuple, one element at a time. A Generator is a simpler way to create these iterators using a special function that "yields" values rather than returning them all at once.
This approach is fundamental to Python's efficiency. Instead of loading an entire dataset into your computer's memory, iterators and generators allow you to process data "on the fly," which is essential when working with large files or infinite data streams.
Why Use Iterators & Generators — Key Benefits
The primary advantage of using iterators and generators is the shift from "eager evaluation" (processing everything now) to "lazy evaluation" (processing only what is needed). This results in significantly faster and more stable applications.
Benefit
Explanation
Memory Efficiency
They only store one item in memory at a time, making them perfect for massive datasets.
Lazy Evaluation
Values are generated only when requested, speeding up the initial program startup.
Infinite Sequences
Allows you to represent data streams that never end, such as live sensor feeds.
Cleaner Code
Generators eliminate the need for complex class-based logic for simple loops.
By using these tools, you can handle files that are larger than your computer's RAM because Python never tries to load the whole file at once.
Understanding Iterators (The Iterator Protocol)
For an object to be considered an iterator in Python, it must follow the Iterator Protocol. This requires the implementation of two specific magic methods: __iter__() and __next__().
Method
Role
__iter__()
Returns the iterator object itself. This is called when a loop starts.
__next__()
Once the collection is empty, the __next__() method must raise a StopIteration exception to tell the loop to finish.
Example of a custom iterator for tracking navigation points:
class flight_path:
def __init__(self, coordinates):
self.points = coordinates
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index < len(self.points):
location = self.points[self.index]
self.index += 1
return location
raise StopIteration
route = flight_path(["Berlin", "Tokyo", "Sydney"])
for city in route:
print(city)
The Power of Generators
A Generator is a special type of function that returns an iterator object. Unlike regular functions that use return to send back a value and terminate, generators use the yield keyword. This allows the function to "pause" its state and resume exactly where it left off when the next value is requested.
Key Difference: When a function returns, it is finished. When a generator yields, it "freezes" its variables and waits for the next call.
def harvest_yield_generator(crop_list):
for crop in crop_list:
# The function pauses here and yields the value
yield f"Harvested: {crop}"
# When called again, it resumes right here
crops = ["Wheat", "Barley", "Corn"]
worker = harvest_yield_generator(crops)
print(next(worker)) # Output: Harvested: Wheat
print(next(worker)) # Output: Harvested: Barley
Generator Expressions: For simple tasks, you don't even need a full function. You can create a generator on a single line using parentheses. This is significantly more memory-efficient than a standard list comprehension.
# List comprehension: Loads 1 million items into RAM immediately
sq_list = [x**2 for x in range(1000000)]
# Generator expression: Creates items only as you loop through them
sq_gen = (x**2 for x in range(1000000))
Practical Comparison: Iterator vs. Generator
While both tools allow you to loop over data, they serve different purposes. Choosing the right one depends on the complexity of your logic and how much control you need over the object's state.
Feature
Iterator (Class-based)
Generator (Function-based)
Implementation
Requires a class with __iter__ and __next__.
Requires a function using the yield keyword.
State Management
Handled manually via instance variables (e.g., self.index).
Handled automatically by Python (it "remembers" where it stopped).
Complexity
More code, but ideal for complex tracking and custom methods.
Concise, highly readable, and faster to implement.
Best Practices With Iterators and Generators
Favor Generators for Simplicity: In 90% of cases, a generator function is more readable and efficient than writing a custom iterator class.
Use Generator Expressions for Transformations: If you are just transforming data (like squaring numbers or filtering strings), use the one-line (x for x in data) syntax.
Don't Re-use Generators: Remember that generators are "exhaustible." Once you have looped through them, they are empty. If you need the data again, you must create a new generator instance.
Avoid Converting to Lists: If you have a generator, don't immediately call list(my_generator) unless you absolutely need the whole list. Doing so defeats the purpose of memory efficiency.
Handle Large Files with yield: When reading large log files or CSVs, yield one line at a time to prevent your application from crashing due to memory limits.
Summary: Key Points
Iterators follow a protocol requiring __iter__ and __next__ methods.
Generators simplify iterator creation by using the yield keyword to produce values lazily.
Lazy evaluation ensures that items are only calculated when they are actually needed.
Generators significantly reduce the memory footprint of your Python applications.
The StopIteration exception is the standard signal that an iterator has reached the end of its data.
Test Your Python Iterators and Generators Knowledge
Practicing Python Iterators and Generators? Don’t forget to test yourself later in our Python Quiz.
About This Exercise: Python – Iterators and Generators
At some point, every Python developer runs into the same problem: the code works perfectly on small data, but everything falls apart when the input grows. Memory usage spikes, performance drops, and scripts that once felt clean suddenly feel fragile. At Solviyo, this is exactly where we see Python iterators and generators stop being “advanced topics” and start becoming essential tools.
Iterators and generators sit at the core of writing efficient, scalable Python code. Instead of loading all data into memory at once, they allow your program to process values one step at a time using lazy evaluation. That means Python only computes the next value when it’s actually needed. In real-world scenarios—such as reading large files, streaming API data, or processing logs—this approach isn’t just an optimization; it’s often a necessity.
We designed these Python exercises to help you move beyond basic loops and develop a real understanding of how iteration works under the hood. You’ll explore how Python manages state, how execution pauses and resumes, and how the yield keyword fundamentally changes a function’s behavior. Our goal is not rote learning, but building intuition around how data flows through efficient Python programs.
What You Will Learn
This section is structured to turn memory management theory into practical coding skill. Through our carefully crafted Python exercises with answers, we focus on:
The Iterator Protocol: Understanding __iter__ and __next__, and how Python internally advances through data.
Generator Functions: Using yield to produce values incrementally while preserving execution state.
Generator Expressions: Writing clean, memory-efficient alternatives to list comprehensions.
StopIteration Handling: Learning how Python signals the end of an iteration sequence.
Stateful Execution: Seeing how generators pause and resume without losing context.
Why This Topic Matters
In production environments, efficiency is non-negotiable. Generators allow you to process massive or even infinite data streams with a constant memory footprint. From our experience, they also lead to cleaner, more maintainable code by separating data generation from data consumption. Mastering this topic is a meaningful step toward writing professional, scalable Python.
Start Practicing
Every exercise on Solviyo includes detailed explanations and carefully written answers to help you understand not just what works, but why it works. If your goal is to write faster, leaner, and more reliable Python code, this section will push you in the right direction.
Need a Quick Refresher?
Jump back to the Python Cheat Sheet to review concepts before solving more challenges.