Loops and Iteration
Loops and Iteration
A loop walks a collection or repeats until a condition fails. The boring default is for item in items. Use enumerate when you need a position, zip when you need two lists in lockstep, range when you need integers, and while when the stop condition is not “the list ended.”
Mental model
for x in iterable: binds x to each element. A list, tuple, str, dict (keys), and range are all iterable. range(n) produces 0 .. n-1. range(start, stop) stops before stop.
enumerate(items, start=0) yields (index, item). zip(a, b) yields pairs and stops at the shorter input.
break leaves the loop. continue skips to the next iteration. A loop may have an else: clause that runs if the loop did not break. That is real Python. It is also easy to misread as “run after the loop.” The boring default is: do not use for/else. Use a flag or return.
while is for “keep going until the pass is empty,” not for walking a list you already have.
Worked examples
Case 1: for, range, enumerate
Save as walk_tickets.py.
# walk_tickets.py
def main():
tickets = ["open", "fired", "paid"]
for status in tickets:
print("status", status)
for i in range(len(tickets)):
print("index", i, tickets[i])
for i, status in enumerate(tickets, start=1):
print(f"#{i} {status}")
if __name__ == "__main__":
main()Run:
uv run python walk_tickets.pyOutput:
status open
status fired
status paid
index 0 open
index 1 fired
index 2 paid
#1 open
#2 fired
#3 paid
Prefer for status in tickets when you do not need the index. Prefer enumerate over range(len(...)) when you do. range(len) is legal and usually worse.
Case 2: zip two columns
# zip_tables.py
def main():
tables = [3, 11, 12]
covers = [2, 4, 2]
for table, n in zip(tables, covers):
print(f"table {table}: {n} covers")
if __name__ == "__main__":
main()Run:
uv run python zip_tables.pyOutput:
table 3: 2 covers
table 11: 4 covers
table 12: 2 covers
If one list is shorter, zip stops early and does not warn. Check lengths when the lists must match: if len(tables) != len(covers): raise ValueError(...).
Case 3: while, break, continue
# pass_window.py
def main():
pass_window = ["soup", "SKIP", "tea", "STOP", "cake"]
while pass_window:
item = pass_window.pop(0)
if item == "SKIP":
continue
if item == "STOP":
break
print("plate", item)
print("left", pass_window)
if __name__ == "__main__":
main()Run:
uv run python pass_window.pyOutput:
plate soup
plate tea
left ['cake']
continue drops SKIP. break leaves cake on the window. pop(0) is simple here; a real queue would use collections.deque. The while pass_window: test is “still something there.”
Case 4: Loop else — then do not make it the default
Python runs the else on a loop when no break happened. This program finds a free table.
# find_table.py
def find_free(tables, want):
for table in tables:
if table["n"] == want and table["free"]:
print(f"seated at {want}")
break
else:
print(f"no free table {want}")
def main():
tables = [
{"n": 11, "free": False},
{"n": 12, "free": True},
]
find_free(tables, 12)
find_free(tables, 3)
if __name__ == "__main__":
main()Run:
uv run python find_table.pyOutput:
seated at 12
no free table 3
It works. Readers still trip over else hanging off for. The boring version is a function that returns the table or None, then the caller prints. Use loop else only when the team already reads it without blinking. This book’s default is to avoid it.
# find_table_return.py
def find_free(tables, want):
for table in tables:
if table["n"] == want and table["free"]:
return table
return None
def main():
tables = [
{"n": 11, "free": False},
{"n": 12, "free": True},
]
for want in (12, 3):
found = find_free(tables, want)
if found is None:
print(f"no free table {want}")
else:
print(f"seated at {want}")
if __name__ == "__main__":
main()Run:
uv run python find_table_return.pyOutput:
seated at 12
no free table 3
Same results. The else belongs to if, where everyone looks for it.
The trap
Mutating a list while you for over it. Inserts and deletes shift the remaining items; you skip rows or Python raises.
# mutate_while.py
def main():
tickets = ["open", "void", "open", "paid"]
for status in tickets:
if status == "void":
tickets.remove(status)
print(tickets)
if __name__ == "__main__":
main()Run:
uv run python mutate_while.pyOutput:
['open', 'open', 'paid']
This tiny list happens to survive. A second "void" next to the first often does not get visited. Build a new list instead:
# keep_tickets.py
def main():
tickets = ["open", "void", "open", "paid"]
kept = [s for s in tickets if s != "void"]
print(kept)
if __name__ == "__main__":
main()Run:
uv run python keep_tickets.pyOutput:
['open', 'open', 'paid']
The boring rule
for item in itemsis the default loop.enumeratefor positions.zipfor parallel lists.rangefor integers.whilewhen the end is a condition, not a length.break/continueare fine when they make a guard, not a maze.- Do not use
for/elseas the house style. Return or set a flag. - Do not mutate the list you are iterating. Make a new one.
Try this
- In
zip_tables.py, add a fourth table without a cover count. Printlenof both lists and raiseValueErrorif they differ. - Rewrite
pass_window.pywith aforloop over a copy of the list (for item in list(pass_window):) andbreak/continuethe same way. Compare what is left. - Change
find_table_return.pyso it prints the whole found dict, not only the number.