Lesson 8 of 25

Loops (for & while)

A for Loop Walks Over Items, Not Indexes

Python's for loop is not the C-style loop with a counter, a condition and an increment. It says: take this collection, hand me one item at a time, and run this block for each. The loop variable holds the item, not its position. That is why for fruit in fruits: reads like a sentence, and why an entire family of off-by-one errors simply cannot happen.

Anything Python can hand out one piece at a time works here — a list, a tuple, a string (giving you characters), a set, a dictionary, a file object (giving you lines), or a range. The word for such a thing is iterable, and the fact that they all behave identically in a for loop is one of the reasons Python code stays short.

Two helpers cover the cases where the plain form is not enough. enumerate() gives you the position alongside the item, with start=1 when you want human-friendly numbering. zip() walks two or more collections in step, which is how you pair a list of names with a list of marks. Between them they remove almost every reason to write for i in range(len(items)) — a pattern that is legal, noisy, and the usual place index bugs are born.

Looping over a dictionary gives you its keys, not its values. That surprises people once and then never again. Use .values() when you want the values and .items() when you want both, which unpacks neatly into two loop variables.

Example
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

for char in "Python":
    print(char, end=" ")     # P y t h o n
print()

# When you genuinely need the position, use enumerate
for position, fruit in enumerate(fruits, start=1):
    print(f"{position}. {fruit}")

# What not to write: noisy, and one typo away from an IndexError
for i in range(len(fruits)):
    print(fruits[i])

# Two collections in step
names = ["Asha", "Ravi", "Meera"]
marks = [87, 92, 78]
for name, mark in zip(names, marks):
    print(f"{name}: {mark}")

# Dictionaries hand you the KEYS
scores = {"Asha": 87, "Ravi": 92}
for name in scores:
    print(name)                    # Asha, Ravi
for name, mark in scores.items():
    print(name, mark)              # Asha 87, Ravi 92
  • for item in collection: — the loop variable is the item itself
  • enumerate(items) — pairs each item with its position; start=1 for human numbering
  • zip(a, b) — walks two collections together, stopping at the shorter one
  • Looping a dict gives keys; use .values() or .items() for the rest
  • Strings give characters, files give lines — the same loop works on all of them
Notes
  • zip() stops silently at the end of the shortest input, so a mismatch in length loses data with no warning. From Python 3.10 you can pass strict=True to make an unequal length raise a ValueError instead.

range(): Lazy, and Its Stop Value Is Excluded

range() produces a sequence of whole numbers, and it is what you use when you want to repeat something a fixed number of times rather than walk over existing data. It takes up to three arguments in the same order as a slice: start, stop, step.

The rule that causes almost every mistake with it is that the stop value is excluded. range(5) gives 0, 1, 2, 3, 4 — five numbers, none of them 5. range(1, 5) gives 1, 2, 3, 4, so "one to five" in ordinary speech is range(1, 6) in Python. The compensation is that range(n) produces exactly n values and its numbers line up perfectly with list positions, which is why range(len(items)) is always in bounds.

A negative step counts down, and the same exclusion rule applies at the bottom: range(10, 0, -1) gives 10 down to 1, not 0. If the start and stop cannot be reached in the direction of the step, you get an empty range rather than an error — range(5, 1) is simply empty, so a loop over it runs zero times and your program carries on quietly. That silence is worth knowing about when a loop mysteriously produces no output.

range does not build a list. It is a lazy object that works out each number as it is asked for, so range(1_000_000) uses the same tiny amount of memory as range(5). Printing one shows you range(0, 5) rather than the numbers; wrap it in list() when you actually want them.

Example
for i in range(5):
    print(i, end=" ")        # 0 1 2 3 4   — 5 is NOT included
print()

for i in range(2, 8):
    print(i, end=" ")        # 2 3 4 5 6 7
print()

for i in range(0, 10, 2):
    print(i, end=" ")        # 0 2 4 6 8
print()

for i in range(10, 0, -1):
    print(i, end=" ")        # 10 9 8 7 6 5 4 3 2 1  — 0 excluded
print()

# "1 to 5" in English is range(1, 6) in Python
print(list(range(1, 5)))     # [1, 2, 3, 4]
print(list(range(1, 6)))     # [1, 2, 3, 4, 5]

# An impossible range is empty, not an error
print(list(range(5, 1)))     # []

