IntermediatePython · 36 min · 19 challenges

Loops: Intermediate

Each year Spotify Wrapped shows every listener their top songs. Picture the job that builds it: a list of track names beside a list of play counts, a dict of minutes per artist, a grid of plays per month. It numbers the top 5, pairs the columns, walks the dict, scans the grid, and must notice the listener whose search finds nothing before an empty card ships.

enumerate() for Index

Daily Life
Interviews

A for loop over a list hands you each item and says nothing about where that item sat. Data work keeps asking for the position: a validation step has to name the row that failed, a cleanup has to write a corrected value back into the right slot, and a progress line has to say how far a batch has got. The usual first attempt is a counter bumped by hand, and it drifts the moment the body skips ahead.

amounts = [120, -5, 80, -2, 40]
row = 0
for amount in amounts:
if amount >= 0:
continue
print("row", row, "is negative:", amount)
row += 1
>>>Output
row 0 is negative: -5
row 1 is negative: -2

The negative amounts sit at positions 1 and 3, and the report says 0 and 1. The continue sends control back to the top of the loop before row += 1 runs, so the counter counts only the steps that reached the bottom of the body. Moving the increment higher would fix this copy until someone adds another continue above it. The position should come from the loop itself, which is what enumerate() provides.

1fruits = ["apple", "banana", "cherry"]
2
3print("Manual approach:")
4i = 0
5for fruit in fruits:
6 print(str(i) + ": " + fruit)
7 i += 1
8
9print()
10
11print("With enumerate:")
12for index, fruit in enumerate(fruits):
13 print(str(index) + ": " + fruit)
i=
fruit=
index=
fruits
[
]
012
output0 lines
fruits is a new list that holds 'apple', 'banana', 'cherry'
1/23

enumerate(amounts) wraps the list, and each step of the loop receives a pair: the position first, the item second. The header for i, amount in enumerate(amounts): splits each pair into 2 names with the same multiple assignment as a, b = 1, 2. Python moves the position forward on every step whatever the body does, so a continue can no longer knock it out of line with the list.

amounts = [120, -5, 80, -2, 40]
for i, amount in enumerate(amounts):
if amount >= 0:
continue
print("row", i, "is negative:", amount)
>>>Output
row 1 is negative: -5
row 3 is negative: -2
StepiamountReaches print?
10120no
21-5yes
3280no
43-2yes
5440no
TIP
Write 2 names after for. With a single name, as in for pair in enumerate(amounts), each step binds the whole pair, which prints as (0, 120), and a test like pair < 0 stops with a TypeError because a pair cannot be compared with a number.

Choosing where the count starts

Positions start at 0 because list indexes do. A report that people read counts from 1, so enumerate takes a start argument that sets the first number. It renumbers the positions and moves nothing: the first item handed out is still the one stored at index 0.

files = ["orders.csv", "refunds.csv", "users.csv"]
for n, name in enumerate(files, start=1):
print("loading file", n, "of 3:", name)
>>>Output
loading file 1 of 3: orders.csv
loading file 2 of 3: refunds.csv
loading file 3 of 3: users.csv

Writing back through the position

Position matters most when the loop changes the list. Assigning to the loop variable, amount = 0, points that name at a new value and leaves the list as it was. Assigning through the index, amounts[i] = 0, replaces the value stored in the list. The program below tries both.

amounts = [120, -5, 80, -2, 40]
for i, amount in enumerate(amounts):
if amount < 0:
amount = 0
print(amounts)
for i, amount in enumerate(amounts):
if amount < 0:
amounts[i] = 0
print(amounts)
>>>Output
[120, -5, 80, -2, 40]
[120, 0, 80, 0, 40]
The start=1 trap
start renumbers the counter, and the list keeps its 0-based indexes. Using a counter from enumerate(xs, start=1) as an index reaches 1 slot to the right of the item in hand, and on the last item it asks for a slot past the end, which stops the program with an IndexError.
Multiple Choice

This loop is meant to replace the 20 with 99. What does it print?

prices = [10, 20, 30]
for i, price in enumerate(prices, start=1):
if price == 20:
prices[i] = 99
print(prices)

