Python For Loops: range(), enumerate() and zip() Explained

A Python for loop visits each item of an iterable. How range() counts, when to use enumerate and zip, looping over a dict and why not to edit a list mid-loop.

  • Course: Python study plan
  • Module: Control flow
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: CPython 3.11

How does a for loop work in Python?

A Python for loop is a for-each: it takes any iterable — a list, string, range, dictionary, file or generator — and binds the loop variable to each element in turn. There is no index unless you ask for one with enumerate; range supplies counting and zip walks two sequences together. break, continue and else work as they do in a while loop.

Lesson

Python's for is a for-each: it takes an iterable — a list, a string, a range, a dictionary, a file, a generator — and binds the loop variable to each element in turn. There is no index unless you ask for one, and asking is done with enumerate; walking two sequences together is zip; counting is range. Learners from C write for i in range(len(xs)): x = xs[i], which works and marks them as learners. This lesson fixes the idioms, explains range precisely, and states the one rule about modifying a collection while iterating over it.

The statement

for ch in "abc":
    print(ch)

for name in ["ada", "grace"]:
    print(name.title())

for key in {"a": 1, "b": 2}:          # a dict iterates its keys
    print(key)

The loop variable is an ordinary name bound in the enclosing scope; it keeps its last value after the loop (for i in range(3): pass; print(i) prints 2), and rebinding it inside the body does not affect the iteration — the next value comes from the iterable, not from the variable. break, continue and else work exactly as in while: else runs when the iterable is exhausted without a break.

for x in xs:
    if x < 0:
        print("has a negative")
        break
else:
    print("all non-negative")

range

range(stop), range(start, stop), range(start, stop, step) — half-open, like slicing: start included, stop excluded.

list(range(5))            # [0, 1, 2, 3, 4]
list(range(2, 5))         # [2, 3, 4]
list(range(0, 10, 3))     # [0, 3, 6, 9]
list(range(5, 0, -1))     # [5, 4, 3, 2, 1]
list(range(3, 3))         # []

Half-open ranges compose: range(0, n) then range(n, m) cover 0 … m-1 exactly once; the count of elements is stop - start for step 1; range(len(xs)) is exactly the valid indices. A range is not a list — it is a small object that computes elements on demand, so range(10 ** 9) costs nothing until iterated, and 5 in range(0, 100, 5) is answered arithmetically. Counting down is range(n - 1, -1, -1), or reversed(range(n)), which reads better.

The loop for _ in range(n): is the idiom for "n times, the number does not matter"; the underscore is a convention meaning "unused".

enumerate: index and element

for i, name in enumerate(names):          # 0-based
    print(i, name)

for i, name in enumerate(names, start=1): # 1-based, for display
    print(f"{i}. {name}")

enumerate yields (index, element) pairs, unpacked into two names by the for. Reach for it whenever the body needs the position — to print a line number, to compare with a neighbour xs[i + 1], to record where a match was found. for i in range(len(xs)) is the older spelling and is right only when the body needs the index and not the element, or must write xs[i] = ….

zip: parallel sequences

for name, score in zip(names, scores):
    print(f"{name}: {score}")

for i, (name, score) in enumerate(zip(names, scores), start=1):
    ...

zip stops at the shortest input, silently; zip(a, b, strict=True) (3.10) raises ValueError on a length mismatch, which is what you want when the sequences are supposed to correspond. dict(zip(keys, values)) builds a mapping from two lists; zip(*grid) transposes a list of rows into columns (Module 6). itertools.zip_longest pads the shorter one instead of truncating.

Iterating dictionaries

for k in d gives keys; for v in d.values() values; for k, v in d.items() both. Insertion order is preserved (since 3.7), so a dictionary iterates in the order its keys were added. for k in sorted(d) iterates the keys in sorted order.

Nested loops

for r in range(rows):
    for c in range(cols):
        visit(r, c)

