Python iterators and generators practice — 3 real programs that make them tangible
In the previous article we covered Python iterators and generators theory. Now it’s time to build real programs where generators aren’t just a curiosity — they’re the right tool. In this article we implement three programs: an infinite Fibonacci generator, a prime number generator and a custom range iterator class. Each one demonstrates a different aspect of the iterator protocol in action.
Table of Contents
Python iterators and generators practice — Program 1: Infinite Fibonacci generator
The Fibonacci sequence is the canonical example for generators because it’s naturally infinite — there’s no last Fibonacci number. A list-based approach would require deciding in advance how many numbers you want. A generator produces them on demand forever.
def fibonacci():
"""
Infinite Fibonacci sequence generator.
Produces: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
"""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
def take(generator, n):
"""Take the first n values from a generator."""
for _ in range(n):
yield next(generator)
def take_while(generator, condition):
"""Take values from generator while condition is True."""
for value in generator:
if not condition(value):
break
yield value
def fibonacci_at(n):
"""Return the nth Fibonacci number (0-indexed)."""
gen = fibonacci()
for _ in range(n):
next(gen)
return next(gen)
def fibonacci_index(target):
"""Return the index of the first Fibonacci number >= target."""
gen = fibonacci()
for i, value in enumerate(gen):
if value >= target:
return i, value
# ─── Demo ────────────────────────────────────────────
print('=== FIBONACCI GENERATOR ===\n')
# First 10 Fibonacci numbers
fib = fibonacci()
first_10 = list(take(fib, 10))
print(f'First 10: {first_10}')
# All Fibonacci numbers below 100
fib2 = fibonacci()
below_100 = list(take_while(fib2, lambda x: x < 100))
print(f'Below 100: {below_100}')
# The 20th Fibonacci number (0-indexed)
print(f'F(20) = {fibonacci_at(20)}')
# First Fibonacci number >= 1000
idx, value = fibonacci_index(1000)
print(f'First Fibonacci >= 1000: F({idx}) = {value}')
# Golden ratio approximation (approaches phi = 1.618...)
import itertools
fib3 = fibonacci()
next(fib3) # skip 0
ratios = []
for _ in range(15):
a = next(fib3)
b = next(fib3)
if a > 0:
ratios.append(round(b / a, 6))
print(f'\nGolden ratio approximations (F(n+1)/F(n)):')
for r in ratios[-5:]:
print(f' {r}')
print(f' (φ = {(1 + 5**0.5) / 2:.6f})')
# Sum of even Fibonacci numbers below 4 million
fib4 = fibonacci()
even_fib_sum = sum(
x for x in take_while(fib4, lambda x: x < 4_000_000)
if x % 2 == 0
)
print(f'\nSum of even Fibonacci numbers below 4,000,000: {even_fib_sum:,}')
# Memory comparison
import sys
fib_gen = fibonacci()
fib_list = list(take(fibonacci(), 10_000)) # 10k numbers as list
print(f'\n--- Memory comparison ---')
print(f'Generator object: {sys.getsizeof(fib_gen)} bytes')
print(f'List of 10k terms: {sys.getsizeof(fib_list):,} bytes')
Output:
=== FIBONACCI GENERATOR === First 10: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] Below 100: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89] F(20) = 6765 First Fibonacci >= 1000: F(16) = 1597 Golden ratio approximations (F(n+1)/F(n)): 1.617647 1.618056 1.617978 1.618026 1.618018 (φ = 1.618034) Sum of even Fibonacci numbers below 4,000,000: 4,613,732 --- Memory comparison --- Generator object: 192 bytes List of 10k terms: 89,656 bytes
The while True in fibonacci() is intentional — the generator is genuinely infinite. The caller decides how many values to consume using take, take_while, or an explicit for loop with a break. The golden ratio approximation emerges naturally from successive Fibonacci ratios — each pair of adjacent terms gets closer to φ = 1.618034. This is a mathematical fact that generators let you observe lazily without pre-computing a fixed number of terms.
Python iterators and generators practice — Program 2: Prime number generator
A prime generator shows two important techniques: using one generator inside another, and the difference between checking primality with a list (must store all primes so far) versus checking with a generator pipeline.
def is_prime(n):
"""Check if n is prime."""
if n < 2:
return False
if n == 2:
return True
if n % 2 == 0:
return False
i = 3
while i * i <= n:
if n % i == 0:
return False
i += 2
return True
def primes():
"""Infinite generator of prime numbers: 2, 3, 5, 7, 11, ..."""
yield 2
n = 3
while True:
if is_prime(n):
yield n
n += 2 # only check odd numbers
def twin_primes():
"""Generator of twin prime pairs: (3,5), (5,7), (11,13), ..."""
gen = primes()
prev = next(gen)
for curr in gen:
if curr - prev == 2:
yield (prev, curr)
prev = curr
def prime_gaps():
"""Generator of gaps between consecutive primes."""
gen = primes()
prev = next(gen)
for curr in gen:
yield (prev, curr, curr - prev)
prev = curr
def nth_prime(n):
"""Return the nth prime number (1-indexed)."""
gen = primes()
for _ in range(n - 1):
next(gen)
return next(gen)
def primes_in_range(low, high):
"""Generator of primes in [low, high]."""
return (p for p in range(max(2, low), high + 1) if is_prime(p))
def prime_factorisation(n):
"""Return the prime factorisation of n as a generator."""
if n < 2:
return
d = 2
while d * d <= n:
while n % d == 0:
yield d
n //= d
d += 1
if n > 1:
yield n
# ─── Demo ────────────────────────────────────────────
print('=== PRIME GENERATOR ===\n')
# First 20 primes
gen = primes()
first_20 = [next(gen) for _ in range(20)]
print(f'First 20 primes:\n {first_20}')
# Primes between 100 and 150
print(f'\nPrimes between 100 and 150:')
print(f' {list(primes_in_range(100, 150))}')
# 100th prime
print(f'\n100th prime: {nth_prime(100)}')
# Twin primes (first 10 pairs)
print(f'\nFirst 10 twin prime pairs:')
twins = twin_primes()
for _ in range(10):
print(f' {next(twins)}')
# Largest prime gap below 100
print(f'\nLargest prime gap below 100:')
max_gap = max(
(gap for p, q, gap in prime_gaps() if q <= 100),
default=0
)
# Find which gap it is
for p, q, gap in prime_gaps():
if q > 100:
break
if gap == max_gap:
print(f' Gap of {gap} between {p} and {q}')
# Prime factorisation
numbers = [12, 60, 100, 360, 1024, 9999]
print(f'\nPrime factorisation:')
for n in numbers:
factors = list(prime_factorisation(n))
print(f' {n} = {" × ".join(map(str, factors))}')
# Goldbach's conjecture demonstration
# (every even number > 2 is the sum of two primes)
print(f'\nGoldbach conjecture (first 10 even numbers > 2):')
prime_set = set(list(primes_in_range(2, 100)))
for n in range(4, 24, 2):
for p in sorted(prime_set):
if n - p in prime_set:
print(f' {n} = {p} + {n-p}')
break
# Memory
import sys
prime_gen = primes()
print(f'\nPrime generator size: {sys.getsizeof(prime_gen)} bytes')
print(f'(regardless of how many primes it produces)')
Output:
=== PRIME GENERATOR === First 20 primes: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71] Primes between 100 and 150: [101, 103, 107, 109, 113, 127, 131, 137, 139, 149] 100th prime: 541 First 10 twin prime pairs: (3, 5) (5, 7) (11, 13) (17, 19) (29, 31) (41, 43) (59, 61) (71, 73) (101, 103) (107, 109) Largest prime gap below 100: Gap of 8 between 89 and 97 Prime factorisation: 12 = 2 × 2 × 3 60 = 2 × 2 × 3 × 5 100 = 2 × 2 × 5 × 5 360 = 2 × 2 × 2 × 3 × 3 × 5 1024 = 2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 × 2 9999 = 3 × 3 × 11 × 101 Prime generator size: 192 bytes
The twin_primes generator wraps primes() — it consumes two prime generators to find pairs that differ by 2. prime_gaps wraps primes() differently — it tracks consecutive pairs to find the gap between them. Both generators compose cleanly because they’re lazy — no prime is computed until it’s needed. The generator expression (p for p in range(max(2, low), high + 1) if is_prime(p)) in primes_in_range is concise and memory-efficient for bounded ranges.
Python iterators and generators practice — Program 3: Custom range iterator class
This program builds a full iterator class that mimics Python’s built-in range — but with extra features like reversibility, slicing support and infinite mode.
class SmartRange:
"""
A custom range iterator with extra features.
Mimics range() but adds reverse iteration, infinite mode,
and step validation.
"""
def __init__(self, start, stop=None, step=1, infinite=False):
if stop is None:
start, stop = 0, start
if step == 0:
raise ValueError('step cannot be zero')
if not infinite and step > 0 and start >= stop:
# Empty range — valid, just produces nothing
pass
if not infinite and step < 0 and start <= stop:
pass
self._start = start
self._stop = stop
self._step = step
self._infinite = infinite
@property
def start(self):
return self._start
@property
def stop(self):
return self._stop
@property
def step(self):
return self._step
def __iter__(self):
return SmartRangeIterator(
self._start, self._stop, self._step, self._infinite
)
def __reversed__(self):
"""Support reversed() — iterate in reverse."""
if self._infinite:
raise TypeError('Cannot reverse an infinite range')
# Calculate the actual last value
if self._step > 0:
last = self._start + ((self._stop - self._start - 1)
// self._step) * self._step
else:
last = self._start + ((self._stop - self._start + 1)
// self._step) * self._step
return SmartRangeIterator(last, self._start - 1, -self._step)
def __len__(self):
if self._infinite:
raise TypeError('Infinite range has no length')
if self._step > 0:
length = max(0, (self._stop - self._start + self._step - 1)
// self._step)
else:
length = max(0, (self._stop - self._start + self._step + 1)
// self._step)
return length
def __contains__(self, value):
if not isinstance(value, int):
return False
if self._step > 0:
return (self._start <= value < self._stop and
(value - self._start) % self._step == 0)
else:
return (self._start >= value > self._stop and
(value - self._start) % self._step == 0)
def __bool__(self):
return self._infinite or len(self) > 0
def __str__(self):
if self._infinite:
return f'SmartRange({self._start}, ∞, step={self._step})'
return (f'SmartRange({self._start}, {self._stop}, '
f'step={self._step})')
def __repr__(self):
return str(self)
def take(self, n):
"""Take the first n elements."""
result = []
for i, value in enumerate(self):
if i >= n:
break
result.append(value)
return result
def skip(self, n):
"""Return a new SmartRange starting n steps ahead."""
new_start = self._start + n * self._step
return SmartRange(new_start, self._stop, self._step, self._infinite)
def filter(self, predicate):
"""Lazy filter — generator that yields values matching predicate."""
for value in self:
if predicate(value):
yield value
def map(self, func):
"""Lazy map — generator applying func to each value."""
for value in self:
yield func(value)
class SmartRangeIterator:
"""Iterator for SmartRange."""
def __init__(self, start, stop, step, infinite=False):
self._current = start
self._stop = stop
self._step = step
self._infinite = infinite
def __iter__(self):
return self
def __next__(self):
if not self._infinite:
if self._step > 0 and self._current >= self._stop:
raise StopIteration
if self._step < 0 and self._current <= self._stop:
raise StopIteration
value = self._current
self._current += self._step
return value
# ─── Demo ────────────────────────────────────────────
print('=== SMART RANGE ITERATOR ===\n')
# Basic usage — like range()
r = SmartRange(1, 11)
print(f'SmartRange(1, 11): {list(r)}')
r2 = SmartRange(0, 20, 3)
print(f'SmartRange(0,20,3): {list(r2)}')
r3 = SmartRange(10, 0, -2)
print(f'SmartRange(10,0,-2): {list(r3)}')
# Length and contains
r4 = SmartRange(0, 100, 5)
print(f'\nSmartRange(0,100,5):')
print(f' Length: {len(r4)}')
print(f' 25 in range: {25 in r4}')
print(f' 26 in range: {26 in r4}')
# Reversed
print(f'\nReversed SmartRange(1, 6):')
for v in reversed(SmartRange(1, 6)):
print(f' {v}', end=' ')
print()
# take and skip
r5 = SmartRange(1, 100)
print(f'\nFirst 5 of SmartRange(1,100): {r5.take(5)}')
print(f'Skip 5, take 5: {r5.skip(5).take(5)}')
# Lazy filter and map
evens = list(SmartRange(1, 21).filter(lambda x: x % 2 == 0))
print(f'\nEven numbers 1-20: {evens}')
squares = list(SmartRange(1, 8).map(lambda x: x ** 2))
print(f'Squares 1-7: {squares}')
# Infinite mode
inf_range = SmartRange(0, None, 3, infinite=True)
print(f'\nInfinite SmartRange (step=3): {inf_range}')
print(f'First 8 values: {inf_range.take(8)}')
print(f'First even: ', end='')
for v in inf_range:
if v % 2 == 0:
print(v)
break
# Chaining operations (generator pipeline)
print('\nPipeline: squares of odd numbers from 1 to 20:')
result = list(
SmartRange(1, 21)
.filter(lambda x: x % 2 != 0)
)
result_squares = [x**2 for x in result]
print(f' {result_squares}')
# bool
empty = SmartRange(5, 5)
non_empty = SmartRange(1, 5)
print(f'\nbool(SmartRange(5,5)) = {bool(empty)}')
print(f'bool(SmartRange(1,5)) = {bool(non_empty)}')
Output:
=== SMART RANGE ITERATOR === SmartRange(1, 11): [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] SmartRange(0,20,3): [0, 3, 6, 9, 12, 15, 18] SmartRange(10,0,-2): [10, 8, 6, 4, 2] SmartRange(0,100,5): Length: 20 25 in range: True 26 in range: False Reversed SmartRange(1, 6): 5 4 3 2 1 First 5 of SmartRange(1,100): [1, 2, 3, 4, 5] Skip 5, take 5: [6, 7, 8, 9, 10] Even numbers 1-20: [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] Squares 1-7: [1, 4, 9, 16, 25, 36, 49] Infinite SmartRange (step=3): SmartRange(0, ∞, step=3) First 8 values: [0, 3, 6, 9, 12, 15, 18, 21] First even: 0 Pipeline: squares of odd numbers from 1 to 20: [1, 9, 25, 49, 81, 121, 169, 225, 289, 361] bool(SmartRange(5,5)) = False bool(SmartRange(1,5)) = True
The separation between SmartRange (iterable) and SmartRangeIterator (iterator) is the key design decision. You can iterate over a SmartRange multiple times because each for loop calls __iter__ which creates a fresh SmartRangeIterator. If SmartRange were also the iterator (returning self from __iter__), you could only loop over it once. The filter and map methods return generators — they’re lazy pipelines that don’t compute anything until consumed.
Visualise with Python Tutor
Copy this code into pythontutor.com and step through it:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
def take(gen, n):
for _ in range(n):
yield next(gen)
fib = fibonacci()
# Manual steps
print(next(fib)) # 0
print(next(fib)) # 1
print(next(fib)) # 1
print(next(fib)) # 2
# take() wrapping fib
fib2 = fibonacci()
for value in take(fib2, 5):
print(value)
Step through and observe three things. When fibonacci() is called nothing executes — it returns a generator object. On the first next(fib), execution runs until yield a with a=0. On the second next(fib), execution resumes from just after yield, a, b = b, a + b runs making a=1, b=1, then yield a pauses again with 1. When take is called, it’s also a generator — for value in take(fib2, 5) creates a chain: the for loop calls next(take_gen), which calls next(fib2), which advances fibonacci. Generators compose naturally because each is just an object waiting for next().
Summary and next step
In this article you practised Python iterators and generators with three real programs. The Fibonacci generator showed infinite lazy sequences, generator composition with take and take_while, and the memory advantage over lists. The prime generator showed generators calling generators, lazy filtering with generator expressions, and mathematical exploration without pre-computing bounds. The SmartRange class showed the clean separation between iterable and iterator, supporting reversed(), len(), in, bool() and lazy filter/map pipelines.
In the next article you’ll find exercises to solve on your own.

One Comment