Iterators and Iterables — Building Your Own
What Python actually does when you write a for loop, the iterable vs iterator protocols, and how to build your own iterator class.
What Makes an Object Iterable — __iter__
Since Module 06, you have written dozens of for loops over lists, strings, dicts, and ranges without asking what actually makes those objects loop-able in the first place. The answer is a specific, well-defined contract: an object is iterable if it implements a method called __iter__, which returns an iterator — a separate, related object covered in Part 02. This is exactly the same "special method" mechanism you met in the Object-Oriented Python phase with __init__, __str__, and __eq__ — Python calls it implicitly, on your behalf, whenever the situation calls for it.
numbers = [1, 2, 3]
print(hasattr(numbers, "__iter__")) # True
text = "hello"
print(hasattr(text, "__iter__")) # True
count = 42
print(hasattr(count, "__iter__")) # False — you cannot "for x in 42:", and this is whyYou can call __iter__ directly, though you almost never would in real code — the for loop and the built-in iter() function both call it for you automatically. What matters right now is simply that "iterable" is not a vague, informal description — it is a precise, checkable contract: does this object have a working __iter__ method, yes or no.
What Makes an Object an Iterator — __iter__ Plus __next__
An iterator is a related but distinct concept: an object that implements both __iter__ (which, on an iterator, simply returns itself) and __next__, a method that produces the next value in a sequence each time it is called, and raises the built-in StopIteration exception once there is nothing left to produce.
class Countdown:
def __init__(self, start):
self.current = start
def __iter__(self):
return self # an iterator's __iter__ just returns itself
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
cd = Countdown(3)
print(next(cd)) # 3
print(next(cd)) # 2
print(next(cd)) # 1
print(next(cd)) # StopIteration raised — nothing left to produceEvery iterator is automatically an iterable too (because __iter__ is part of both contracts), but not every iterable is an iterator — a plain list has __iter__ but does not have __next__. This distinction, which looks academic at first glance, is the exact subject of Part 05 and is responsible for a genuinely common category of real production bugs.
__iter__ answers "how do I start iterating over you?" — it hands back an iterator to begin with. __next__ answers "what is the next value, and are you done yet?" An iterable is something you can start iterating over; an iterator is the thing that actually does the iterating, one value at a time, and remembers where it left off between calls.Demystifying the for Loop — iter(), Then next(), Until StopIteration
Here is the exact mechanism Python runs, every single time you write a for loop. It is not magic and it is not built into the language at some inaccessible level — it is precisely the two protocols from Parts 01 and 02, applied automatically.
for item in [10, 20, 30]:
print(item)
# Is functionally identical to writing this by hand:
iterator = iter([10, 20, 30]) # calls __iter__ once, up front
while True:
try:
item = next(iterator) # calls __next__ once per iteration
except StopIteration:
break # the loop ends cleanly — no error escapes
print(item)This is the single most important idea in this module: for item in something: is entirely built out of two simpler operations you can perform yourself — call iter() on the iterable once to get an iterator, then call next() on that iterator repeatedly until it raises StopIteration, which the for loop catches silently and treats as "loop finished normally," never letting the exception escape to your code.
list, str, dict, range, file objects, and countless third-party objects can all be looped over with the same, single for syntax, despite being completely different types under the hood internally. The for loop does not know or care what kind of object it is looping over — it only cares that the object honours the iterable protocol.Worked Example — A DateRange Iterator for Business Days
A fictional logistics company, Northstar Freight, needs to iterate over business days (Monday through Friday) between a start and end date, for scheduling driver shifts. Rather than building a list of every business day up front, a custom iterator class expresses this cleanly and lazily — one day at a time, using nothing more than the two-method contract from Part 02.
from datetime import date, timedelta
class DateRange:
"""Iterates over business days (Mon–Fri) between start and end, inclusive."""
def __init__(self, start: date, end: date):
self.start = start
self.end = end
def __iter__(self):
return DateRangeIterator(self.start, self.end)
class DateRangeIterator:
def __init__(self, start: date, end: date):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
while self.current <= self.end:
day = self.current
self.current += timedelta(days=1)
if day.weekday() < 5: # 0=Monday ... 4=Friday
return day
raise StopIteration
schedule = DateRange(date(2026, 8, 17), date(2026, 8, 21))
for business_day in schedule:
print(business_day)
# 2026-08-17 (Monday)
# 2026-08-18 (Tuesday)
# 2026-08-19 (Wednesday)
# 2026-08-20 (Thursday)
# 2026-08-21 (Friday)Notice the split into two classes: DateRange is the iterable — it knows the start and end dates, and its job is only to hand back a fresh iterator each time someone starts looping over it. DateRangeIterator is the iterator — it holds the actual in-progress state (self.current) and knows how to produce the next business day, or signal there are none left. This split is not incidental — it is exactly what makes the object safely reusable, which is the entire subject of Part 05.
A shortcut worth knowing: many custom classes can skip the second class
If an object never needs to support two independent, simultaneous loops over it at once, it is common and acceptable to collapse the iterable and iterator into a single class — implement __next__ directly on the same class and have __iter__ simply return self, exactly as the earlier Countdown example in Part 02 did. The two-class split in the example above is the more robust, general-purpose pattern — and it is what the Generators module (Module 28) will show you how to get almost for free, without writing either class by hand.
An Iterable Can Be Looped Many Times — an Iterator Can Only Be Looped Once
This is the single most practically important distinction in the entire module, and it is a genuinely common source of real bugs. A list is an iterable: every time you write a for loop over it, Python calls iter() and gets a brand new iterator, starting fresh from the beginning. But if you get hold of an iterator directly — by calling iter() yourself, or by using something that is only an iterator to begin with, like map() or filter() from Module 26 — it remembers its position, and once exhausted, it stays exhausted forever.
numbers = [1, 2, 3]
# The list itself — loop over it as many times as you like:
print(sum(numbers)) # 6
print(sum(numbers)) # 6 — still works, because each "for"/sum() call gets a FRESH iterator
# The iterator you get FROM it — exhausted after one full pass:
it = iter(numbers)
print(sum(it)) # 6 — consumes the whole iterator
print(sum(it)) # 0 — nothing left; it was already exhausted, no error, just emptyNotice the second call did not raise an error — it silently returned 0, because sum() internally does exactly what Part 03 described: call next() until StopIteration, and an already-exhausted iterator raises StopIteration on the very first call. This is precisely what makes the bug dangerous in real code — nothing crashes, a value is simply silently wrong, or silently empty.
results = list(filter(...)) — and loop over the resulting list as many times as you need.Using the Built-ins Yourself, Outside of a for Loop
You now have the tools to work with iteration manually, which is genuinely useful anytime you need to pull items one at a time rather than processing a whole collection in one pass — for example, reading the first few lines of a file differently from the rest, or interleaving values from two sources.
values = [10, 20, 30]
it = iter(values)
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
print(next(it)) # StopIteration raisednext() also accepts an optional second argument — a default value to return instead of raising StopIteration, which is a genuinely useful way to safely peek at the next value without needing a full try/except block.
it = iter([1, 2])
print(next(it, "no more values")) # 1
print(next(it, "no more values")) # 2
print(next(it, "no more values")) # "no more values" — no exception raisedA real pattern: manually advancing past a header row
with open("shipments.csv") as f:
lines = iter(f)
header = next(lines) # pull the header line off manually
for line in lines: # the SAME iterator, now starting from line 2
process(line)How to Actually Check Whether Something Is Iterable
There are two real approaches, and which one is correct depends on what you are about to do with the object next.
from collections.abc import Iterable
print(isinstance([1, 2, 3], Iterable)) # True
print(isinstance("hello", Iterable)) # True
print(isinstance(42, Iterable)) # FalseThis is the clean, explicit way to check before committing to iterate — genuinely useful when writing a function that needs to branch its behaviour depending on whether it received a single item or a collection of items.
def process(value):
try:
for item in value:
print("Processing item:", item)
except TypeError:
print("Processing single value:", value)
process([1, 2, 3]) # loops over three items
process(42) # "Processing single value: 42" — caught the TypeError from trying to iterateThis "Easier to Ask Forgiveness than Permission" (EAFP) style — attempt the operation and handle the failure, rather than checking upfront whether it will work — is generally considered more idiomatic Python than checking first (sometimes called "Look Before You Leap," or LBYL), and you will see this philosophy again, in much more depth, in the Exception Handling module later in this track. For now, the practical guidance is simple: reach for isinstance(x, Iterable) when you genuinely need to branch behaviour ahead of time, and let a natural try/except TypeError handle it when you are just going to attempt the loop anyway.
The Empty Report at a Minneapolis Freight Analytics Company
An engineer at a Minneapolis freight analytics company writes a function to summarise a batch of delayed shipments — it needs to compute a total delay count, and separately, build a formatted list of shipment IDs for a notification email. The shipments are pulled from a database query wrapped in a generator-like cursor object (a real iterator, not a list — exactly the distinction from Part 05).
def summarize_delays(delayed_shipments):
# delayed_shipments is an ITERATOR from a database cursor, not a list
count = sum(1 for _ in delayed_shipments) # first pass — consumes the iterator
ids = [s.id for s in delayed_shipments] # second pass — expects MORE items
return count, idsWhat actually happens in production
count comes back correct — say, 14 delayed shipments. But ids comes back as an empty list, every single time, with no error raised anywhere. The notification email goes out reporting "14 shipments delayed" with an empty list of which ones. This exact behaviour is precisely what Part 05 described: the first pass over delayed_shipments fully exhausted the iterator, and the second for loop, given the same already-exhausted iterator, produces nothing at all — silently, with zero indication anything went wrong.
The fix
The team fixes it by converting the iterator to a concrete list once, up front, and doing every subsequent pass over that list instead of the original iterator — exactly the guidance from the Callout in Part 05.
def summarize_delays(delayed_shipments):
shipments = list(delayed_shipments) # materialize ONCE — now it's a reusable list
count = len(shipments)
ids = [s.id for s in shipments]
return count, idsThis is a bug class that a compiler or a type checker cannot catch for you, because both versions of the code are entirely type-correct — an iterator really does support for and really can be passed to sum(). The only way to catch it is understanding, at the level covered in this module, exactly which objects are reusable iterables and which are single-pass iterators.
Four Misconceptions About Iterators and Iterables
5 Interview Questions — With Complete Answers
Iteration Mistakes That Cause Genuinely Confusing Bugs
Errors You Will Hit With Iterators — And Exactly Why
🎯 Key Takeaways
- ✓An iterable implements __iter__ and can be looped over repeatedly, producing a fresh iterator each time. An iterator implements __iter__ (returning itself) and __next__, and holds in-progress state.
- ✓A for loop is entirely built from two simpler operations: call iter() once to get an iterator, then call next() repeatedly until StopIteration is raised — which the loop catches silently.
- ✓StopIteration is the normal, expected end-of-iteration signal, not an error condition — you would only see it directly outside of a for loop, calling next() manually with no default.
- ✓A custom class supports for loops by implementing __iter__ — either returning self (if it also implements __next__ directly) or a separate iterator object, as shown with DateRange.
- ✓An iterator is exhausted after a single full pass and cannot be "rewound" — looping over the same iterator twice silently produces nothing on the second pass, with no error raised.
- ✓map(), filter(), and open file objects are all iterators, not reusable iterables — convert to a list with list(...) if you need to loop over their results more than once.
- ✓next(iterator, default) lets you safely pull the next value without raising StopIteration, useful for manually skipping a header row or peeking ahead.
- ✓Check iterability with isinstance(x, collections.abc.Iterable) when you need to branch behaviour upfront, or just attempt the loop and catch TypeError for the more idiomatic EAFP style.
What comes next
Module 28 shows you how to get everything you just built by hand in DateRangeIterator almost for free — generators and the yield keyword, Python's shortcut for writing iterators without writing a single __next__ method.
Module 28 → Generators and yieldDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.