So start=1 belongs where the counter is only shown to a person, in a printed line or a report row. Any read or write into the list keeps using the default 0-based counter, which matches the indexes the list actually has.

zip() Parallel Iteration

Daily Life
Interviews

Data often arrives column by column: a list of user IDs, a list of countries and a list of play counts, where position 2 in each list describes the same user. Reading a user means taking the same position from every list. With enumerate that is for i, user in enumerate(users): plus countries[i] and plays[i] in the body, which works and puts an index on every line that reads a column.

zip(users, countries) walks the lists in lockstep. Its first step pairs the first item of each list, its second step the second items, and so on, so for user, country in zip(users, countries): binds 1 name per list and needs no index. It accepts any number of lists, and the count of names after for has to match the count of lists passed in.

1names = ["Alice", "Bob", "Charlie"]
2ages = [25, 30, 35]
3
4for name, age in zip(names, ages):
5 print(name + " is " + str(age) + " years old")
age=
name=
names
[
]
012
ages
[
]
012
output0 lines
names is a new list that holds 'Alice', 'Bob', 'Charlie'
1/10

Each step takes the next name and the next age together

users = ["u7", "u12", "u31"]
countries = ["BR", "SE", "JP"]
plays = [140, 85, 302]
for user, country, count in zip(users, countries, plays):
print(user, country, count)
>>>Output
u7 BR 140
u12 SE 85
u31 JP 302
✓Counter plus indexes
  • One list drives the loop and the rest are read by position
  • Every column read is a [i] that can name the wrong list
  • A short list fails with an IndexError partway through
✗zip
  • All lists advance together, 1 item each per step
  • Each column gets its own name in the header
  • A short list ends the loop early and raises nothing

The shortest list decides

zip stops the moment any of its lists runs out. It raises no error and prints no warning, and the leftover items in the longer lists are never visited.

1names = ["Alice", "Bob", "Charlie", "Diana"]
2scores = [85, 92, 78]
3
4for name, score in zip(names, scores):
5 print(name + ":", score)
6
7print()
8
9letters = ["a", "b"]
10numbers = [1, 2, 3, 4, 5]
11for letter, num in zip(letters, numbers):
12 print(letter, num)
name=
score=
num=
letter=
names
[
]
0123
scores
[
]
012
letters
[
]
01
numbers
[
]
01234
output0 lines
names is a new list that holds 'Alice', 'Bob', 'Charlie', 'Diana'
1/18

Diana and the numbers 3, 4 and 5 have no partner, so zip never visits them

In a pipeline, that silence is the risk. Suppose the country lookup failed for the last user and countries came back 1 item short. A loop over zip(users, countries, plays) writes 2 rows where there should be 3, and nothing in the output shows that u31 went missing.

Positionuserscountriesplayszip step
0u7BR1401
1u12SE852
2u31missing302never reached
TIP
Before the loop, test len(users) == len(countries) == len(plays) and report the mismatch instead of loading when it is False. From Python 3.10, zip(users, countries, plays, strict=True) raises a ValueError on a length mismatch, but 3.9 has no such option.

Positions and parallel values together

When the row number matters too, wrap the zip in enumerate: for i, (user, count) in enumerate(zip(users, plays)):. Each step of enumerate delivers a position and an item, and here the item is itself a pair made by zip. The parentheses copy that shape: i takes the position and (user, count) splits the inner pair. Without them, Python sees 3 names for a value with 2 parts and stops with ValueError: not enough values to unpack (expected 3, got 2).

users = ["u40", "u41", "u42"]
plays = [12, 0, 9]
for i, (user, count) in enumerate(zip(users, plays), start=1):
if count == 0:
print("row", i, "user", user, "has no plays")
>>>Output
row 2 user u41 has no plays

Turning 2 lists into a dict

A frequent job for zip is building a lookup from 2 columns. The first list supplies the keys, the second supplies the values, and the loop assigns each pair.