Leaving both loops at once has no break 2. The options: put the loops in a function and return; set a flag and test it in the outer loop; or iterate the pairs with itertools.product(range(rows), range(cols)), which makes it one loop with one break. The function is the cleanest when the search is a unit of work.

Modifying while iterating

Do not add to or remove from a list, dict or set while a for loop is iterating over it. Removing shifts the elements and the loop skips one; adding can make it infinite; a dict raises RuntimeError: dictionary changed size during iteration. The fixes are to iterate over a copy (for x in list(xs):), to build a new collection (kept = [x for x in xs if keep(x)] — the comprehension is almost always what you meant), or to collect the changes and apply them after the loop. Modifying elements in place (xs[i] = …) is fine.

Pitfalls

  • for i in range(len(xs)): x = xs[i] where for x in xs or enumerate was meant.
  • range(1, n) when n should be included: the stop is excluded.
  • zip truncating silently when the lists should have matched — strict=True.
  • xs.remove(x) inside for x in xs.
  • Expecting a for loop to have its own scope; the loop variable and everything assigned in the body are visible after it.
  • for k, v in d: — iterating a dict gives keys; .items() gives pairs.

Key takeaways

  • for iterates the elements of any iterable; break, continue, else as with while.
  • range(start, stop, step) is half-open and lazy; range(len(xs)) is the valid indices; for _ in range(n) repeats.
  • enumerate for index and element, zip for parallel sequences (strict=True to check lengths), .items() for dict pairs.
  • Leave nested loops with a function return or itertools.product.
  • Never change a collection's size while iterating it; iterate a copy or build a new one.

Common questions

How does range() work in Python?

range(start, stop, step) produces integers from start up to but not including stop: range(5) gives 0 to 4 and range(0, 10, 3) gives 0, 3, 6, 9. It is lazy, computing values on demand, so range(10 ** 9) costs nothing until iterated. Count down with range(n - 1, -1, -1) or reversed(range(n)).

How do I get the index in a Python for loop?

Use enumerate: for i, name in enumerate(names): yields each index with its element, and enumerate(names, start=1) counts from 1 for display. for i in range(len(xs)) is right only when the body needs the index and not the element, or assigns to xs[i].

What does zip do in Python?

zip walks several iterables in parallel, yielding tuples of corresponding elements, as in for name, score in zip(names, scores):. It stops silently at the shortest input; since Python 3.10, zip(a, b, strict=True) raises ValueError on a length mismatch. dict(zip(keys, values)) builds a mapping from two lists.

How do I loop over a dictionary in Python?

for k in d iterates the keys, for v in d.values() the values, and for k, v in d.items() both at once. Dictionaries keep insertion order since Python 3.7, so they iterate in the order their keys were added; for k in sorted(d) visits the keys in sorted order.

How do I remove items from a list while iterating over it in Python?

Do not remove them inside a loop over that same list: the elements shift and the loop skips one. Build a new list instead, such as kept = [x for x in xs if keep(x)], or iterate over a copy with for x in list(xs):. Changing a dictionary's size during iteration raises RuntimeError.

Exercises

Multiplication table

Print the n × n multiplication table with nested for loops over range(1, n + 1). Every entry is right-aligned in a field as wide as the largest product, and entries on a row are separated by one space.

Input: n (1–12). Output: n rows.

4

prints

 1  2  3  4
 2  4  6  8
 3  6  9 12
 4  8 12 16

Ranked pairs

Two lines hold names and integer scores in matching order. Print them numbered from 1 using enumerate over zip(names, scores, strict=True), then the name with the highest score (the first one on a tie). If the two lines have different lengths, strict=True raises ValueError — catch it and print length mismatch instead.

Input: a line of names, then a line of integers. Output: <i>. <name> <score> per pair, then best: <name>; or the single line length mismatch.

ada grace linus
90 95 88

prints

1. ada 90
2. grace 95
3. linus 88
best: grace

In this module: Control flow

← While loops — repeat until a condition changes · Match statements — structural pattern matching →