Skip to Content
Writing a Custom Iterator Class in Python With __iter__ and __next__
Advanced
0 likes
Tutorials

Writing a Custom Iterator Class in Python With __iter__ and __next__

A for loop calls iter() once and next() until StopIteration. Write __iter__ and __next__ yourself, avoid the iterator that only works once, and know when a generator in __iter__ does the same job in fewer lines.

Write an iterator class when a for loop over your object should do something a list can't: produce values on demand, read from somewhere as it goes, or keep state between steps. Most of the time a generator function does the same job in fewer lines, and I'd use one first. But the class shows exactly what a for loop does, and some objects really do need it.

Skip ahead: If you only need the template, go to "The split that matters: iterable vs iterator". Single-class iterators have a bug that shows up the second time you loop.

What a for loop actually calls

items = ["coffee", "bread"]
it = iter(items)
print(next(it))
print(next(it))
try:
    next(it)
except StopIteration:
    print("StopIteration: the loop ends here")
coffee
bread
StopIteration: the loop ends here

for item in items: does exactly this. It calls iter(items) once to get an iterator, calls next() on it until StopIteration is raised, and stops quietly. To make your own object work in a for loop, you provide those two methods:

  • **__iter__** returns the iterator. iter() calls it.
  • **__next__** returns the next value, or raises StopIteration when there are none. next() calls it.

A first iterator: business days

from datetime import date, timedelta


class BusinessDays:
    def __init__(self, start, end):
        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:
                return day
        raise StopIteration


for day in BusinessDays(date(2026, 10, 9), date(2026, 10, 14)):
    print(day, day.strftime("%a"))
2026-10-09 Fri
2026-10-12 Mon
2026-10-13 Tue
2026-10-14 Wed

It skips the weekend and never builds a list. A range of ten years costs the same memory as a range of a week, because only current is stored.

The bug: it only works once

from datetime import date, timedelta


class BusinessDays:
    def __init__(self, start, end):
        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:
                return day
        raise StopIteration


week = BusinessDays(date(2026, 10, 12), date(2026, 10, 16))
print("first loop:", len(list(week)))
print("second loop:", len(list(week)))
first loop: 5
second loop: 0

No error. The second loop is just empty. __iter__ returns self, and self.current is already past the end. That's correct for an iterator, which is used up as you go, just like a file you've read to the end. It's wrong for something people expect to loop over twice, like a date range in a report that counts the days and then prints them.

The split that matters: iterable vs iterator

The fix is two classes. The iterable holds the settings and makes a fresh iterator each time someone loops. The iterator holds the position:

from datetime import date, timedelta


class BusinessDays:
    """Iterable: loop over it as many times as you like."""

    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        return _BusinessDayIterator(self.start, self.end)


class _BusinessDayIterator:
    """Iterator: used up after one pass."""

    def __init__(self, start, end):
        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:
                return day
        raise StopIteration


week = BusinessDays(date(2026, 10, 12), date(2026, 10, 16))
print("first loop:", len(list(week)))
print("second loop:", len(list(week)))

a = iter(week)
b = iter(week)
print(next(a), next(a), next(b))
first loop: 5
second loop: 5
2026-10-12 2026-10-13 2026-10-12

Two loops, two results, and two iterators that don't disturb each other. This is how lists, dictionaries and range work: range(5) is an iterable you can loop over again and again, and each iter(range(5)) is a new iterator.

⚠ Warning: An iterator's __iter__ must return self. Without it, code like for x in iter(obj) or zip() fails with TypeError: '...' object is not iterable, because Python's rule is that every iterator is also iterable.

The shorter way: make __iter__ a generator

Here's the same iterable with no second class:

from datetime import date, timedelta


class BusinessDays:
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        day = self.start
        while day <= self.end:
            if day.weekday() < 5:
                yield day
            day += timedelta(days=1)


week = BusinessDays(date(2026, 10, 12), date(2026, 10, 16))
print(len(list(week)), len(list(week)))
print([d.day for d in week])
5 5
[12, 13, 14, 15, 16]

Because __iter__ contains yield, calling it returns a brand-new generator every time, and a generator is an iterator. You get the two-class behavior with one class and no StopIteration. For most real code, this is the version I'd write.

So when do you still write __next__ by hand?

  • You need extra methods on the iterator itself, like peek() to see the next value without using it, or a position you can read mid-loop.
  • You're wrapping something with its own position, like a cursor from a database driver or a network stream, and you want the class to show clearly that it's one pass only.
  • You want to understand what generators do for you. That's a fair reason too.

An iterator with peek()

class Peekable:
    _empty = object()

    def __init__(self, iterable):
        self._it = iter(iterable)
        self._next = self._empty

    def __iter__(self):
        return self

    def __next__(self):
        if self._next is not self._empty:
            value, self._next = self._next, self._empty
            return value
        return next(self._it)

    def peek(self, default=None):
        if self._next is self._empty:
            try:
                self._next = next(self._it)
            except StopIteration:
                return default
        return self._next


lines = Peekable(["# header", "a", "b"])
if lines.peek().startswith("#"):
    print("skip:", next(lines))
print(list(lines))
print(lines.peek("nothing left"))
skip: # header
['a', 'b']
nothing left

next(self._it) raises StopIteration on its own when the source runs out, so __next__ doesn't need to raise it itself. _empty is a unique marker, so None can still be a real value in the data.

Common mistakes

  • **__iter__ returning self on something people loop over twice.** Second loop is silently empty.
  • **Returning None instead of raising StopIteration.** The loop never ends, or yields None forever.
  • **Raising StopIteration inside a generator.** In a generator, just return. A StopIteration raised inside one becomes a RuntimeError.
  • A full iterator class where a generator would do. More code, same result.

What happens if you get iterable vs iterator wrong

The report counts 5 business days and then prints none of them. Or a test passes alone and fails when another test looped over the same object first. Nothing crashes, so you look everywhere except the __iter__ method.

If you pass your object to a function that calls len(list(x)) and then loops over x, does your class survive that?

About

The iterator protocol, a business-days iterator, the iterable vs iterator split, __iter__ as a generator, and a peekable iterator. After this, your objects work in for loops, and the second loop isn't silently empty.

Responsible
Jay P
Last Update
09/13/2026
Reading Time
6 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.