users = ["u7", "u12", "u31"]
plays = [140, 85, 302]
plays_by_user = {}
for user, count in zip(users, plays):
plays_by_user[user] = count
print(plays_by_user)
print(plays_by_user["u12"])
>>>Output
{'u7': 140, 'u12': 85, 'u31': 302}
85
If a key appears twice in the first list, the later assignment overwrites the earlier one, and the dict ends up with fewer entries than the lists have items.
Multiple Choice

The names list has 4 items and the scores list has 3. What does this program print?

names = ["ana", "ben", "cal", "dee"]
scores = [3, 9, 4]
total = 0
for name, score in zip(names, scores):
total += score
print(total)

Building a lookup with zip takes 3 pieces: an empty dict created before the loop, a header with 1 name per list, and a body that assigns the second name's value under the first name as the key.

Iterating Dict Items

Daily Life
Interviews

Once records are keyed in a dict, a loop can walk 3 things: the keys, the values, or both at once. A plain for over a dict walks its keys, in the order they were first inserted. The program below walks the same dict all 3 ways.

plays = {"BR": 140, "SE": 85, "JP": 302}
for code in plays:
print(code)
for count in plays.values():
print(count)
for code, count in plays.items():
print(code, count)
>>>Output
BR
SE
JP
140
85
302
BR 140
SE 85
JP 302
Loop headerEach step bindsReach for it when
`for code in plays:`a keythe body needs the keys, or reads only some values
`for count in plays.values():`a valuekeys play no part, as in a total
`for code, count in plays.items():`a key and its valuethe body uses both, the common case
1person = {"name": "Alice", "age": 25, "city": "NYC"}
2
3for key, value in person.items():
4 print(key + ": " + str(value))
5
6print()
7
8config = {"debug": True, "timeout": 30, "max_retries": 3}
9print("Current settings:")
10for setting, value in config.items():
11 print(" " + setting + " = " + str(value))
key=
value=
setting=
person
:
:
:
config
:
:
:
output0 lines
person is a new dict that holds 3 keys
1/19

Each step hands the loop 1 key and the value stored under it

.items() delivers each key together with its value, the same shape zip(keys, values) would make, so 2-name unpacking carries over unchanged. A keys loop that reads plays[code] in its body reaches the same values with an extra lookup per step and an extra place to mistype the key.

Counting first, reporting second

A dict is often built by a loop and read by the next. The first loop below counts signups per country: a code seen for the first time gets a new key set to 1, and a repeat adds 1 to the stored count. The second loop reports the totals through .items().

signups = ["SE", "JP", "BR", "JP", "BR", "JP"]
per_country = {}
for code in signups:
if code in per_country:
per_country[code] += 1
else:
per_country[code] = 1
for code, n in per_country.items():
print(code, n)
>>>Output
SE 1
JP 3
BR 2

The report lists SE, then JP, then BR, which is the order the codes first appeared in signups. A dict keeps insertion order and nothing more, so a report sorted by name or by count needs a sorting step of its own.

Forgetting .items()

Drop .items() from the header and the loop goes back to walking keys, so for code, count in plays: tries to split each key into 2 names. What happens next depends on the key. A key like "Alice" has 5 characters, and Python stops with ValueError: too many values to unpack (expected 2). A 2-letter country code has exactly 2 characters, so the split succeeds: code gets the first letter, count gets the second, and the loop runs to the end printing nonsense.

plays = {"BR": 140, "SE": 85, "JP": 302}
for code, count in plays:
print(code, count)
>>>Output
B R
S E
J P

Filtering into a new dict

A filtered result goes into a new dict that the loop fills as it walks the original, which is the first step of the animation below, followed by a curve applied to every score and a search for the top scorer.
1scores = {"Alice": 85, "Bob": 92, "Charlie": 78, "Diana": 95}
2
3passing = {}
4for name, score in scores.items():
5 if score >= 80:
6 passing[name] = score
7print("Passing:", passing)
8
9for name, score in scores.items():
10 scores[name] = score + 5
11print("Curved:", scores)
12
13top_name = None
14top_score = -1
15for name, score in scores.items():
16 if score > top_score:
17 top_score = score
18 top_name = name
19print("Top:", top_name, top_score)
name=
score=
top_name=
top_score=
scores
:
:
:
:
passing
:
:
:
output0 lines
scores is a new dict that holds 4 keys
1/44

The curve changes values under existing keys, so the keys stay the same throughout

✓Do
  • Use .items() whenever the body needs both the key and its value
  • Use .values() for totals and checks where the keys play no part
  • Build filtered or reshaped results in a new dict
✗Don't
  • Unpack 2 names from a dict without .items()
  • Look up d[k] in a keys loop when .items() already hands you the value
Multiple Choice

This dict has a 2-letter key and a 3-letter key. What does the loop print?

plays = {"US": 9, "GBR": 4}
for code, count in plays:
print(code, count)

Every error of this kind is fixed in the header, never in the body. With .items() added, each step offers exactly 2 parts, a key and its value, whatever the length of the key.

Nested Loops

Daily Life
Interviews
A loop placed inside another loop runs its whole course once for every step of the outer one. With 3 outer steps and 2 inner steps, the inner body runs 6 times, in a fixed order: both inner steps for outer step 1, then both for outer step 2, then both for outer step 3.
1for i in range(1, 4):
2 print("Outer:", i)
3 for j in range(1, 3):
4 print(" Inner:", j)
5 print()
i=
j=
output0 lines
i takes 1, item 1 of range(1, 4)
1/26

Walking a grid

Tabular data often arrives as a list of rows in which every row is itself a list of cells. That shape is a grid. sales[1] is the second row, a list, and sales[1][2] indexes into that list for its third cell. The outer loop takes the rows and the inner loop takes the cells of the row in hand.

sales = [
[12, 0, 7],
[3, 9, 4],
[0, 0, 15],
]
for store, row in enumerate(sales, start=1):
total = 0
for amount in row:
total += amount
print("store", store, "total", total)
>>>Output
store 1 total 19
store 2 total 16
store 3 total 15

Where total = 0 sits decides what it counts. Inside the outer loop it resets for each store. Moved above the outer loop, it never resets, and the 3 lines read 19, 35 and 50: running totals across all stores.

Wrapping both levels in enumerate gives every cell a pair of coordinates, which is how a data check reports where a bad value sits.

sales = [
[12, 0, 7],
[3, 9, 4],
[0, 0, 15],
]
for r, row in enumerate(sales):
for c, amount in enumerate(row):
if amount == 0:
print("empty cell at row", r, "col", c)
>>>Output
empty cell at row 0 col 1
empty cell at row 2 col 0
empty cell at row 2 col 1
TIP
Give each loop its own variable. If the inner loop reuses the outer loop's name, say i, the outer loop still steps through its own items, but for the rest of each outer pass i holds the inner loop's last value.

Generating combinations

The second use of nested loops is pairing items. Looping over the same list twice makes every item meet every item: 4 teams give 16 pairs, including each team against itself and every match listed in both orders. To list each match once, start the inner loop 1 position past the outer one with range(i + 1, 4).

teams = ["ants", "bees", "cats", "dogs"]
for i in range(4):
for j in range(i + 1, 4):
print(teams[i], "vs", teams[j])
>>>Output
ants vs bees
ants vs cats
ants vs dogs
bees vs cats
bees vs dogs
cats vs dogs
iteams[i]j takesPairs printed
0ants1, 2, 33
1bees2, 32
2cats31
3dogsnothing0
The work a nested loop does is the outer size multiplied by the inner size. A 3 by 3 grid costs 9 inner steps. Comparing 2 lists of records item against item costs far more, and that comparison is where nested loops usually turn slow. A later lesson on joining records removes that inner loop entirely.
1,000,000
inner steps for 2 lists of 1,000
499,500
unique pairs among 1,000 records

break exits only the inner loop

break ends the innermost loop it sits in. In a grid search, finding the target breaks the cell loop, and the row loop moves on to the next row unless something tells it to stop as well. The usual signal is a variable set at the moment of the find and checked right after the inner loop.

