Loops and Iteration

Updated

September 8, 2026

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

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.py

Output:

['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.py

Output:

['open', 'open', 'paid']

The boring rule

  • for item in items is the default loop.
  • enumerate for positions. zip for parallel lists. range for integers.
  • while when the end is a condition, not a length.
  • break / continue are fine when they make a guard, not a maze.
  • Do not use for/else as the house style. Return or set a flag.
  • Do not mutate the list you are iterating. Make a new one.

Try this

  1. In zip_tables.py, add a fourth table without a cover count. Print len of both lists and raise ValueError if they differ.
  2. Rewrite pass_window.py with a for loop over a copy of the list (for item in list(pass_window):) and break/continue the same way. Compare what is left.
  3. Change find_table_return.py so it prints the whole found dict, not only the number.