Why this matters
Real loops rarely want just "each item". You want each item and its position (to number lines in a report), or items from two lists at once (names and scores, keys and values), or items backwards. Beginners reach for range(len(...)) and index arithmetic, which works but is noisy and easy to get off by one. Python has three small built-ins that express these intentions directly, and all three are lazy iterators built on the protocol from the last lesson.
Unpacking in the loop header
First, a feature that all three rely on. You know tuple unpacking from the collections module:
point = (3, 4)
x, y = point
The target of a for loop can unpack too. Each value the loop receives is unpacked into the names:
pairs = [("ann", 90), ("bo", 85)]
for name, score in pairs:
print(name, score)
ann 90
bo 85
Nested shapes work if you mirror them with parentheses: for i, (a, b) in .... You have already met one producer of pairs: dict.items().
ages = {"ann": 31, "bo": 27}
for name, age in ages.items():
print(f"{name} is {age}")
enumerate: position and value
enumerate(iterable, start=0) yields (index, value) tuples:
>>> list(enumerate(["a", "b", "c"]))
[(0, 'a'), (1, 'b'), (2, 'c')]
>>> list(enumerate(["a", "b", "c"], start=1))
[(1, 'a'), (2, 'b'), (3, 'c')]
Compare the two styles:
fruits = ["apple", "pear", "plum"]
# index style: works, but the value is buried in fruits[i]
for i in range(len(fruits)):
print(i + 1, fruits[i])
# enumerate style: says what you mean
for number, fruit in enumerate(fruits, start=1):
print(number, fruit)
Both print 1 apple, 2 pear, 3 plum. enumerate also works on things with no length or indexing at all, such as a file or a generator, where range(len(...)) is impossible.
Underneath, enumerate is an iterator that wraps another iterator and keeps a counter: each next() call pulls one value from the inner iterator and returns (counter, value), then increments the counter. It never builds a list.
zip: walking several iterables together
zip(a, b, ...) yields tuples containing the first item of each, then the second of each, and so on:
>>> names = ["ann", "bo", "cy"]
>>> scores = [90, 85, 77]
>>> list(zip(names, scores))
[('ann', 90), ('bo', 85), ('cy', 77)]
Picture it like a zipper bringing two rows together:
names: "ann" "bo" "cy"
| | |
scores: 90 85 77
v v v
("ann",90) ("bo",85) ("cy",77)
Typical uses:
for name, score in zip(names, scores):
print(f"{name:>4}: {score}")
lookup = dict(zip(names, scores)) # {'ann': 90, 'bo': 85, 'cy': 77}
a = [1, 2, 3]
b = [10, 20, 30]
sums = [x + y for x, y in zip(a, b)] # [11, 22, 33] (comprehensions: lesson 4)
Uneven lengths
By default zip stops at the shortest input and silently ignores the leftovers:
>>> list(zip([1, 2, 3], ["a", "b"]))
[(1, 'a'), (2, 'b')]
If unequal lengths mean your data is broken, say so with strict=True (Python 3.10+):
>>> list(zip([1, 2, 3], ["a", "b"], strict=True))
Traceback (most recent call last):
...
ValueError: zip() argument 2 is shorter than argument 1
If you want to keep going and fill the gaps, itertools.zip_longest(a, b, fillvalue=None) does that.
Unzipping
zip(*pairs) reverses the operation. The * spreads the list into separate arguments, so zip receives each pair as one input:
>>> pairs = [("ann", 90), ("bo", 85)]
>>> names, scores = zip(*pairs)
>>> names
('ann', 'bo')
>>> scores
(90, 85)
reversed: backwards without copying
reversed(sequence) returns an iterator that walks a sequence from the end:
>>> list(reversed([1, 2, 3]))
[3, 2, 1]
>>> "".join(reversed("stressed"))
'desserts'
>>> for i in reversed(range(3)):
... print(i)
...
2
1
0
It differs from the alternatives in useful ways:
seq[::-1]builds a full reversed copy. Fine for small data, but it allocates.seq.reverse()(lists only) reverses the list in place and returnsNone.reversed(seq)builds nothing: it is an iterator that starts at the last index and counts down. It needs a sequence (something with a length and indexing) or an object that supports reversal, likerange, list, tuple, str and dict (keys, newest first). It does not work on arbitrary iterators:reversed(iter([1, 2]))raisesTypeError: 'list_iterator' object is not reversible, because an iterator cannot go backwards.
Combining them
These tools compose. Number the pairs of two lists:
for i, (name, score) in enumerate(zip(names, scores), start=1):
print(f"{i}. {name} scored {score}")
1. ann scored 90
2. bo scored 85
3. cy scored 77
Read it from the inside out: zip makes ("ann", 90), enumerate wraps that as (1, ("ann", 90)), and the loop target i, (name, score) mirrors that shape exactly.
Compare a word with its mirror image:
word = "abca"
for a, b in zip(word, reversed(word)):
print(a, b)
a a
b c
c b
a a
Common mistakes
Wrong unpacking shape.
for i, name, score in enumerate(zip(names, scores)):
...
ValueError: not enough values to unpack (expected 3, got 2)
enumerate gives 2-tuples whose second element is itself a pair. Write for i, (name, score) in ....
Printing a zip object. print(zip(a, b)) shows <zip object at 0x7f...>, not the pairs. It is a lazy iterator; wrap it in list() to see the contents.
Reusing a zip. As with every iterator, the second loop over the same zip object produces nothing.
Silent truncation. If two lists that "should" match differ in length, plain zip hides the bug. Use strict=True when equal length is an assumption.
list.reverse() returns None. rev = names.reverse() sets rev to None and reverses names itself. Use reversed(names) or names[::-1] when you want a new sequence.
Recap
- A
fortarget can unpack tuples, including nested ones:for i, (a, b) in .... enumerate(it, start=0)yields(index, value)pairs; prefer it overrange(len(...)).zip(a, b, ...)walks several iterables in step, stops at the shortest, and raises on mismatch withstrict=True;zip(*pairs)unzips.reversed(seq)iterates a sequence backwards without copying; it needs a reversible sequence, not an iterator.- All three are lazy, one-shot iterators.
Going further: read the documentation for itertools.zip_longest and try dict(zip(keys, values)) and dict(enumerate("abc")) in the REPL.