Python From Scratch — Part 4: Loops
You told Python to remove every 2 from the list, and one survived. Loops are the friendliest thing in the language until they quietly skip an item or run until the heat death of the universe — here's every loop you need, and the two traps that get everyone once.
Part 4 of 23 · Level: Beginner · From Hello World to Neural Networks
Sooner or later every program has to do the same thing more than once, and copy-pasting a line forty times is how you end up with a bug in thirty-nine of them. Loops let you write the work once and tell Python how often to run it. If Part 3 taught you how a program decides whether to do something, this part is about doing it again and again without losing your mind.
The for loop: walking over things
The for loop takes anything iterable — a list, a string, a tuple, a dictionary, even a file — and hands you one item per pass. No counter to increment, no index to babysit.
colours = ["red", "green", "blue"]
for colour in colours:
print(colour.upper())
Output:
RED
GREEN
BLUE
colour is just a name you pick; Python reassigns it to the next item each time round. Strings are iterable too, so you can loop over characters the same way:
for letter in "hi":
print(letter)
Output:
h
i
The vast majority of loops you'll ever write look exactly like the first example: hand for a collection, do something with each item. That's the whole idea.
Counting with range()
Sometimes you don't have a list — you just want to do something a fixed number of times. That's what range() is for.
for i in range(3):
print(f"attempt {i}")
Output:
attempt 0
attempt 1
attempt 2
Two things to internalise now and save yourself pain later: range starts at 0, and it stops before the number you give it. range(3) yields 0, 1, 2 — three values, no 3. That half-open behaviour feels odd for about a week and then feels obviously correct forever.
range also takes a start, a stop, and a step:
for n in range(2, 11, 2):
print(n)
Output:
2
4
6
8
10
Start at 2, stop before 11, step by 2. Note that 11 never prints — the stop is always exclusive. A negative step counts down: range(3, 0, -1) gives you 3, 2, 1.
When you need the index too: enumerate()
New Python programmers reach for range(len(colours)) to get positions. It works, but it's the loop equivalent of holding your fork with both hands. When you need the item and its index, use enumerate():
names = ["Ada", "Alan", "Grace"]
for i, name in enumerate(names, start=1):
print(f"{i}. {name}")
Output:
1. Ada
2. Alan
3. Grace
enumerate hands back a (index, item) pair each pass, and Python unpacks it into i and name for you. The start=1 is optional — handy when you're numbering things for humans, who stubbornly begin counting at one.
Looping over a dictionary
Loop over a dict and you get its keys by default:
scores = {"Ada": 90, "Alan": 85}
for name in scores:
print(name)
Output:
Ada
Alan
When you want keys and values together, use .items() and unpack the pair:
for name, score in scores.items():
print(f"{name} scored {score}")
Output:
Ada scored 90
Alan scored 85
There's also .values() for when you only care about the values. That's enough dict-looping for now — we'll go deeper when dictionaries get their own part.
The while loop: when you don't know how many times
for is for "loop over these." while is for "keep going until." It runs as long as its condition is true — and the condition follows the exact truthiness rules from Part 3.
countdown = 3
while countdown > 0:
print(countdown)
countdown -= 1
print("liftoff")
Output:
3
2
1
liftoff
The crucial detail: something inside the loop has to eventually make the condition false. Here it's countdown -= 1 (shorthand for countdown = countdown - 1). Forget that line and you've built a machine that counts to three forever. We'll come back to that.
break and continue
Two words give you fine control inside any loop. break bails out of the loop entirely. continue skips the rest of the current pass and jumps to the next one.
for n in range(1, 10):
if n == 5:
break
print(n)
Output:
1
2
3
4
The loop was set up to run to 9, but break yanked it out the moment n hit 5. Now continue, to skip the even numbers:
for n in range(1, 7):
if n % 2 == 0:
continue
print(n)
Output:
1
3
5
% is the remainder operator, so n % 2 == 0 means "n is even." When it's even, continue skips straight past the print.
Nested loops
A loop can live inside another loop. The inner one runs all the way through for every single pass of the outer one — which is how you produce a grid, like a times table:
for row in range(1, 4):
for col in range(1, 4):
print(row * col, end=" ")
print()
Output:
1 2 3
2 4 6
3 6 9
(end=" " tells print to end with a space instead of a newline, so a row stays on one line; the bare print() afterwards drops to the next line.) One thing to know up front: break only escapes the innermost loop it's in, not all of them. And if you find yourself three loops deep, that's usually a nudge to pull some of it into a function or rethink your data.
for ... else: the clause nobody remembers exists
Here's a genuine Python oddity. A for (or while) loop can have an else block, and it runs only if the loop finished without hitting a break. It's tailor-made for "search through these, and tell me if nothing matched."
haystack = [3, 7, 11, 15]
for n in haystack:
if n % 2 == 0:
print("found an even number")
break
else:
print("no evens here")
Output:
no evens here
The trick to reading it: mentally rename else to "no break." The loop ran to completion, never broke, so the else fired. If the list had contained an even number, we'd have printed and broken out — and the else would have been skipped. Almost nobody remembers this feature exists, which makes it a small delight when it saves you a flag variable.
Gotcha: the accidental infinite loop
The most common while bug in the world is forgetting to change the thing the condition checks:
# DON'T run this — it never stops
n = 5
while n > 0:
print(n)
# forgot to write n -= 1, so n stays 5 forever
n never drops below 1, the condition stays true, and your terminal fills with 5s until the heat death of the universe (or you press Ctrl+C, which interrupts a runaway program). Whenever you write a while, immediately ask: what in here makes the condition eventually false?
Gotcha: don't delete from a list while looping over it
This one burns everybody exactly once. Say you want to strip every 2 out of a list:
nums = [1, 2, 2, 3]
for n in nums:
if n == 2:
nums.remove(2)
print(nums)
Output:
[1, 2, 3]
You asked it to remove every 2, and one survived. Here's why: the loop tracks its position by index. When you remove the first 2, everything after it slides one slot to the left — so the second 2 shifts into a position the loop has already walked past. It gets skipped.
The fix is to never mutate the thing you're iterating. Loop over a copy, or better, build a fresh list of what you want to keep:
nums = [1, 2, 2, 3]
nums = [n for n in nums if n != 2]
print(nums)
Output:
[1, 3]
That bracket syntax is a list comprehension — a compact "build a new list from an old one" — and it gets its own spotlight later in the series. For now, just know the rule: read the list you're looping, write to a new one.
Next up
You can now make Python repeat itself intelligently. In Part 5: Functions we package a chunk of that logic behind a name you can call again and again — turning "loops that do a thing" into "a thing you can reuse anywhere," without the copy-paste that started all this trouble.
Jako Heiberg
Software developer with 40+ years of building things that work. Full-stack, FastAPI, React. Based in Cape Town, working remotely, worldwide.