Python Part I · Module 10 · Loops
Loops That Never End, and Other Classic Mistakes
This chapter closes the module with the failures loops make available, and it is organised around a distinction worth more than the individual items: some loop bugs hang, and some lie. A loop that never ends is obvious the moment you look — the script does not return, the licence stays checked out, and somebody kills it. A loop that lies finishes in milliseconds and hands you a number that is plausible, well-formatted and wrong, and it will be in a report before anyone examines it. Four of the seven mistakes below are of the second kind. They are the reason this is a chapter rather than a warning box.
Foundation17 min readPythonPart IFundamentalsLoopsDebugging
Part I · Module 10 · Chapter 10.12 · Loops That Never End, and Other Classic Mistakes
1. Two Kinds of Loop Bug
Loops that hang. The condition never becomes false. The program produces nothing and holds whatever it was holding until a person intervenes. Costly, and unmistakable.
Loops that lie. The loop finishes normally and the result is wrong. No error, no warning, no delay — just a number or a list that nobody has a reason to doubt.
The first kind is in Sections 2 and 3. The rest are the second kind.
2. The Forgotten Increment
The canonical hang. The condition depends on attempts, and nothing changes attempts:
attempts = 0
guard = 0
while attempts < 3:
guard += 1
if guard >= 6:
print("after", guard, "passes, attempts is still", attempts)
break
print("the guard stopped it; without the guard this never ends")after 6 passes, attempts is still 0
the guard stopped it; without the guard this never endsSix passes and attempts has never moved. It never will, because no line in the body touches it.
This usually happens by deletion rather than by omission — the increment was there, the body was reorganised, and it went with the lines around it. It also happens when the increment is inside a branch that does not always run, which is the same bug with a disguise.
The check from Chapter 10.5: point at the line that can make the condition false. If your finger does not land anywhere, the loop does not terminate.
3. Advancing in the Wrong Direction
A subtler hang: the body does change the value in the condition, and changes it the wrong way:
countdown = 3
guard = 0
while countdown > 0:
guard += 1
countdown += 1
if guard >= 5:
print("countdown is now", countdown, "- moving away from zero")
breakcountdown is now 8 - moving away from zero+= where -= was meant. The loop is not stuck — it is making steady progress away from its exit, which is worse, because a debugger stepping through it shows a value that is changing and a condition that is being re-evaluated.
Pointing at the advancing line is not enough. Check that it moves the condition towards false, not merely that it moves.
4. The Accumulator Reset Inside the Loop
Chapter 10.10 covered this at length; it is here because it is the most common lying loop:
lines = ["UVM_ERROR a", "INFO b", "UVM_ERROR c"]
for line in lines:
errors = 0
if "UVM_ERROR" in line:
errors += 1
print("errors:", errors)errors: 1Two errors; the program says one. errors = 0 belongs at the same column as the for.
The giveaway when reading code: an assignment of an empty starting value (0, [], {}, "") inside a loop body is almost always wrong. It is right only when the value is genuinely per-pass — a per-test counter inside the outer loop of a nest, for example — and then it is worth a comment saying so.
5. Changing a List While Looping Over It
This one is silent, and it is the mistake most likely to survive review:
tests = ["smoke", "skip_me", "skip_me", "reset"]
for test in tests:
if test == "skip_me":
tests.remove(test)
print("after:", tests)after: ['smoke', 'skip_me', 'reset']One "skip_me" was removed. One survived. No error.
The reason is positional. The loop tracks where it is by index. At position 1 it finds "skip_me" and removes it, so everything shifts left — the second "skip_me" moves into position 1, which the loop has already passed. Next pass it looks at position 2, which is now "reset".
Removing an item from a list while iterating it skips the item after it, every time. With consecutive matches, that means missing exactly the ones that were adjacent.
Two fixes, both correct:
tests = ["smoke", "skip_me", "skip_me", "reset"]
for test in list(tests):
if test == "skip_me":
tests.remove(test)
print("copy:", tests)copy: ['smoke', 'reset']list(tests) makes a separate list to iterate, so removals from the original cannot disturb the walk.
tests = ["smoke", "skip_me", "skip_me", "reset"]
kept = []
for test in tests:
if test != "skip_me":
kept.append(test)
print("rebuild:", kept)rebuild: ['smoke', 'reset']Building a new list is the better habit. It is the accumulator pattern from Chapter 10.10, it never mutates what it is reading, and it states the rule positively — keep the ones that are not skipped — rather than as a sequence of removals.
6. Changing a Dictionary While Looping Over It
The same mistake, on a dictionary, behaves completely differently:
status = {"smoke": "PASS", "reset": "FAIL", "burst": "FAIL"}
for key in status:
if status[key] == "FAIL":
del status[key]
print(status)RuntimeError: dictionary changed size during iterationPython detects it and refuses.
That difference is worth holding on to. A dictionary knows its size at the start of iteration and checks it; a list is walked by position and has no such check, so the same error is loud on one collection and silent on the other. Meeting the dictionary version first leaves people believing Python guards against this in general. It does not.
The same two fixes apply:
status = {"smoke": "PASS", "reset": "FAIL", "burst": "FAIL"}
for key in list(status):
if status[key] == "FAIL":
del status[key]
print(status){'smoke': 'PASS'}list(status) takes a snapshot of the keys — Chapter 10.3 established that looping a dictionary gives keys — so the deletions cannot disturb the walk.
7. Using the Loop Variable After the Loop
Chapter 10.1 established that the loop variable survives, holding the last item. Relying on that is a bug in two ways.
First, after an empty collection it does not exist at all:
failed = []
for test in failed:
pass
print("last test was", test)NameError: name 'test' is not definedSecond — and this one is silent — after a search that found nothing, the loop variable holds the last item examined, which is indistinguishable from having found it:
tests = ["smoke", "reset", "burst"]
for test in tests:
if test == "cdc":
break
print("result:", test)result: burstNothing matched "cdc". The program reports burst, which is simply the last thing it looked at. Chapter 10.8's loop else, or an explicit flag, is how to say not found — the loop variable cannot.
8. Off-by-One With range()
Two shapes, both common:
tests = ["smoke", "reset", "burst"]
for i in range(1, len(tests)):
print(i, tests[i])1 reset
2 burstThat is correct if skipping the first item was intended. Written by someone counting from one out of habit, it silently drops tests[0] from every report.
And the version that raises:
tests = ["smoke", "reset", "burst"]
for i in range(len(tests) + 1):
print(tests[i])IndexError: list index out of rangeBoth disappear when the index does. for test in tests: cannot skip the first item or run past the last, which is Chapter 10.4's argument restated as a defect class.
9. Two More From Earlier Chapters
A zip() of mismatched lengths truncates silently and misaligns every pair after the mismatch — Chapter 10.9, Section 8. strict=True turns it into a ValueError.
A string built with += in a loop leaves a trailing separator every time — Chapter 10.10, Section 6. Collect into a list and .join() it.
10. The Checklist
Before a loop goes into anything that runs unattended:
| Check | Looking for |
|---|---|
| Can the condition become false? | point at the line; a while with no advance hangs |
| Does the advance move towards the exit? | += where -= was meant |
| Is the accumulator created outside the loop? | an empty value assigned inside the body |
| Does the body modify the collection being looped? | .remove(), .pop(), del on the loop's own collection |
| Is the loop variable used after the loop? | it is the last item, or undefined |
Is there a range(len(...))? | usually wants the collection, or enumerate() |
| Are zipped collections the same length? | add strict=True |
Is a while that waits on the outside world bounded? | add a counter and a limit |
For a nest, what is len(a) * len(b)? | print it before submitting |
11. Predict, Run, Observe
# A
items = [1, 2, 2, 3]
for n in items:
if n == 2:
items.remove(n)
print("A:", items)
# B
total = 0
for n in [1, 2, 3]:
total = 0
total += n
print("B:", total)
# C
names = ["clk", "rst"]
for name in names:
if name == "data":
break
print("C:", name)
# D
kept = []
for n in [1, 2, 2, 3]:
if n != 2:
kept.append(n)
print("D:", kept)The verified output:
A: [1, 2, 3]
B: 3
C: rst
D: [1, 3]A — one 2 survives. Section 5's skip, with numbers.
B — 3, not 6. The reset is inside, so only the last item contributed.
C — rst is the last name examined, not a match. Section 7.
D — the rebuild version of A, and the one to write.
12. Exercises
Exercise 1 — find the hang. For each, say whether it terminates and why: (a) n = 0 / while n < 5: / print(n) (b) n = 10 / while n > 0: / n -= 2 (c) n = 1 / while n < 100: / n *= 2 (d) n = 1 / while n > 0: / n += 1.
Exercise 2 — add a guard. Take the loop from Exercise 1(a) and add a guard that stops it after five passes and prints the stuck value. Explain in one sentence why the guard is not a fix.
Exercise 3 — the wrong direction. Write a countdown that uses += by mistake, bound it with a guard, and print the value at the point the guard fires. Say what a debugger would show you and why it is misleading.
Exercise 4 — the silent skip. Run Section 5's broken program with ["a", "x", "x", "x", "b"]. Predict how many "x" survive before running. Explain the number you get.
Exercise 5 — both fixes. Write the copy fix and the rebuild fix for Exercise 4's data. Say which leaves the original list intact and why that might matter.
Exercise 6 — loud or silent. Write the deletion-during-iteration mistake once on a list and once on a dictionary with the same logical content. Record what each does. Explain in two sentences why Python treats them differently.
Exercise 7 — not found. Write a search over ["clk", "rst"] for "data" that reports it correctly, using a loop else. Then write the version that uses the loop variable and show what it wrongly reports.
Exercise 8 — review a loop. Apply the Section 10 checklist to this loop and list every problem you find, then rewrite it:
lines = ["# h", "ERROR a", "ERROR b", "INFO c"]
for i in range(1, len(lines) + 1):
count = 0
if "ERROR" in lines[i]:
count += 1
lines.remove(lines[i])
print(count, line)13. Summary
- Loop bugs divide into those that hang and those that lie. The second kind is more expensive, because the answer looks fine.
- The forgotten increment: nothing in the body changes what the condition depends on. Point at the line, or the loop never ends.
- Advancing the wrong way is worse than not advancing — the value moves, so it looks like progress.
- An accumulator created inside the loop is reset every pass and reports the last item's contribution.
- An empty starting value assigned inside a loop body is almost always wrong.
- Removing from a list while looping over it skips the next item, silently, because the walk is positional and everything shifts left.
- The same mistake on a dictionary raises
RuntimeError. Loud on one collection, silent on the other — Python does not protect you in general. - Fix it by looping over a copy, or better, by building a new collection.
- The loop variable after a loop is the last item examined — undefined if the collection was empty, and misleading after a failed search. Use a loop
elseor a flag. range(len(...))invites both off-by-one shapes. Loop the collection, or useenumerate().- Run the checklist before anything goes into a nightly job.
That closes Module 10. You can now repeat work over any collection, count passes without a collection, stop early, skip items, report which exit a loop took, number and pair items without manual indices, accumulate results of five different kinds, expand a grid of combinations, and recognise the nine ways a loop goes wrong.
Every loop so far has had its body written inline, and the next module is about what happens when that body is worth a name — and why a script that repeats the same six lines three times is one bug fix away from being wrong in two places.
Where this fits
Part of the Python curriculum.