# range is lazy, not a list
print(range(5))              # range(0, 5)
print(list(range(5)))        # [0, 1, 2, 3, 4]
print(len(range(1000000)))   # 1000000 — instant, nothing was built
  • range(stop) — 0 up to but not including stop
  • range(start, stop) — starts where you say, still excludes stop
  • range(start, stop, step) — a negative step counts down
  • range(n) yields exactly n values
  • It is lazy: use list(range(...)) when you need the numbers themselves
Notes
  • range only handles whole numbers. There is no range(0, 1, 0.1) — it raises a TypeError. For fractional steps, loop over integers and divide, or use numpy.arange once you reach numerical work.

while: Repeat Until Something Changes

A while loop repeats as long as its condition stays true, and it is the right tool when you do not know in advance how many rounds you need. Asking a user for valid input, retrying a network call, or working out how many years an investment takes to double are all while problems: the stopping point depends on what happens inside the loop.

That freedom carries the one danger that for loops do not have. Something inside the body must eventually make the condition false. Forget the line that increases the counter and the loop runs forever, printing until you stop it with Ctrl+C. When a program appears to hang, an unchanging while condition is the first thing to check — and the specific question to ask is "which line is supposed to change the value being tested?"

A deliberately infinite loop is a legitimate design, not a mistake, as long as it has a clear way out. while True: with a break inside is the standard shape for menu-driven programs and input validation, and it often reads better than trying to squeeze the exit condition into the header. The rule is simply that the break must be reachable — an exit the user can always get to.

As a rough guide: if you are counting or walking over data, use for. If you are waiting for a condition, use while. Rewriting a for loop as a while with a manual counter is a step backwards, because you have taken on the bookkeeping the for loop was doing for you.

Example
count = 0
while count < 5:
    print(count, end=" ")
    count += 1          # forget this line and the loop never ends
print()                 # 0 1 2 3 4

# The stopping point depends on what happens inside
balance = 10000
years = 0
while balance < 20000:
    balance *= 1.08
    years += 1
print(f"{years} years at 8% to double")

# while True with a clear exit — the menu-loop shape
while True:
    command = input("Command (or 'quit'): ").strip().lower()
    if command == "quit":
        break
    print(f"running {command}")

# An accidental infinite loop
n = 5
while n > 0:
    print(n)
    # n -= 1   <- missing: n never changes, so n > 0 stays true forever
Notes
  • Ctrl+C stops a runaway loop in the terminal and raises KeyboardInterrupt. If your loop is also printing, the output may take a moment to stop scrolling — that is the output buffer draining, not the loop continuing.

break, continue and the Loop else

break leaves the loop immediately — no further items, no further passes. continue abandons the rest of the current pass and goes straight to the next item. Both work in for and while loops. continue is at its best as a filter at the top of a loop body: skip the rows you do not care about early, and the interesting work below stays unindented.

The detail that catches people is that break only leaves the innermost loop it is inside. In a nested pair, a break in the inner loop returns you to the outer loop, which then carries on with its next item. There is no labelled break in Python. To leave both, either set a flag and test it in the outer loop, or — usually cleaner — move the nested loops into a function and return.

Python has one loop feature most languages lack: else on a loop. The else block runs when the loop finishes normally and is skipped when a break fired. Read it as "if we never broke out". Its one genuinely good use is search: loop looking for something, break when you find it, and put the not-found message in the else. That removes the found-flag variable that would otherwise be needed. It is admittedly a confusing keyword choice — nobreak would have been clearer — so add a comment when you use it.

Example
# break leaves the loop entirely
for n in range(10):
    if n == 5:
        break
    print(n, end=" ")       # 0 1 2 3 4
print()

# continue skips the rest of this pass only
for n in range(10):
    if n % 2 == 0:
        continue
    print(n, end=" ")       # 1 3 5 7 9
print()

# continue as an early filter keeps the real work unindented
rows = ["101,87", "", "# comment", "102,92"]
for row in rows:
    if not row or row.startswith("#"):
        continue
    roll, mark = row.split(",")
    print(roll, int(mark))

# break leaves only the INNER loop
for i in range(3):
    for j in range(3):
        if j == 1:
            break            # ends the j loop; the i loop continues
        print(i, j)          # 0 0 / 1 0 / 2 0

# for/else: else runs only if no break happened
roll_numbers = [101, 102, 103]
target = 105
for r in roll_numbers:
    if r == target:
        print("found")
        break
else:
    print(f"{target} is not in the list")   # this runs
  • break — leave the loop now; only the innermost one
  • continue — skip to the next item; the loop carries on
  • else on a loop — runs only if no break fired; read it as "if not found"
  • Python has no labelled break; use a function and return to escape nested loops
  • continue at the top of a body is the tidiest way to skip unwanted rows

