Python Module 3 – Control flow: decisions, loops and pattern matching
for loops with range, enumerate and zip
Loop over lists, strings, dictionaries and ranges with Python's for, number items with enumerate, pair lists with zip and catch missing data with strict=True.
What you will learn
- Loop over sequences, dictionaries and ranges
- Use enumerate and zip instead of index arithmetic
- Detect length mismatches with zip(strict=True)
Before you start
On this page
A Python for loop does not count anything itself. It takes the items of a collection one at a time: each
character of a string, each name in a list, each key of a dictionary, and it works the same way for anything Python
can iterate over. When you do need numbers, range() supplies them, and enumerate() and zip() cover the two jobs
for which you would otherwise count positions by hand.
One item at a time
for name in collection: takes the first item, gives it the name name, runs the block, and repeats with the next
item until there are none left. There is no counter to update and no condition to get wrong:
for letter in "Go!":
print(letter)
for city in ["Pune", "Lagos", "Lima"]:
print(city.upper())
stock = {"pens": 12, "pads": 0, "clips": 40}
for item in stock:
print("key:", item)
for item, count in stock.items():
print(item, "->", count)
print("Total:", sum(stock.values())) Output
G o ! PUNE LAGOS LIMA key: pens key: pads key: clips pens -> 12 pads -> 0 clips -> 40 Total: 52
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 each_item.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A string gives its characters and a list its items, in order. A dictionary gives its keys, in the order they were
added. To get each key with its value, loop over stock.items() and unpack the two parts into two names, as the
second dictionary loop does; stock.values() gives the values alone.
break, continue and a loop’s else block work in a for loop as they do in the while loops of the previous
lesson: break leaves the loop, continue moves on to the next item, and the else block runs when the items run
out without a break, even when there were none at all.
Counting with range()
range(stop) gives the whole numbers from 0 up to, but not including, stop. range(start, stop) starts somewhere
else, and range(start, stop, step) takes steps of any size but 0, also backwards:
for n in range(3, 0, -1):
print(n)
print("Go")
print(list(range(5)))
print(list(range(2, 11, 3)))
print(list(range(10, 0, -2)))
print(list(range(4, 4)))
seats = range(0, 1_000_000_000, 7)
print(len(seats), seats[-1], 700 in seats, 701 in seats) Output
3 2 1 Go [0, 1, 2, 3, 4] [2, 5, 8] [10, 8, 6, 4, 2] [] 142857143 999999994 True False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ranges.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The countdown shows the rule that matters most: the stop value is never included. range(3, 0, -1) gives 3, 2
and 1, and range(4, 4) gives nothing at all, because there is no number from 4 up to but not including 4. A loop
over an empty range simply does not run. range(1, 11) is the way to write “1 to 10”.
The calls to list() are there only to print the numbers. A range does not store them: it keeps its start, stop
and step and works out each number when asked. So the range in the last line, with over 142 million numbers, takes no
more memory than range(5), and len(), indexing (seats[-1] is the last one) and in all work on it without
building a list.
Positions with enumerate()
When you need each item and its position, it is tempting to loop over range(len(items)) and look each item up.
enumerate() hands you both at once:
# In the order they crossed the finish line
finishers = ["Meera", "Tom", "Ana", "Kofi"]
for i in range(len(finishers)):
print(i + 1, finishers[i])
for place, name in enumerate(finishers, start=1):
print(place, name) Output
1 Meera 2 Tom 3 Ana 4 Kofi 1 Meera 2 Tom 3 Ana 4 Kofi
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finish_order.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Both loops print the same results table. The second has no i + 1 and no finishers[i], which are exactly the
places where off-by-one mistakes creep in. enumerate(items) counts from 0 like positions do; start=1 makes it count
the way people do.
Pairs with zip()
zip() walks through several lists side by side and gives one item from each in every pass. It is the way to combine
lists that belong together, such as names and their marks:
names = ["Asha", "Ravi", "Mei", "Kiran"]
marks = [78, 91, 66] # Kiran's mark was never entered
for name, mark in zip(names, marks):
print(name, mark)
print(len(list(zip(names, marks))), "pairs for", len(names), "names")
for name, mark in zip(names, marks, strict=True):
print(name, mark) Output (exit status 1)
Asha 78 Ravi 91 Mei 66 3 pairs for 4 names Asha 78 Ravi 91 Mei 66
Printed as an error (standard error)
Traceback (most recent call last):
File "missing_mark.py", line 8, in <module>
for name, mark in zip(names, marks, strict=True):
~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: zip() argument 2 is shorter than argument 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 missing_mark.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Four names but three marks, and the first loop does not complain: by default zip() stops as soon as the shortest
list runs out, so Kiran is silently left out. That default is useful when the lists may differ on purpose, and
dangerous when they should match, because the missing data goes unnoticed.
strict=True turns the mismatch into an error: ValueError: zip() argument 2 is shorter than argument 1. Notice
when the error came. The strict loop had already printed three pairs, because zip() only finds out that marks
is shorter when it reaches the end. If nothing may happen before the check, compare the lengths first, or build the
result completely, as in this lesson’s exercise, before you use any of it.
Version note
zip() gained strict in Python 3.10 (PEP 618). New in Python 3.14: map(), which calls a function with the
items of one or more lists, accepts strict=True too.
Other orders without changing the data
reversed() and sorted() let a loop visit the items in another order while the list itself stays as it was:
queue = ["Tom", "Asha", "Mei"]
for name in reversed(queue):
print("reversed:", name)
for name in sorted(queue):
print("sorted:", name)
print("queue is still", queue) Output
reversed: Mei reversed: Asha reversed: Tom sorted: Asha sorted: Mei sorted: Tom queue is still ['Tom', 'Asha', 'Mei']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 other_orders.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
sorted() makes a new, sorted list; reversed() reads the list from the end without copying it. Neither changes
queue, as the last line shows.
Changing a collection while you loop over it
Behind the scenes, a for loop over a list moves through it by position: 0, then 1, then 2. Remove an item during
the loop and the items after it move up one place, so the loop skips the one that took its place:
marks = [30, 20, 65, 34, 90]
# The bug: removing items from the list that the loop is reading
kept = marks.copy()
for m in kept:
if m < 35:
kept.remove(m)
print("Bug: ", kept)
# Fix 1: loop over a copy and remove from the list itself
kept = marks.copy()
for m in kept.copy():
if m < 35:
kept.remove(m)
print("Copy: ", kept)
# Fix 2: build a new list
passed = []
for m in marks:
if m >= 35:
passed.append(m)
print("New list:", passed) Output
Bug: [20, 65, 90] Copy: [65, 90] New list: [65, 90]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 remove_in_loop.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The bug leaves 20 in the list. When 30 was removed, 20 moved into position 0, which the loop had already visited, and the next pass looked at 65. There are two dependable fixes: loop over a copy and change the original, or build a new list with the items you want to keep, which is usually the clearer of the two.
Dictionaries are stricter. The documentation warns that adding or removing keys while you loop over a dictionary may
raise RuntimeError or miss entries, and that is what happens here:
stock = {"pens": 12, "pads": 0, "clips": 40, "tape": 0}
for item in list(stock):
if stock[item] == 0:
del stock[item]
print(stock)
for item in stock:
if item == "pens":
stock["pencils"] = 5 Output (exit status 1)
{'pens': 12, 'clips': 40}
Printed as an error (standard error)
Traceback (most recent call last):
File "dict_resize.py", line 8, in <module>
for item in stock:
^^^^^
RuntimeError: dictionary changed size during iteration
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dict_resize.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The first loop works because list(stock) copies the keys before the loop starts. The second adds a key to the
dictionary it is reading, and Python stops it with RuntimeError at its next step.
The loop variable after the loop
The loop variable is an ordinary variable. After the loop it still holds the last item, and if the loop had nothing to go through, it was never set at all:
for n in range(3):
pass
print("n after the loop:", n)
for m in []:
pass
print("m after the loop:", m) Output (exit status 1)
n after the loop: 2
Printed as an error (standard error)
Traceback (most recent call last):
File "after_the_loop.py", line 7, in <module>
print("m after the loop:", m)
^
NameError: name 'm' is not defined
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 after_the_loop.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
n is 2, the last number of range(3). The loop over the empty list never ran, so m does not exist and using it
is a NameError. If you need a value from a loop that might not run, give the variable a value before the loop.
Key takeaways
fortakes the items of any iterable one at a time: characters of a string, items of a list, keys of a dictionary (.items()for keys with values).range(start, stop, step)never includesstop; it stores no numbers, yet supportslen(), indexing andin.- Use
enumerate(items, start=1)instead ofrange(len(items)), andzip()to walk lists side by side. zip()stops at the shortest input without a word;zip(..., strict=True)raisesValueErrorwhen the lengths differ, but only after it has given every pair it could make.- Do not add or remove items of the collection you are looping over: loop over a copy or build a new list.
reversed()andsorted()change the order of a loop, not the data.
Exercise
Exercise · Easy · Python
Pair names with marks and keep a running total
Write two functions with for loops.
merge_marks(names, marks)takes a list of names and a list of marks in the same order and returns a dictionary from each name to its mark:merge_marks(["Asha", "Ravi"], [78, 91])returns{"Asha": 78, "Ravi": 91}. Each name appears once. When one list is longer than the other, a name or a mark is missing: raiseValueErrorinstead of returning a dictionary (zip(..., strict=True)raises it for you). Two empty lists give an empty dictionary.running_total(nums)returns a new list in which each item is the sum of the numbers up to and including that position:running_total([3, 4, 5])returns[3, 7, 12]. An empty list gives[], andnumsitself must not change.
Starter code · marks.py
def merge_marks(names, marks):
"""Return a dict of each name's mark; ValueError if lengths differ."""
result = {}
return result
def running_total(nums):
"""Return a new list of the sums so far."""
totals = []
return totals The sample tests · test_marks.py
from marks import merge_marks, running_total
def raises_value_error(names, marks):
"""True when merge_marks(names, marks) raises ValueError."""
try:
merge_marks(names, marks)
except ValueError:
return True
return False
def test_merge():
"""pairs each name with its mark"""
assert merge_marks(["Asha", "Ravi"], [78, 91]) == {"Asha": 78, "Ravi": 91}
assert merge_marks(["Mei"], [66]) == {"Mei": 66}
def test_merge_empty():
"""gives an empty dictionary for two empty lists"""
assert merge_marks([], []) == {}
def test_merge_mismatch():
"""raises ValueError when a name or a mark is missing"""
assert raises_value_error(["Asha", "Ravi", "Kiran"], [78, 91])
assert raises_value_error(["Asha"], [78, 91])
assert raises_value_error([], [50])
def test_running_total():
"""adds up the numbers so far at each position"""
assert running_total([3, 4, 5]) == [3, 7, 12]
assert running_total([10]) == [10]
assert running_total([5, -5, 2]) == [5, 0, 2]
def test_running_total_empty():
"""gives an empty list for an empty list"""
assert running_total([]) == []
def test_running_total_keeps_input():
"""leaves the list it is given unchanged"""
nums = [1, 2, 3]
running_total(nums)
assert nums == [1, 2, 3] A hint
For merge_marks, start with an empty dictionary and fill it in a loop over zip(names, marks, strict=True): each pass gives you one name and its mark. For running_total, keep the sum so far in a variable that starts at 0, add each number to it, and append the sum to a new list after every addition.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- The Python Tutorial: for Statements (Python Software Foundation)
- The Python Tutorial: The range() Function (Python Software Foundation)
- The Python Language Reference: The for statement (Python Software Foundation)
- The Python Tutorial: else Clauses on Loops (Python Software Foundation)
- Built-in Types: Ranges (Python Software Foundation)
- Built-in Functions: enumerate() (Python Software Foundation)
- Built-in Functions: zip() (Python Software Foundation)
- Built-in Types: Mapping Types, dict (Python Software Foundation)
- PEP 618: Add Optional Length-Checking To zip (Python Software Foundation)
- What's New In Python 3.14: built-ins (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress