Loops: Intermediate
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.
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"]23print("Manual approach:")4i = 05for fruit in fruits:6 print(str(i) + ": " + fruit)7 i += 189print()1011print("With enumerate:")12for index, fruit in enumerate(fruits):13 print(str(index) + ": " + fruit)
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.
| Step | i | amount | Reaches print? |
|---|---|---|---|
| 1 | 0 | 120 | no |
| 2 | 1 | -5 | yes |
| 3 | 2 | 80 | no |
| 4 | 3 | -2 | yes |
| 5 | 4 | 40 | no |
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.
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.
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.This loop is meant to replace the 20 with 99. What does it print?
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.
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]34for name, age in zip(names, ages):5 print(name + " is " + str(age) + " years old")
Each step takes the next name and the next age together
- 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
- 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]34for name, score in zip(names, scores):5 print(name + ":", score)67print()89letters = ["a", "b"]10numbers = [1, 2, 3, 4, 5]11for letter, num in zip(letters, numbers):12 print(letter, num)
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.
| Position | users | countries | plays | zip step |
|---|---|---|---|---|
| 0 | u7 | BR | 140 | 1 |
| 1 | u12 | SE | 85 | 2 |
| 2 | u31 | missing | 302 | never reached |
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).
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.
The names list has 4 items and the scores list has 3. What does this program print?
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.
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.
| Loop header | Each step binds | Reach for it when |
|---|---|---|
| `for code in plays:` | a key | the body needs the keys, or reads only some values |
| `for count in plays.values():` | a value | keys play no part, as in a total |
| `for code, count in plays.items():` | a key and its value | the body uses both, the common case |
1person = {"name": "Alice", "age": 25, "city": "NYC"}23for key, value in person.items():4 print(key + ": " + str(value))56print()78config = {"debug": True, "timeout": 30, "max_retries": 3}9print("Current settings:")10for setting, value in config.items():11 print(" " + setting + " = " + str(value))
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().
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.
Filtering into a new dict
1scores = {"Alice": 85, "Bob": 92, "Charlie": 78, "Diana": 95}23passing = {}4for name, score in scores.items():5 if score >= 80:6 passing[name] = score7print("Passing:", passing)89for name, score in scores.items():10 scores[name] = score + 511print("Curved:", scores)1213top_name = None14top_score = -115for name, score in scores.items():16 if score > top_score:17 top_score = score18 top_name = name19print("Top:", top_name, top_score)
The curve changes values under existing keys, so the keys stay the same throughout
- 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
- Unpack 2 names from a dict without .items()
- Look up d[k] in a keys loop when .items() already hands you the value
This dict has a 2-letter key and a 3-letter key. What does the loop print?
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.
1for i in range(1, 4):2 print("Outer:", i)3 for j in range(1, 3):4 print(" Inner:", j)5 print()
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.
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.
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).
| i | teams[i] | j takes | Pairs printed |
|---|---|---|---|
| 0 | ants | 1, 2, 3 | 3 |
| 1 | bees | 2, 3 | 2 |
| 2 | cats | 3 | 1 |
| 3 | dogs | nothing | 0 |
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 = 57found_row = None8found_col = None9for r, row in enumerate(grid):10 for c, value in enumerate(row):11 if value == target:12 found_row = r13 found_col = c14 break15 if found_row is not None:16 break17print("Found", target, "at row", found_row, "col", found_col)
The break ends the scan of row 1, and the check after it ends the row loop
Every row of this grid holds a 5. What does the program print?
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.
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 break6else:7 print("No even numbers found")89print()1011numbers2 = [1, 3, 4, 7, 9]12for num in numbers2:13 if num % 2 == 0:14 print("Found even:", num)15 break16else:17 print("No even numbers found")
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 ended | Does else run? |
|---|---|
| It ran out of items | Yes |
| A `break` fired | No |
| The list was empty, so the body never ran | Yes |
| Some steps took `continue`, then items ran out | Yes |
| A `while` condition turned false | Yes |
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.
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.
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.
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.
No batch arrived today, so batches is empty. What does the program print?
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.
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.
> 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.break ends a month's scan at the first track over the threshold.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.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..values() gives the values, .items() gives both, and a missing .items() can split keys into letters.break exits only the inner one.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
- enumerate() for Index (concepts: Indexed Iteration)
Choosing where the count starts Writing back through the position
- 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.
- 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.
- 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
- 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