1grid = [
2 [1, 2, 3],
3 [4, 5, 6],
4 [7, 8, 9],
5]
6target = 5
7found_row = None
8found_col = None
9for r, row in enumerate(grid):
10 for c, value in enumerate(row):
11 if value == target:
12 found_row = r
13 found_col = c
14 break
15 if found_row is not None:
16 break
17print("Found", target, "at row", found_row, "col", found_col)
target=
found_row=
found_col=
r=
c=
value=
grid
[
]
012
row
[
]
012
row
[
]
012
output0 lines
runs
1/28

The break ends the scan of row 1, and the check after it ends the row loop

Multiple Choice

Every row of this grid holds a 5. What does the program print?

grid = [[1, 5], [5, 2], [3, 5]]
hits = 0
for row in grid:
for value in row:
if value == 5:
hits += 1
break
print(hits)

Stopping the whole scan takes 3 pieces: a variable set when the match is recorded, a break in the inner loop, and an if on that variable placed after the inner loop at the outer loop's indentation, holding a second break.

Loop else Clauses

Daily Life
Interviews

The grid search in the previous section kept found_row and found_col for 2 jobs: holding where the match sat, and telling the code after the loops whether a match happened at all. Python has a clause for the second job. A for or while loop can carry an else block, and the block runs only when the loop ends without a break.

1numbers = [1, 3, 5, 7, 9]
2for num in numbers:
3 if num % 2 == 0:
4 print("Found even:", num)
5 break
6else:
7 print("No even numbers found")
8
9print()
10
11numbers2 = [1, 3, 4, 7, 9]
12for num in numbers2:
13 if num % 2 == 0:
14 print("Found even:", num)
15 break
16else:
17 print("No even numbers found")
num=
numbers
[
]
01234
numbers2
[
]
01234
output0 lines
numbers is a new list that holds 1, 3, 5, 7, 9
1/24

The first loop runs out of numbers and reaches else, while the second breaks at 4 and skips it

Read the else as "no break". In the first search no number is even, the loop runs out of items, and the else prints its message. In the second, the 4 triggers break, which skips the else along with the rest of the loop. The clause lines up with for because it belongs to the loop.

How the loop endedDoes else run?
It ran out of itemsYes
A `break` firedNo
The list was empty, so the body never ranYes
Some steps took `continue`, then items ran outYes
A `while` condition turned falseYes

The empty-list row catches people out. A loop over [] finishes at once without a break, so its else runs. For a search that is the right answer: nothing in an empty list matched, and the not-found branch should fire.

An else in the wrong column

Indentation decides which statement owns an else. Indent it under the if inside the loop and it becomes that if's branch, which runs for every item that fails the test, before the search has finished.

codes = ["BR", "SE", "JP"]
for code in codes:
if code == "SE":
print("found SE")
break
else:
print("no SE")
>>>Output
no SE
found SE

The program reports SE missing and then reports it found. Moving else: 4 spaces to the left attaches it to the for, and the output shrinks to found SE.

while loops take else too

A while loop takes an else under the same rule: the block runs when the condition turns false and is skipped when a break ends the loop. That fits retry logic, where a job tries a load up to 3 times, stops early on success, and reports failure only after using every attempt. The outcomes below stand in for 3 load attempts.

outcomes = ["timeout", "timeout", "timeout"]
attempt = 0
while attempt < 3:
result = outcomes[attempt]
attempt += 1
if result == "ok":
print("loaded on attempt", attempt)
break
else:
print("gave up after", attempt, "attempts")
>>>Output
gave up after 3 attempts

Change the second outcome to "ok" and the program prints loaded on attempt 2 and never reaches the else.

Leaving 2 loops at once

A loop else can also replace the found-variable from the grid search. Give the inner loop an else: continue and put a break straight after it. When the inner loop runs out of items, its else sends the outer loop on to the next line. When the inner loop breaks, the else is skipped and the break below it ends the outer loop too. The outer loop's own else reports a grid with no match.

readings = [
[40, 41, 39],
[42, 120, 44],
[38, 37, 36],
]
for i, line in enumerate(readings):
for j, reading in enumerate(line):
if reading > 100:
print("spike at line", i, "position", j)
break
else:
continue
break
else:
print("no spikes")
>>>Output
spike at line 1 position 1

Both versions give the same result. A found-variable reads more plainly to someone who has never met a loop else, so match whichever style the surrounding code already uses.

