Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

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.

50 min August 2026
// Part 01 — The Iterable Protocol

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.

Every built-in you already loop over implements __iter__
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 why

You 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.

// Part 02 — The Iterator Protocol

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.

The full iterator contract, in two methods
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 produce

Every 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.

💡 Note
Two methods, one job each: __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.
// Part 03 — What a for Loop Actually Does

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.

A for loop, and exactly what it desugars to
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.

🎯 Pro Tip
This exact same mechanism is why 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.
// Part 04 — Building a Custom Iterator

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.

A real, complete custom iterator class
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.

// Part 05 — Iterable vs Iterator, and Exhaustion

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.

A list can be looped repeatedly. Its iterator, once you extract it, cannot.
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 empty

Notice 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.

⚠️ Important
This is exactly why map(), filter(), and file objects can only be looped once. They are iterators themselves, not iterables that hand back fresh iterators each time. If you need to loop over their results more than once, convert to a concrete collection first — e.g. results = list(filter(...)) — and loop over the resulting list as many times as you need.
// Part 06 — iter() and next() Directly

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.

iter() and next() used directly
values = [10, 20, 30]
it = iter(values)

print(next(it))   # 10
print(next(it))   # 20
print(next(it))   # 30
print(next(it))   # StopIteration raised

next() 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.

next() with a default — avoiding StopIteration entirely
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 raised

A real pattern: manually advancing past a header row

Skipping a CSV header manually with next(), before looping the rest normally
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)
// Part 07 — Checking Iterability

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.

Approach 1 — checking with the collections.abc module
from collections.abc import Iterable

print(isinstance([1, 2, 3], Iterable))   # True
print(isinstance("hello", Iterable))      # True
print(isinstance(42, Iterable))            # False

This 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.

Approach 2 — EAFP: try it and handle the failure (the more Pythonic default)
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 iterate

This "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.

// Part 08 — Real World
💼 What This Looks Like at Work

The Empty Report at a Minneapolis Freight Analytics Company

Scenario — Freight analytics company, Minneapolis · Production bug report

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).

The original, buggy version
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, ids

What 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.

The fix — materialize once, then reuse freely
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, ids

This 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.

// Part 09 — Misconceptions

Four Misconceptions About Iterators and Iterables

✕ ""Iterable and iterator mean basically the same thing""
They describe two related but distinct contracts. An iterable knows how to PRODUCE an iterator (via __iter__) and can be looped over repeatedly, getting a fresh iterator each time. An iterator IS the thing doing the stepping (via __next__), holds in-progress state, and — as shown in Part 05 — is exhausted after a single full pass.
✕ ""A for loop is a special language feature that only works on lists, strings, and a few built-in types""
A for loop works on ANY object that implements __iter__, including classes you write yourself, as shown in Part 04's DateRange example. It is not a special case for built-ins — it is a general, extensible protocol any object can opt into.
✕ ""Once you've looped over something once, you can always loop over it again the same way""
This depends entirely on whether the object is a reusable iterable (like a list) or a single-pass iterator (like a database cursor, a map() object, or an open file). The Minneapolis example above shows exactly how this assumption produces a silent, no-error bug in real production code.
✕ ""StopIteration is an error that means something went wrong""
It is the NORMAL, expected signal that an iterator has no more values — the for loop catches it silently every single time a loop finishes, and you would never see it directly unless you were calling __next__() or next() manually, outside of a for loop, without a default value.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