The Trap: Changing a Collection While You Loop Over It

This one produces wrong answers rather than errors, which is why it deserves its own section. When you loop over a list, Python keeps an internal position counter and moves it forward after every pass. If you remove an item during the loop, everything after it shifts down one place — and the counter has already moved on, so the item that shifted into the vacated position is never examined.

The result is that filtering a list by removing from it while iterating skips roughly every second match. With the marks below, one of the two zeros survives. No exception is raised, the code looks reasonable, and the bug hides until the day two unwanted items happen to sit next to each other.

Dictionaries and sets are stricter: changing their size during iteration raises RuntimeError: dictionary changed size during iteration. That is a kindness, since it fails loudly rather than quietly.

There are two correct fixes and one bad habit to avoid. The best fix is to build a new collection rather than editing the old one — marks = [m for m in marks if m != 0] says exactly what you mean and is what you would write anyway once you know comprehensions. The alternative is to iterate over a copy, with for m in marks[:] for a list or for key in list(scores) for a dictionary, so the thing you are walking and the thing you are editing are different objects. The bad habit is switching to an index-based while loop and adjusting the index by hand; it works, and it is a reliable source of off-by-one bugs.

Example
# WRONG — removing while iterating
marks = [0, 0, 87, 92]
for m in marks:
    if m == 0:
        marks.remove(m)
print(marks)          # [0, 87, 92]  — one zero survived

# Fix 1 (best): build a new list
marks = [0, 0, 87, 92]
marks = [m for m in marks if m != 0]
print(marks)          # [87, 92]

# Fix 2: iterate over a copy
marks = [0, 0, 87, 92]
for m in marks[:]:    # marks[:] is a separate copy
    if m == 0:
        marks.remove(m)
print(marks)          # [87, 92]

# Dictionaries refuse outright
scores = {"Asha": 87, "Ravi": 0, "Meera": 78}
# for name, mark in scores.items():
#     if mark == 0:
#         del scores[name]       # RuntimeError: dictionary changed size

for name in list(scores):        # list(scores) is a snapshot of the keys
    if scores[name] == 0:
        del scores[name]
print(scores)         # {'Asha': 87, 'Meera': 78}
Notes
  • Adding to a list while looping over it is worse than removing: the loop keeps reaching the newly added items and never ends. If you need to grow a collection based on what you are reading, write into a second list.

Loops You Should Not Write

A surprising share of hand-written loops are re-implementations of a built-in function. Adding up a list, finding the largest value, counting how many items pass a test, checking whether any of them do — Python already has all of these, and the built-in version is shorter, harder to get wrong, and faster because the work happens in compiled code.

sum(), max(), min() and len() cover the arithmetic. any() and all() answer "is at least one true" and "are they all true", and they short-circuit, so any() stops at the first true item rather than checking the rest. sorted() orders a collection without changing the original. The habit worth building is to pause before writing a loop and ask whether a built-in already says it.

Two small facts about the loop variable, because they cause confusing errors. It still exists after the loop ends, holding whatever it had on the final pass — handy occasionally, and a trap when you reuse the same name in two loops. And if the collection was empty, the variable is never created at all, so touching it afterwards raises NameError rather than giving you an obvious empty value.

Lesson 21 takes this further with comprehensions, which compress the common "loop, filter, collect" pattern into a single expression. Until then, write the loop plainly; the point here is only that total = 0 followed by four lines of addition is not the shortest true statement of what you meant.

Example
marks = [78, 84, 91, 65]

# The hand-written version
total = 0
for m in marks:
    total += m
print(total)                        # 318

# The same thing, said once
print(sum(marks))                   # 318
print(max(marks), min(marks))       # 91 65
print(sum(marks) / len(marks))      # 79.5

# Questions about a whole collection
print(any(m > 90 for m in marks))   # True  — did anyone score above 90?
print(all(m >= 40 for m in marks))  # True  — did everyone pass?
print(sorted(marks, reverse=True))  # [91, 84, 78, 65]
print(marks)                        # unchanged: sorted() returns a new list

# The loop variable outlives the loop
for i in range(3):
    pass
print(i)                            # 2

# ...but only if the loop ran at least once
for j in []:
    pass
# print(j)   # NameError: name 'j' is not defined
Notes
  • sorted(x) returns a new list and leaves x alone; x.sort() reorders x in place and returns None. Writing x = x.sort() is a common slip that replaces your list with None.
Ask AI