Iteration · about 35 min

enumerate, zip and reversed

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:

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

Going further: read the documentation for itertools.zip_longest and try dict(zip(keys, values)) and dict(enumerate("abc")) in the REPL.

Exercises

Exercise 1 · quiz

Uneven zip

What does this print?

print(list(zip([1, 2, 3], "ab")))
Answer
  • [(1, 'a'), (2, 'b')]

zip stops as soon as the shortest input runs out, silently dropping the rest. That is convenient sometimes and a hidden bug other times. Pass strict=True to get a ValueError when the lengths differ, or use itertools.zip_longest to pad with a fill value.

Exercise 2 · write code

Numbered list

Read lines from input until an empty line or the end of input, then print them numbered from 1, in the format N. text. For input:

milk
eggs
bread

the output must be exactly:

1. milk
2. eggs
3. bread

Read all lines first with this loop (provided in the starter), then use enumerate with start=1.

File: main.py

Starting point

items = []
while True:
    try:
        line = input()
    except EOFError:
        break
    if line == "":
        break
    items.append(line)

for item in items:
    print(item)

Example

input

milk
eggs
bread

expected output

1. milk
2. eggs
3. bread

Example

input

a

b

expected output

1. a
Hint 1

enumerate(items, start=1) yields pairs (1, 'milk'), (2, 'eggs'), ...

Hint 2

Unpack the pair in the loop header: for number, item in enumerate(...).

Reference solution
items = []
while True:
    try:
        line = input()
    except EOFError:
        break
    if line == "":
        break
    items.append(line)

for number, item in enumerate(items, start=1):
    print(f"{number}. {item}")

Exercise 3 · write code

Parallel lists

In solution.py, write report(names, scores) that returns a list of strings "name: score", pairing the two lists by position. If the lists have different lengths, it must raise ValueError (let zip(..., strict=True) do that for you).

report(["ann", "bo"], [90, 85])   # ['ann: 90', 'bo: 85']
report([], [])                    # []
report(["ann"], [1, 2])           # raises ValueError

File: solution.py

Starting point

def report(names, scores):
    result = []
    for i in range(len(names)):
        result.append(f"{names[i]}: {scores[i]}")
    return result
Hint 1

for name, score in zip(names, scores): walks both lists together.

Hint 2

Add strict=True to zip so mismatched lengths raise ValueError.

Reference solution
def report(names, scores):
    result = []
    for name, score in zip(names, scores, strict=True):
        result.append(f"{name}: {score}")
    return result

Exercise 4 · write code

Mirror check

Write mismatches(word) in solution.py that compares a word with its reverse, position by position, and returns a list of the 0-based positions in the first half of the word where the character differs from its mirror. Use zip and reversed (and enumerate), not index arithmetic like word[len(word) - 1 - i].

mismatches("level")    # []        a palindrome
mismatches("lever")    # [0]       explained below

Precisely: for "lever", the pairs are l-r, e-e, v-v, e-e, r-l; positions in the first half are 0 and 1 (for a word of length n, the first half is positions 0 to n // 2 - 1). Position 0 differs, position 1 does not, so the result is [0].

More examples: mismatches("abcd") is [0, 1], mismatches("ab") is [0], mismatches("x") is [], mismatches("") is [].

File: solution.py

Starting point

def mismatches(word):
    return []
Hint 1

zip(word, reversed(word)) gives pairs (first, last), (second, second-to-last), ...

Hint 2

Wrap it in enumerate and unpack with for i, (a, b) in ....

Hint 3

Stop once i reaches len(word) // 2.

Reference solution
def mismatches(word):
    half = len(word) // 2
    result = []
    for i, (a, b) in enumerate(zip(word, reversed(word))):
        if i >= half:
            break
        if a != b:
            result.append(i)
    return result