Multiple Choice

No batch arrived today, so batches is empty. What does the program print?

batches = []
for batch in batches:
if batch == "bad":
print("stopping at bad batch")
break
else:
print("all batches clean")
print("done")

One check that uses all 5 tools

The program below checks hourly row counts for 3 tables. zip pairs each table name with its row of counts, enumerate supplies the hour, the inner loop stops at the first empty hour, its else marks tables that never had one, and an .items() loop prints the status dict.

tables = ["orders", "users", "refunds"]
hourly = [
[410, 395, 402],
[88, 0, 91],
[15, 12, 18],
]
status = {}
for table, counts in zip(tables, hourly):
for hour, rows in enumerate(counts):
if rows == 0:
status[table] = "empty at hour " + str(hour)
break
else:
status[table] = "ok"
for table, state in status.items():
print(table, state)
>>>Output
orders ok
users empty at hour 1
refunds ok

In that program the inner loop's else sits in the same column as for hour, rows in enumerate(counts):, 4 spaces in from the outer for. Moved 4 spaces left, it would belong to the outer loop and run once, after every table had been checked.

❯❯❯PUTTING IT ALL TOGETHER

> At Spotify, picture the job that prepares Wrapped cards. For each listener it holds a list of track names, a parallel list of play counts, a dict of minutes per artist and a grid of plays by month, and it must never ship a card whose top songs are missing.

zip pairs the track names with their play counts, and enumerate(..., start=1) numbers the top 5 the way the card shows them.
.items() walks minutes per artist, so each artist's name stays attached to its total while the job picks the top artist.
Nested loops scan the month-by-track grid, and the inner break ends a month's scan at the first track over the threshold.
A for over the listener's play counts breaks at the first count above zero, and its else catches a listener with no plays, whose card is held back before it ships empty.
KEY TAKEAWAYS
enumerate(xs, start=1) attaches a position to each item that no continue can knock out of step, and start renumbers without moving any item.
zip walks lists in lockstep and stops at the shortest without a word, so confirm parallel lists hold equal counts first.
A loop over a dict walks keys. .values() gives the values, .items() gives both, and a missing .items() can split keys into letters.
An inner loop runs in full for each outer step, costs the product of the 2 sizes, and break exits only the inner one.
A loop's else runs only when no break fired, including when the loop had nothing to visit.

Track positions, pair parallel lists, walk dicts, nest loops, and make a search say when it found nothing.

Category
Python
Difficulty
intermediate
Duration
36 minutes
Challenges
19 hands-on challenges

Topics covered: enumerate() for Index, zip() Parallel Iteration, Iterating Dict Items, Nested Loops, Loop else Clauses

Lesson Sections

  1. enumerate() for Index (concepts: Indexed Iteration)

    Choosing where the count starts Writing back through the position

  2. zip() Parallel Iteration (concepts: Parallel Iteration)

    The shortest list decides Positions and parallel values together Turning 2 lists into a dict If a key appears twice in the first list, the later assignment overwrites the earlier one, and the dict ends up with fewer entries than the lists have items.

  3. Iterating Dict Items (concepts: Dict Iteration)

    Counting first, reporting second Forgetting .items() Filtering into a new dict A filtered result goes into a new dict that the loop fills as it walks the original, which is the first step of the animation below, followed by a curve applied to every score and a search for the top scorer.

  4. Nested Loops (concepts: Nested Loops)

    A loop placed inside another loop runs its whole course once for every step of the outer one. With 3 outer steps and 2 inner steps, the inner body runs 6 times, in a fixed order: both inner steps for outer step 1, then both for outer step 2, then both for outer step 3. Walking a grid Generating combinations The work a nested loop does is the outer size multiplied by the inner size. A 3 by 3 grid costs 9 inner steps. Comparing 2 lists of records item against item costs far more, and that comparis

  5. Loop else Clauses (concepts: Loop else)

    An else in the wrong column while loops take else too Leaving 2 loops at once Both versions give the same result. A found-variable reads more plainly to someone who has never met a loop else, so match whichever style the surrounding code already uses. One check that uses all 5 tools