Back to Blog
Python

Python iter(): How the Built-in Function Works

python **iter**: Explains Python's iter() built-in: how it creates iterators, the two-argument sentinel form, custom iterator classes, and common edge cases.

pythoniteratorsiterablesgeneratorsiteration-protocolbuilt-in-functions
Illustration of a Python list being converted into a single iterator block by the iter() built-in, with a one-way arrow showing single-pass iteration.

The iter() built-in is the entry point to Python's iteration protocol. When you write for x in data, Python calls iter(data) behind the scenes, obtains an iterator, and then repeatedly calls next() on it until StopIteration is raised. Understanding python **iter** directly — rather than only through for loops — matters when you need manual control over iteration, custom iterator classes, or the two-argument form that most tutorials skip.

What iter() Returns and Why It Matters

Calling iter() on an iterable returns an iterator object. The iterator produces values one at a time through its __next__() method, which the built-in next() function invokes:

data = [10, 20, 30] it = iter(data) print(next(it)) # 10 print(next(it)) # 20 print(next(it)) # 30

After the last value, the next call to next(it) raises StopIteration. This exception is what signals a for loop to terminate. The key property of an iterator is that it is single-pass: once you consume a value, it is gone. Iterating over the same iterator a second time yields nothing.

it = iter([1, 2, 3]) print(list(it)) # [1, 2, 3] print(list(it)) # []

The iterator is exhausted after the first list() call. If you need to iterate twice, you must call iter() again on the original iterable.

The Two-Argument Form: iter(callable, sentinel)

The less common overload of iter() takes a callable and a sentinel value. Instead of iterating over a collection, it calls the callable repeatedly until the return value equals the sentinel, then stops.

with open("data.txt") as f: for chunk in iter(lambda: f.read(1024), ""): process(chunk)

Here f.read(1024) is called repeatedly. When it returns an empty string — the sentinel — iteration stops. This is a compact way to read a file in fixed-size chunks without writing an explicit while loop.

The same pattern works for any callable that has a natural stopping value:

import random for value in iter(lambda: random.randint(0, 10), 5): print(value)

This prints random integers until the first 5 is produced. Note that the sentinel value itself is not yielded; iteration stops when the callable returns it. If the callable never returns the sentinel, the loop runs forever, so this form requires a callable with a guaranteed termination condition.

How iter() Discovers an Iterator

When you call iter(obj), Python first looks for an __iter__() method on the object. If it exists, that method is called and must return an iterator. Most built-in types — lists, strings, tuples, dictionaries, sets, and files — implement __iter__.

If __iter__ is absent, Python falls back to the older sequence protocol: it calls __getitem__() with index 0, then 1, then 2, and so on, until the method raises IndexError. This means you can make an object iterable without defining __iter__ at all:

class Countdown: def __init__(self, start): self.start = start def __getitem__(self, index): if index >= self.start: raise IndexError return self.start - index for n in Countdown(3): print(n) # 3, 2, 1

The sequence fallback exists for backward compatibility with code written before iterators were standardized. In new code, prefer an explicit __iter__ method, because it makes the iteration behavior visible and avoids the index-based protocol.

Building Custom Iterators

To create your own iterator, implement both __iter__() and __next__(). The __iter__ method typically returns self, and __next__ returns the next value or raises StopIteration when exhausted.

class Squares: def __init__(self, limit): self.limit = limit self.n = 0 def __iter__(self): return self def __next__(self): if self.n >= self.limit: raise StopIteration result = self.n ** 2 self.n += 1 return result for value in Squares(4): print(value) # 0, 1, 4, 9

Because __iter__ returns self, the object is both an iterable and an iterator. That is exactly what iter() expects: calling iter(Squares(4)) returns the object itself.

A generator function is a shorter way to express the same iterator. Any function containing yield returns a generator, which is already an iterator:

def squares(limit): for n in range(limit): yield n ** 2 it = iter(squares(4)) print(next(it)) # 0

Generators are the idiomatic choice when the iterator is simple enough to express as a sequence of yields. A class-based iterator is preferable when the object needs to carry additional state or methods beyond iteration.

Common Mistakes and Edge Cases

Calling iter() on an object that supports neither __iter__ nor __getitem__ raises TypeError:

iter(42) # TypeError: 'int' object is not iterable

Calling iter() on an iterator returns the same object, because iterators are also iterables:

it = iter([1, 2, 3]) print(iter(it) is it) # True

This matters when you pass an iterator to a function that calls iter() internally, such as list() or a for loop. The iterator is not duplicated; it is consumed.

A common source of confusion is the sentinel form with a callable that has no stopping condition. If the callable never returns the sentinel, the iteration never ends, and the consuming loop hangs. Always confirm that the callable's return values can actually reach the sentinel.

Another edge case is mixing iterators with re-iterable collections. A list can be iterated many times because each iter() call creates a fresh iterator. An iterator object cannot, because it is single-pass. If a function receives an iterator and needs to traverse it twice, it must materialize the values first with list().

Memory and Runtime Behavior

Iterators produce values lazily: each value is computed when requested, not all at once. This is the main reason to prefer iteration over building a full list. Processing a large file line by line with for line in f reads one line into memory at a time, whereas f.readlines() materializes every line.

The same principle applies to custom iterators and generators. A generator that computes squares on demand uses constant memory regardless of the limit, while a list comprehension of the same size grows with the input. The tradeoff is that iterators are single-pass and cannot be indexed or sliced. When you need random access or repeated traversal, materialize the sequence with list().

There is no free lunch: lazy iteration shifts work to the consumer. If the consumer needs the entire sequence, converting the iterator to a list costs the same memory as building the list directly. Choose iteration when the values are consumed once, and choose a list when they must be revisited.

Practical Use Cases

Manual iteration with iter() and next() is useful when a for loop is too rigid. For example, when processing a stream where the first value determines how to handle the rest:

it = iter(data) first = next(it) # handle first value specially, then continue with it

The sentinel form is the cleanest way to read fixed-size chunks from a file or socket. It also works for reading until a specific marker in a stream, as long as the callable can detect the marker.

For library code that accepts either an iterable or an iterator, calling iter() on the input normalizes it. This is a common pattern in functions that need to guarantee they hold an iterator:

def consume(stream): it = iter(stream) for item in it: process(item)

This works whether the caller passes a list, a generator, or a custom iterator, because iter() returns a valid iterator in every case. Understanding iter() at this level makes it easier to write functions that handle both eager and lazy inputs correctly.

python **iter**: Practical Usage and Code Examples | RYUSLOG DEV