Python Part I · Module 10 · Loops
while Loops, Polling and Retrying
A for loop needs a collection, and a collection answers the question how many passes before the loop starts. Plenty of real work cannot answer it. A simulation is running and will finish at some point; a licence server is busy and may free up; a flaky network step failed and might succeed on the second attempt. In each case the number of passes is not a property of your data — it is a property of something outside your program, and you find out only by checking again. That is what a while loop is for, and it comes with one responsibility a for loop does not have: because nothing limits it in advance, you have to limit it yourself.
Foundation16 min readPythonPart IFundamentalsLoopswhile
Part I · Module 10 · Chapter 10.5 · while Loops, Polling and Retrying
1. When There Is No Collection
Every loop so far began with something to walk: a list of tests, a range of seeds, the lines of a log. Here is a problem with no such thing.
A job has been submitted. You want to carry on when it finishes. How many times will you have to check? You cannot know — it depends on the job, the machine and the queue. There is no collection of checks to loop over, because the checks are generated by the situation rather than by your data.
What you can say is the condition under which you keep going: while the job is still running, check again.
2. The Statement
remaining = 3
while remaining > 0:
print("remaining:", remaining)
remaining -= 1
print("done, remaining =", remaining)remaining: 3
remaining: 2
remaining: 1
done, remaining = 0The shape is while <condition>: followed by an indented body — the same block rule as Chapter 9.2, and visually almost the same as an if. The difference is what happens at the bottom of the body: control goes back to the condition instead of continuing past it.
3. The Condition Is Re-Tested Before Every Pass
This is the whole of the statement, and it is worth stating against the thing it most resembles.
Chapter 9.7 established that an if evaluates its condition once, when execution reaches it, and never again. Here are the two statements over the same data:
remaining = 3
if remaining > 0:
print("if: remaining is", remaining)
remaining -= 1
print("if finished with remaining =", remaining)if: remaining is 3
if finished with remaining = 2remaining = 3
while remaining > 0:
print("while: remaining is", remaining)
remaining -= 1
print("while finished with remaining =", remaining)while: remaining is 3
while: remaining is 2
while: remaining is 1
while finished with remaining = 0Identical conditions, identical bodies. The if ran its body once and moved on. The while ran it until the condition stopped being true, re-evaluating remaining > 0 before each pass — four evaluations in total, the last of which was false.
4. Something in the Body Must Change the Answer
A for loop ends because the collection runs out. A while loop ends only if the condition becomes false, and the only thing that can make that happen is the body.
In Section 2 that was remaining -= 1. Remove it and the condition is true forever, because nothing it depends on ever changes.
The question to ask of every while loop you write or read is: what in this body can make the condition false? If you cannot point at a specific line, the loop does not terminate.
For while remaining > 0: the answer is remaining -= 1, and you can check it: remaining strictly decreases every pass and the condition is a lower bound, so it must eventually be reached.
5. The Condition Runs One More Time Than the Body
A small fact that makes off-by-one reasoning about while loops reliable: for n passes of the body, the condition is evaluated n + 1 times. The extra one is the evaluation that finally comes out false and ends the loop.
checks = []
remaining = 3
while remaining > 0:
checks.append(remaining)
remaining -= 1
print("body ran", len(checks), "times for", checks)
print("the condition was evaluated", len(checks) + 1, "times")
print("the last evaluation was", 0, "> 0 ->", 0 > 0)body ran 3 times for [3, 2, 1]
the condition was evaluated 4 times
the last evaluation was 0 > 0 -> FalseThree passes, four evaluations. This is why the value a while loop leaves behind is the first value that failed the test, not the last one that passed it — remaining ends at 0, which the body never saw. Code after a while loop that uses the loop's variable is reading that failing value, and expecting the last successful one is a reliable source of off-by-one bugs.
6. Polling: Waiting for Something to Finish
Here is the first real use. A job is checked repeatedly until it reports something other than RUNNING:
polls = ["RUNNING", "RUNNING", "RUNNING", "DONE"]
state = "RUNNING"
checks = 0
while state == "RUNNING":
state = polls[checks]
checks += 1
print("check", checks, "->", state)
print("job finished after", checks, "checks")check 1 -> RUNNING
check 2 -> RUNNING
check 3 -> RUNNING
check 4 -> DONE
job finished after 4 checksFour passes. Nothing in the program decided that number — the data did, and with a different polls list it would be a different number. That is exactly the situation a for loop cannot express.
7. Retrying: The Bounded Attempt
The second use is a step that fails for reasons that have nothing to do with your code — a licence checkout that collided, a filesystem that was briefly unavailable. Trying again is reasonable; trying forever is not.
attempt_results = ["timeout", "timeout", "ok"]
attempts = 0
ok = False
while attempts < 3 and not ok:
result = attempt_results[attempts]
attempts += 1
print("attempt", attempts, "->", result)
if result == "ok":
ok = True
print("ok =", ok, "after", attempts, "attempts")attempt 1 -> timeout
attempt 2 -> timeout
attempt 3 -> ok
ok = True after 3 attemptsThe condition has two parts and they do different jobs. not ok is the success exit — stop as soon as it works. attempts < 3 is the bound — stop regardless after three tries. A loop with only the first can run forever; a loop with only the second always uses all three attempts even after succeeding.
The failing case matters as much as the succeeding one, and the loop handles it without a new branch:
attempt_results = ["timeout", "timeout", "timeout"]
attempts = 0
ok = False
while attempts < 3 and not ok:
result = attempt_results[attempts]
attempts += 1
print("attempt", attempts, "->", result)
if result == "ok":
ok = True
print("ok =", ok, "after", attempts, "attempts")attempt 1 -> timeout
attempt 2 -> timeout
attempt 3 -> timeout
ok = False after 3 attemptsThe loop ended because the bound was reached, and ok is False. After a bounded retry, the flag tells you which exit you took — and the code after the loop must check it, because "the loop finished" does not mean "it worked".
8. A Condition With Three Parts
Real waiting usually has more than one reason to stop. A job can finish, your patience can run out, or the queue can tell you something that means waiting is pointless:
states = ["RUNNING", "RUNNING", "SUSPENDED"]
state = "RUNNING"
checks = 0
aborted = False
while state == "RUNNING" and checks < 5 and not aborted:
state = states[checks]
checks += 1
if state == "SUSPENDED":
aborted = True
print("state:", state, "| checks:", checks, "| aborted:", aborted)
if aborted:
print("the queue suspended the job — not waiting further")
elif state == "RUNNING":
print("gave up waiting after the bound")
else:
print("job reached", state)state: SUSPENDED | checks: 3 | aborted: True
the queue suspended the job — not waiting furtherThree exits, and the code after the loop distinguishes them with the chain from Chapter 9.1. That reporting block is not optional decoration — a loop with three ways out that prints one message is a loop whose log entry cannot be acted on.
Note the condition is read left to right with short-circuit evaluation (Chapter 9.3), so states[checks] is only reached when the bound still holds. Ordering the parts so the cheap safety checks come first is the same discipline that made the guard work there.
9. Always Bound It
The rule for anything that runs unattended: a while loop whose condition depends on the outside world gets a counter and an upper limit, every time.
The reason is operational rather than aesthetic. A for loop with a bug produces a wrong answer, which you find in the morning and fix. An unbounded while loop that never becomes false does not produce anything — it holds its machine, its simulator licence and its slot in the queue until a person notices and kills it, and a nightly regression that hangs at 02:00 has lost the whole night by 09:00.
A bound costs two lines:
state = "RUNNING"
checks = 0
polls = ["RUNNING", "RUNNING", "RUNNING", "DONE"]
while state == "RUNNING" and checks < 100:
state = polls[checks]
checks += 1
print("state:", state, "after", checks, "checks")
if state == "RUNNING":
print("gave up waiting — the job is still running")
else:
print("job reached state", state)state: DONE after 4 checks
job reached state DONEThe bound did not trigger here, and that is the normal case. It exists for the night when the queue system wedges and the job never changes state — on that night, this script gives up after a hundred checks and says so, instead of waiting until someone arrives.
10. A Condition That Starts False Runs Zero Passes
Like a for loop over an empty collection, a while whose condition is false at the start simply does nothing:
queued = 0
while queued > 0:
print("draining")
queued -= 1
print("nothing to drain")nothing to drainThe condition is tested before the first pass, not after it, so there is no guaranteed minimum of one. This is worth knowing because the mental picture of "do this, then check" is wrong — it is "check, then maybe do this".
11. Predict, Run, Observe
# A
n = 0
while n < 3:
print("A:", n)
n += 1
# B
n = 5
while n < 3:
print("B:", n)
n += 1
print("B finished, n =", n)
# C
total = 0
n = 1
while n <= 4:
total += n
n += 1
print("C total:", total)
# D
word = "axi"
i = 0
while i < len(word):
print("D:", word[i])
i += 1The verified output:
A: 0
A: 1
A: 2
B finished, n = 5
C total: 10
D: a
D: x
D: iA — three passes; the condition is false when n reaches 3.
B — zero passes. The condition was already false, so the body never ran and n is untouched.
C — 1 + 2 + 3 + 4. The <= makes 4 inclusive, which is a deliberate contrast with range(1, 5) doing the same job in Chapter 10.4.
D — this works, and it is the wrong tool. Walking a collection by index with a while is what for ch in word: does in one line with no counter to get wrong. Chapter 10.6 is about choosing between them.
12. Exercises
Exercise 1 — name the advance. For each loop, point at the line that can make the condition false, or say that none can: (a) while n < 10: with n += 2 (b) while not done: with print(done) (c) while attempts < 3: with attempts = 0 inside (d) while len(queue) > 0: with queue.pop().
Exercise 2 — if versus while. Write the same three-line body under an if remaining > 0: and under a while remaining > 0: with remaining = 2. Run both, and explain the difference in output in two sentences using the word "re-evaluated".
Exercise 3 — bound an unbounded loop. The polling loop in Section 5 has no bound. Add one of 2 checks, run it against the same polls list, and print whether it gave up or finished. Say what the output tells the person reading the log.
Exercise 4 — which exit. Using Section 6's retry, add a line after the loop that prints either succeeded on attempt N or gave up after N attempts. Verify it with both the succeeding and the failing attempt_results.
Exercise 5 — zero passes. Write a while loop that you expect to run three times, then change one value so it runs zero times without changing the body. Explain which part of the loop decided that.
Exercise 6 — count the evaluations. For n = 3 and while n > 0: with n -= 1, state exactly how many times the condition is evaluated and how many times the body runs. Explain why the two numbers differ by one.
Exercise 7 — drain a queue. Given queue = ["j1", "j2", "j3"], use a while loop and .pop() to process and remove jobs until the queue is empty, printing each. Then say in one sentence why a for loop over queue would have been a bad idea here.
Exercise 8 — two-part condition. Write a retry that stops on success or after 5 attempts, using one while condition. Then write the version with only the bound and the version with only the success test, and describe in one sentence each what goes wrong.
13. Summary
- A
whileloop repeats as long as a condition is true, and is for situations where the number of passes is not known in advance. - The condition is re-evaluated before every pass — the one real difference from an
if, which evaluates once. - Everything from Module 9 applies to the condition: comparisons,
and/or/not, truthiness, short-circuit. - Something in the body must be able to make the condition false. If you cannot point at the line, the loop does not terminate.
- Polling waits for an outside state to change; the number of checks is decided by the situation, not by your data.
- A bounded retry uses a two-part condition: a success exit and an attempt limit.
- After a bounded loop, check which exit you took — finishing is not succeeding.
- Always bound a
whilethat depends on the outside world. A hung nightly run costs the whole night; a bound costs two lines. - A condition that is false at the start runs the body zero times — it is "check, then maybe do", not "do, then check".
- The condition is evaluated one more time than the body runs, so the value left behind is the first one that failed the test, not the last that passed it.
- A condition may have several parts — a success exit, a bound, and an abort flag — and the code after the loop must say which of them ended it.
The next chapter puts the two loop statements side by side and settles which problem each one is for.
Where this fits
Part of the Python curriculum.