What is the difference between an iterable and an iterator in Python?
An iterable is any object that implements __iter__, which returns an iterator — lists, strings, and dicts are all iterables. An iterator is an object that implements both __iter__ (returning itself) and __next__ (producing the next value, or raising StopIteration when exhausted). Every iterator is also an iterable, but not every iterable is an iterator — a list has __iter__ but not __next__.
What does Python actually do internally when it executes a for loop?
It calls iter() on the object once, up front, to get an iterator. Then it repeatedly calls next() on that iterator, using each returned value as the loop variable, until next() raises StopIteration — at which point the loop catches that exception silently and ends. This is exactly what a for loop desugars to if written manually with a while True / try / except StopIteration / break block.
What does StopIteration signal, and is it an error condition?
It is the standard, expected signal that an iterator has no more values to produce — it is not a bug or a failure. Every for loop catches it automatically and silently on every single loop it runs. You would only see it directly if you called __next__() or the built-in next() manually, outside a for loop, without supplying a default value.
Why can an iterator like the object returned by map() or a database cursor only be looped over once?
Because an iterator holds its own in-progress position internally, and calling next() permanently advances that position — there is no way to "rewind" it. A list, by contrast, is an iterable that hands back a brand-new, fresh iterator every time you start a new loop over it, which is why looping over a list repeatedly works fine while looping over an already-consumed iterator produces nothing.
How would you build a custom class that supports being used in a for loop?
Implement __iter__ on the class, returning an iterator — either self, if the class also implements __next__ directly (the simplest case, works for single-pass use), or a separate helper iterator object if the class needs to support multiple independent, simultaneous loops over it, exactly as demonstrated with DateRange and DateRangeIterator in Part 04.
// Common Mistakes

Iteration Mistakes That Cause Genuinely Confusing Bugs

Looping over the same iterator object twice, expecting two independent passes
As shown in the Minneapolis example above, this silently produces nothing on the second pass with no error. If you need multiple passes, convert the iterator to a list ONCE with list(iterator), then loop over the resulting list as many times as needed.
Forgetting that __next__ (with double underscores) is the method name, not next()
When defining a custom iterator class, the special method you implement is __next__ — next() is the separate built-in FUNCTION that calls __next__ on your behalf. Implementing a method literally named "next" (no underscores) does nothing; Python's iterator protocol will not find it.
Assuming a class with only __iter__ (no __next__) is a complete iterator
A class needs __next__ too if it intends to act as its own iterator (returning self from __iter__). Without __next__, calling next() on an instance of that class raises "TypeError: object is not an iterator" — it is a valid iterable, but not, by itself, an iterator.
Catching StopIteration broadly, inside a generator or a loop, and accidentally silencing a genuine bug
Because StopIteration is a normal control-flow signal, wrapping too much code in a broad try/except StopIteration block can accidentally swallow a StopIteration that was actually raised somewhere else, unintentionally — masking a real bug as if iteration had simply ended.
Checking iterability with hasattr(x, "__next__") when you actually meant __iter__
A list is iterable but is NOT its own iterator — it has __iter__ but not __next__. Checking for __next__ specifically will incorrectly report that a perfectly loop-able list is "not iterable." Use collections.abc.Iterable (checks for __iter__) unless you specifically need to confirm something is already an iterator.
// Error Library

Errors You Will Hit With Iterators — And Exactly Why

TypeError: 'int' object is not iterable
Cause: A for loop, or a function like list()/sum()/sorted(), was given an object with no __iter__ method — most commonly a plain number, passed where a collection was expected.
Fix: Confirm the value is actually the collection you meant to pass, not a single item. If a function should accept either one item or a collection, wrap a lone value in a list, or branch explicitly with an isinstance() check (Part 07).
TypeError: 'Countdown' object is not an iterator
Cause: A class implements __iter__ but returns something (or itself) that is missing __next__ — the object satisfies the iterable protocol but not the iterator protocol.
Fix: Implement __next__ on whichever class __iter__ returns, following the pattern in Part 02 and Part 04 — remember to raise StopIteration once there are no more values to produce.
StopIteration (raised outside of a for loop)
Cause: next() was called directly on an iterator that has already produced its last value, without supplying a default second argument to next().
Fix: Either catch it explicitly with try/except StopIteration, or pass a default: next(iterator, default_value), exactly as shown in Part 06.
RuntimeError: generator raised StopIteration
Cause: A StopIteration accidentally escaped from inside a generator function's body (covered in the next module) instead of being used as a normal loop-ending signal — Python 3.7+ converts this specific case into a RuntimeError to prevent it from silently and incorrectly ending an enclosing loop.
Fix: Never raise StopIteration manually inside a generator function. Use a plain "return" statement to end a generator early instead — covered in full in Module 28.

🎯 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 yield
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...