Python loops — practice with the till PIN, the ways to order the queue and the croissant oven (and the attempt that was counted too late)
The first time I wrote the till PIN program, I called it done straight away: three attempts and it locks. I showed it to a classmate, he started typing fake PINs to break it… and got four attempts. The bug was a single line: I added the attempt after checking the PIN, and on the pass where it failed for the last time the counter still said “one left”. Since then I have a rule for counters in while loops: decide at exactly which moment of the pass they are added, and trace it by hand before trusting it.
That rule is the thread running through this practice. You’ve already seen the Python loops theory with the café till; now it’s time to use it in three complete, medium-level programs with the kind of data a real till gets: a PIN typed wrong, a queue that can be ordered in many ways and an oven with its countdown. All three are written without input(): what would come from the keyboard sits in a fixed list, so the output you see is the real one and you can check it line by line.
Table of Contents
Before you start
Make sure you’re clear on:
- The difference between
for(you know how many passes) andwhile(you don’t). - That
range(start, stop)leaves out thestop, and that with a negative step it counts down. - The accumulator pattern: prepared before the loop, updated inside, used after.
- What
breakdoes and why awhileneeds a line that moves its condition towards the end.
If any of these feels shaky, go back over the theory before moving on.
Create a loops_practice folder with one file per program (pin.py, factorial.py, oven.py). That way you can run them separately in VS Code and compare your output with the article’s.
Program 1: the till PIN (validator with limited attempts)
The café till opens with a four-digit PIN. You have three attempts: get it right and the till opens; fail all three and it locks, and the manager has to be called. What makes it tricky is that the loop has two ways to end (getting it right or running out of attempts), and the messages have to say how many attempts are left without being off by one.
The idea
We don’t know how many passes it will make: it may be right on the first try, the second, or never. That calls for a while. The condition has to combine both exits: “keep going while there are attempts left and the till is still locked“. For the second part we use a flag variable (unlocked), a boolean that starts as False and becomes True when the PIN is right. That way, the while itself stops repeating without needing a break.
On each pass, the order is: read the PIN, count the attempt, check, report.
Before coding, think about what value attempts must have when “attempts left: X” is printed. If you count the attempt after printing, the message will be one pass behind (exactly my bug from the story).
The code
# Program 1 · The till PIN: 3 attempts and it locks
CORRECT_PIN = "2024"
MAX_ATTEMPTS = 3
typed = ["1234", "2024"] # what the person types, in order (no input())
attempts = 0
unlocked = False
while attempts < MAX_ATTEMPTS and not unlocked: # stops with EITHER of the two
pin = typed[attempts] # attempt number attempts + 1
attempts += 1 # counted BEFORE checking: failing also uses an attempt
if pin == CORRECT_PIN:
unlocked = True # this makes the while condition false
print(f"Attempt {attempts}: {pin} -> correct")
else:
remaining = MAX_ATTEMPTS - attempts
if remaining > 0:
print(f"Attempt {attempts}: {pin} -> wrong, attempts left: {remaining}")
else:
print(f"Attempt {attempts}: {pin} -> wrong, no attempts left")
if unlocked:
print(f"Till opened in {attempts} attempt(s)")
else:
print("Till locked: tell the manager")
Output
With typed = ["1234", "2024"] (wrong, then right):
Attempt 1: 1234 -> wrong, attempts left: 2 Attempt 2: 2024 -> correct Till opened in 2 attempt(s)
And with three wrong PINs, typed = ["1234", "0000", "2042"] (the same program with a different list; each version was run once):
Attempt 1: 1234 -> wrong, attempts left: 2 Attempt 2: 0000 -> wrong, attempts left: 1 Attempt 3: 2042 -> wrong, no attempts left Till locked: tell the manager
Checking the second case by hand: on pass 3, attempts becomes 3 and remaining = 3 − 3 = 0, which is why it says “no attempts left” and not “attempts left: 0”. Then the condition is 3 < 3 and not False → False and True → False, and the loop ends. The till doesn’t open because unlocked never became True.
The loop is the heart of the program. This is the part that decides everything:
while attempts < MAX_ATTEMPTS and not unlocked: # stops with EITHER of the two
pin = typed[attempts] # attempt number attempts + 1
attempts += 1 # counted BEFORE checking: failing also uses an attempt
if pin == CORRECT_PIN:
unlocked = True # this makes the while condition false
Its trace in the locked case (values at the end of each pass):
| Pass | pin | attempts | unlocked | Condition for the next pass |
|---|---|---|---|---|
| 1 | “1234” | 1 | False | 1 < 3 and not False → True |
| 2 | “0000” | 2 | False | 2 < 3 and not False → True |
| 3 | “2042” | 3 | False | 3 < 3 and not False → False |
The diagram puts both cases one above the other, with the key that closes the loop in each:
Questions you might ask
Why pin = typed[attempts] and not another counter? Because attempts is 0 on the first pass, 1 on the second… exactly the positions in the list. The PIN is read before the attempt is added, so pass 1 reads position 0.
Why is the attempt added before the if? Because failing also uses an attempt. If it were only added in the else, a correct PIN wouldn’t count as an attempt and “Till opened in 1 attempt(s)” would say “in 0”. And if it were added after printing the message, “attempts left” would be one pass behind.
Wouldn’t it be easier with break? You can: while attempts < MAX_ATTEMPTS: and a break when it’s right. It works the same. The flag has one advantage: after the loop, if unlocked: tells you why it ended. With break you’d have to work it out some other way (for example, checking whether attempts reached 3, which is also the case when the third attempt is right… and that gets messy).
What does not unlocked do? It flips the boolean: while unlocked is False, not unlocked is True and the and can still be true. We saw it in the logical operators.
Why is MAX_ATTEMPTS in capitals? It’s a Python convention for constants: values that shouldn’t change while the program runs. If tomorrow the till allows 5 attempts, you change it in one place.
Writing or instead of and in the condition: while attempts < MAX_ATTEMPTS or not unlocked:. When the PIN is right, attempts < 3 is still True, so the or stays True and the loop keeps asking for PINs with the till already open. And if you never get it right, Python raises IndexError when the list runs out.
Try it yourself
- Change
MAX_ATTEMPTSto 5 and use a list with four wrong PINs and one right one. What does the final message say? - Rewrite the loop with
while attempts < MAX_ATTEMPTS:and abreakwhen it’s right, without a flag, and make the final message still tell “opened” from “locked”.
Program 2: in how many ways can the queue be ordered? (factorial)
There are 5 people in the café queue. In how many different ways could they line up? The first one can be any of the 5; the second, any of the 4 left; the third, any of 3… That’s 5 × 4 × 3 × 2 × 1 ways, which mathematicians write as 5! (five factorial). The program computes it with for and with while, shows the process as text and prints a table of factorials to see how fast they grow. What makes it tricky: the case of 0, and building the text “1 x 2 x 3″ without leaving a ” x ” dangling at the end.
The idea
It’s an accumulator, but of products: it starts at 1 and on each pass it’s multiplied by the next number. Since we know exactly where we go from and to (from 1 to people), the natural choice is a for with range(1, people + 1). We also do it with while to see what you have to write yourself when the loop doesn’t keep the counter for you.
Before coding, write the passes for 3 people on paper: 1 → 1 × 1 = 1 → 1 × 2 = 2 → 2 × 3 = 6. If your program doesn’t give 6 with people = 3, the bug is in the range.
The code
# Program 2 · In how many ways can the queue be ordered? n! (factorial)
people = 5
# With for: we know we multiply from 1 to n
ways_for = 1 # 1, not 0: it is a PRODUCT accumulator
for i in range(1, people + 1): # + 1 because range's stop is left out
ways_for *= i
# With while: the same, but we keep the counter ourselves
ways_while = 1
i = 1
while i <= people:
ways_while *= i
i += 1 # without this line, infinite loop
print(f"{people}! with for: {ways_for}")
print(f"{people}! with while: {ways_while}")
print("Do they match?", ways_for == ways_while)
# The process in sight: a text is built pass by pass
product = 1
steps = "" # TEXT accumulator: starts empty
for i in range(1, people + 1):
product *= i
steps += str(i)
if i < people: # no " x " after the last number
steps += " x "
print(f"Process: {steps} = {product}")
# And if the queue is longer? It grows VERY fast
product = 1
for n in range(1, 11):
product *= n # reuses the previous result: n! = (n-1)! x n
print(f"{n:2}! = {product}")
Output
5! with for: 120 5! with while: 120 Do they match? True Process: 1 x 2 x 3 x 4 x 5 = 120 1! = 1 2! = 2 3! = 6 4! = 24 5! = 120 6! = 720 7! = 5040 8! = 40320 9! = 362880 10! = 3628800
Checking by hand: 1 × 2 = 2, × 3 = 6, × 4 = 24, × 5 = 120. Both versions agree, and the table confirms it on its fifth line. Look how fast it grows: with 10 people there are more than three and a half million ways to order the queue.
This is how the accumulator grows pass by pass:
The trickiest part is the one that builds the process text:
product = 1
steps = "" # TEXT accumulator: starts empty
for i in range(1, people + 1):
product *= i
steps += str(i)
if i < people: # no " x " after the last number
steps += " x "
print(f"Process: {steps} = {product}")
| Pass (i) | product | steps after the pass |
|---|---|---|
| 1 | 1 | “1 x “ |
| 2 | 2 | “1 x 2 x “ |
| 3 | 6 | “1 x 2 x 3 x “ |
| 4 | 24 | “1 x 2 x 3 x 4 x “ |
| 5 | 120 | “1 x 2 x 3 x 4 x 5” |
On the last pass i < people is 5 < 5, which is False, so the final ” x ” is not added. It’s the same accumulator pattern, but with text: it starts at "" and things are stuck onto it with +=.
What if nobody is in the queue? By definition, 0! = 1 (there is exactly one way to order zero people: do nothing). And the program gets it without any if, because range(1, 1) is empty:
# What about 0!? range(1, 1) is empty: the for does not make a single pass
people = 0
ways = 1
for i in range(1, people + 1):
ways *= i
print("This line never gets printed")
print(f"{people}! = {ways}")
print("Passes of range(1, 1):", len(range(1, people + 1)))
Output:
0! = 1 Passes of range(1, 1): 0
The for doesn’t make a single pass (the print inside never shows up), so ways keeps its starting value, 1. A product accumulator starting at 1 handles the edge case on its own.
Questions you might ask
Why ways_for = 1 and not 0? Because 0 × anything = 0. With 0, the result would always be 0, for any number of people. 1 doesn’t change what you multiply.
Why range(1, people + 1)? Because the range stop is left out. With range(1, people) it would stop at 4 and the result would be 24, not 120. And we start at 1 because, if we started at 0 (range(people + 1)), the first pass would multiply by 0.
If both versions give the same result, which one do I use? The for one. It does the same in fewer lines and you can’t forget the i += 1. The while only makes sense when you don’t know how many passes there will be.
Why doesn’t the table recompute each factorial from scratch? Because it doesn’t need to: 6! = 5! × 6. The product accumulator already holds the previous factorial, so each pass does a single multiplication. Computing them from scratch would need a nested loop and many more operations.
What is {n:2}? It reserves two spaces for the number, so ” 1!” and “10!” line up in the column.
Reusing product without setting it back to 1. If you delete the product = 1 before the table, the table starts from the 120 of the previous block and everything comes out multiplied by 120.
Try it yourself
- Change
peopleto 7 and check with the table that the result is 5040. - Make the table stop (with
break) as soon as the factorial goes over a million, and print thenwhere it happened.
Program 3: the croissant oven countdown
The café oven has a timer: it counts down from 5 seconds (in real life they’d be minutes, but this way you don’t wait as long), warns you halfway through to turn the tray around and announces that the croissants are ready. Then we do the same countdown with while to compare them. What makes it tricky is counting down with range and adding a warning on one specific pass only.
The idea
We know where we go from and to (from 5 to 1), so for. To go down, range needs a negative step: range(seconds, 0, -1). The stop (0) is left out, so the last pass is the one for 1. The tray warning is an if inside the body that only holds on one pass. And time.sleep(1) makes the program wait one real second between numbers.
Before coding, write down what range(5, 0, -1) produces and what range(5, 0) would produce. If the second one comes out empty, you’ve got it (and if not, look at the range diagram in the theory).
The code
# Program 3 · Croissant oven countdown
import time
seconds = 5
print("=== Oven running ===")
for s in range(seconds, 0, -1): # 5, 4, 3, 2, 1: 0 is left out
print(f"{s}...")
if s == 3:
print(" (turn the tray around)")
time.sleep(1) # waits 1 real second
print("Croissants ready!")
print("--- The same countdown with while ---")
s = seconds
while s > 0:
print(f"{s}...", end=" ") # end=" " keeps the numbers on the same line
s -= 1 # without this line, the oven never finishes
print("Ready!")
Output
=== Oven running === 5... 4... 3... (turn the tray around) 2... 1... Croissants ready! --- The same countdown with while --- 5... 4... 3... 2... 1... Ready!
Check: five numbers from 5 to 1 (no 0), the warning right after the 3 and, in the while version, the same five numbers on one line. When you run it, the first part takes 5 seconds: one for each time.sleep(1).
Zooming into the for loop:
for s in range(seconds, 0, -1): # 5, 4, 3, 2, 1: 0 is left out
print(f"{s}...")
if s == 3:
print(" (turn the tray around)")
time.sleep(1) # waits 1 real second
| Pass | s | s == 3? | What it prints |
|---|---|---|---|
| 1 | 5 | No | 5… |
| 2 | 4 | No | 4… |
| 3 | 3 | Yes | 3… and the tray warning |
| 4 | 2 | No | 2… |
| 5 | 1 | No | 1… |
Questions you might ask
Why range(seconds, 0, -1) and not range(seconds, 1, -1)? Because the stop is left out: with 1 as the stop, the last pass would be the one for 2 and the 1 wouldn’t appear. For 1 to be included, the stop has to be 0.
What is import time for? To use time.sleep(), which lives in the time module of the standard library. Without the import, Python doesn’t know what time is and raises NameError.
Why doesn’t the while version have a sleep? So you can see the difference in shape without waiting another 5 seconds. What matters in that version is s -= 1: without it, s stays at 5 and the oven never finishes.
What does end=" " do? It changes what print adds at the end: a space instead of a line break. That’s why the while numbers come out side by side, and the final “Ready!” appears on the same line.
Is this program better with for or while? With for: you know how many passes there will be and the loop keeps the counter itself. Here both are equally short, but the while has a line you can forget.
Writing range(5, 0) without the -1. There’s no error: the range is empty, the loop makes no passes and the program jumps straight to “Croissants ready!”. It’s one of the hardest bugs to spot because there’s no message at all.
Try it yourself
- Make the tray warning appear halfway through the countdown for any value of
seconds(hint:seconds // 2). - Change the
whileversion so it counts down in twos from 10 (10, 8, 6, 4, 2).
Common mistakes in this practice
- Counting the attempt at the wrong moment: if it’s added after printing, the messages are one pass behind and an extra attempt slips in.
orinstead ofandin a double condition: the loop doesn’t stop when it should; with two “keep going” conditions, it’s almost alwaysand.- A product accumulator starting at 0: everything comes out 0.
- A
rangethat falls short:range(1, n)instead ofrange(1, n + 1). - Forgotten negative step:
range(5, 0)is empty and the loop does nothing, silently. - A
whilewithout its-= 1or+= 1: infinite loop; Ctrl + C to get out.
Watch it step by step in Python Tutor
On pythontutor.com (choose Python 3) you can watch the PIN condition change pass by pass. This is a short version of program 1, with two wrong PINs and a right one:
# For Python Tutor: the till PIN, short version
PIN = "2024"
typed = ["1234", "0000", "2024"]
attempts = 0
is_open = False
while attempts < 3 and not is_open:
pin = typed[attempts]
attempts += 1
if pin == PIN:
is_open = True
print(is_open, attempts)
Output:
True 3
What to watch step by step:
- Before the loop,
attemptsis 0 andis_openisFalse: the condition0 < 3 and not FalseisTrue. - On each pass,
pintakes the position marked byattemptsbeforeattemptsadds 1. Watch both variables change in that order. - On the third pass,
pinis “2024” andis_openbecomesTrue. - The arrow goes back to the
while, evaluates3 < 3 and not Trueand leaves: both parts are false at once. Check what would happen iftypedhad the right PIN in second position. - The final
printshowsTrue 3: the till opened and three attempts were used.
Summary
- A
whilewith two conditions joined byandends as soon as either of them becomes false; a boolean flag tells you afterwards why it ended. - When you add to a counter (before or after checking and printing) changes the messages: trace it by hand.
- Product accumulators start at 1, sums at 0 and texts at
"". range(1, n + 1)reaches n;range(n, 0, -1)counts from n down to 1; an empty range doesn’t raise an error, it just makes no passes.- If you know how many passes there will be,
for; the equivalentwhileneeds you to keep the counter. - Without
input(), a list acts as the keyboard and the output can be checked line by line.
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Can you explain why the PIN program ends in both cases?
- Can you say, without running it, what
range(10, 0, -3)produces? - Have you traced the factorial of 4 by hand?
- Have you tried the “Try it yourself” ideas without looking at the theory?
- Could you rewrite the countdown with
whilewithout it hanging?
If you said yes to almost everything, you’re ready for the Python loops exercises, where I no longer give you the code. See you there!
Next step: exercisesPython loops — exercises with FizzBuzz, a growing investment, primes and a savings plan (and the break that only left the tray)Did this article help you? Here is how you can support SergioLearns →




What is an operating system — theory with your laptop on a study afternoon, a write() call and a CPU that never rests (and the three programs that weren’t running at the same time)