An iterable is any object implementing __iter__, which returns an iterator. An iterator is any object implementing __next__ (returning the next value or raising StopIteration) and __iter__ (returning itself). The Python Language Reference defines the iterator protocol as the mechanism underlying all for loops and comprehensions.
What every for loop really does
What you will learn: the difference between an iterable (you can loop it) and an iterator (does the stepping), and how iter() and next() power every for loop.
How to read this tab: The key practical fact: iterables can be looped many times; iterators are single-use.
An iterable is anything you can put after in in a for loop — a list, a string, a dict. An iterator is the helper Python creates to walk through it one item at a time, remembering where it is. Every for loop quietly turns an iterable into an iterator.
Iterable: loop many times. Iterator: loop once. A list is iterable — loop it repeatedly, each loop gets a fresh iterator. The result of iter(list) or any generator is an iterator — exhausted after one pass. This is the single most important practical distinction.
Iterable vs iterator — the distinction
These two words sound alike but mean different things. An iterable can produce an iterator; an iterator produces values. A list is iterable but is not itself an iterator — you can loop over it many times.
nums = [1, 2, 3] # a LIST — iterable, but not an iterator
# iter() turns an iterable into an iterator
it = iter(nums) # now 'it' is an iterator
print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
# print(next(it)) # StopIteration — exhausted
# A for loop does exactly this for you, automatically:
for n in nums: # Python calls iter(nums), then next() repeatedly
print(n) # ...and stops cleanly at StopIteration
# An iterable can be looped MANY times (fresh iterator each time)
for n in nums: pass
for n in nums: pass # works — list is reusable
# An iterator is ONE-SHOT — once exhausted, it's done
it = iter(nums)
print(list(it)) # [1, 2, 3]
print(list(it)) # [] — already exhausted, nothing leftA list can be looped over again and again. An iterator (including a generator) can be walked only once — after it raises StopIteration, it stays exhausted. If you need to iterate twice, either keep the original iterable or convert to a list first.
✅ Beginner tab complete
- I know an iterable produces an iterator via iter()
- I know an iterator produces values via next() until StopIteration
- I know a for loop calls iter() then next() repeatedly
- I know iterators are single-use but iterables (like lists) are reusable
The iterator protocol and custom iterators
What you will learn: the two-method protocol (__iter__ and __next__), and how to separate a reusable iterable from a one-shot iterator.
How to read this tab: A generator is the easy way to implement __iter__ — yield from your data instead of writing a separate iterator class.
An iterator’s __iter__ must return self. An iterable’s __iter__ returns a new iterator; an iterator’s __iter__ returns itself. This lets an iterator be used directly in a for loop. Getting this wrong makes loops behave unexpectedly.
The iterator protocol
The protocol is two methods. An iterable implements __iter__() returning an iterator. An iterator implements __next__() returning the next value (or raising StopIteration) and __iter__() returning itself.
# What a for loop REALLY does under the hood:
nums = [10, 20, 30]
iterator = iter(nums) # 1. get an iterator (calls __iter__)
while True:
try:
item = next(iterator) # 2. get next value (calls __next__)
except StopIteration: # 3. stop when exhausted
break
print(item) # 4. run the loop body
# An iterator must implement BOTH __next__ and __iter__ (returning self)
class CountUp:
def __init__(self, limit):
self.current = 0
self.limit = limit
def __iter__(self):
return self # an iterator returns itself
def __next__(self):
if self.current >= self.limit:
raise StopIteration
self.current += 1
return self.current
for n in CountUp(3):
print(n) # 1, 2, 3A generator is the easy way to write __iter__. Instead of a separate iterator class with __next__, just make __iter__ a generator method that uses yield from self.data. Far less code, and you get a fresh iterator on every loop automatically.
Iterable vs iterator as separate objects
The cleaner design separates the iterable (reusable) from the iterator (one-shot), so the collection can be looped multiple times — each loop gets a fresh iterator. This is how built-in containers work.
class Deck:
"""An ITERABLE — reusable, produces a fresh iterator each time."""
def __init__(self):
self.cards = ["A", "K", "Q", "J"]
def __iter__(self):
return DeckIterator(self.cards) # NEW iterator each call
class DeckIterator:
"""An ITERATOR — one-shot, tracks position."""
def __init__(self, cards):
self.cards = cards
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.cards):
raise StopIteration
card = self.cards[self.index]
self.index += 1
return card
deck = Deck()
print(list(deck)) # ['A','K','Q','J']
print(list(deck)) # ['A','K','Q','J'] — reusable! fresh iterator each time
# In practice, a generator is the easy way to write __iter__:
class Deck2:
def __init__(self):
self.cards = ["A", "K", "Q", "J"]
def __iter__(self):
yield from self.cards # generator = iterator, far less code✅ Intermediate tab complete
- I can implement __iter__ and __next__ for a custom iterator
- I know an iterator’s __iter__ must return self
- I can separate a reusable iterable from a one-shot iterator
- I know using a generator in __iter__ is the easy approach
Lazy evaluation, infinite iterators, two-arg iter()
What you will learn: lazy evaluation with itertools, infinite iterators in constant memory, and the lesser-known iter(callable, sentinel) form.
How to read this tab: iter(callable, sentinel) consumes a stream until an end marker — cleaner than while True/break.
Lazy evaluation, infinite iterators, and the two-argument iter()
Iterators are the foundation of lazy evaluation in Python — values are produced only when requested, so an iterator can represent an infinite or arbitrarily large sequence in constant memory. Generators are the most common iterators, but the standard library's itertools module provides composable iterator building blocks, and the built-in iter() has a lesser-known two-argument form for sentinel-based iteration.
import itertools
# Infinite iterator — only computes what you consume
counter = itertools.count(start=1, step=2) # 1, 3, 5, 7, ... forever
print(list(itertools.islice(counter, 5))) # [1, 3, 5, 7, 9]
# Lazy pipeline — no intermediate lists, constant memory
squares = map(lambda x: x*x, itertools.count(1))
first_5_big = itertools.takewhile(lambda x: x < 100, squares)
print(list(first_5_big)) # [1, 4, 9, 16, 25, 36, 49, 64, 81]
# The two-argument iter(callable, sentinel) — call until sentinel returned
import io
stream = io.StringIO("line1\nline2\nSTOP\nline4\n")
# Read lines until "STOP\n" is returned
for line in iter(stream.readline, "STOP\n"):
print(line.strip()) # line1, line2 (stops at STOP)
# A common real use: read fixed-size chunks until empty
# with open("file.bin", "rb") as f:
# for chunk in iter(lambda: f.read(4096), b""):
# process(chunk)
# itertools.tee — split one iterator into independent copies
it = iter([1, 2, 3, 4])
a, b = itertools.tee(it, 2)
print(list(a)) # [1, 2, 3, 4]
print(list(b)) # [1, 2, 3, 4] — independent copy
# Check the protocols at runtime
from collections.abc import Iterable, Iterator
print(isinstance([1,2,3], Iterable)) # True
print(isinstance([1,2,3], Iterator)) # False — list is iterable, not iterator
print(isinstance(iter([1,2,3]), Iterator)) # TrueThe two-argument iter(callable, sentinel) form repeatedly calls the zero-argument callable until it returns the sentinel value, then stops. It is the idiomatic way to consume a stream until an end marker — reading chunks from a file until b"", or lines until a terminator — without a while True / break loop. Combined with itertools and generators, the iterator protocol gives Python a complete, memory-efficient, composable model for processing sequences of any size, including infinite ones.
✅ Expert tab complete
- I can build lazy pipelines with itertools.count/islice/takewhile
- I can use iter(callable, sentinel) to read until an end marker
- I know isinstance checks against Iterable vs Iterator from collections.abc