Loops — for / while, break / continue
Every form of iteration in Python — for loops, range(), while loops, break/continue, the loop else clause, nested loops, enumerate(), and zip().
for Loops — Iterating Over Anything Python Can Walk Through
A for loop in Python is fundamentally different from the "count from 0 to N" for-loop you may have seen in C, Java, or JavaScript. Python's for loop is built around a single idea: it walks through an iterable — anything Python knows how to hand out values from, one at a time — and runs its body once per value. There is no separate counter variable to initialise, no condition to check, and no increment step to remember. You simply say "for each thing in this collection, do this."
for letter in "Python":
print(letter)
# P
# y
# t
# h
# o
# n
# A string is iterable — each character is handed out in order, one at a time.This works identically over a range() (covered in depth in Part 02) and over a list — you have not formally met lists yet (that is the entire subject of the next module), but you have already seen enough of them to follow a loop over one.
for i in range(5):
print(i)
# 0 1 2 3 4
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
# apple
# banana
# cherryThe important mental shift, if you are coming from a language with C-style for loops: Python does not ask "how many times should this run, and what index am I on?" It asks "what collection am I walking through, and what is the current item?" Every iterable in Python — a string, a range, a list, a dict, a set, a file opened for reading, and dozens of built-in functions that produce values lazily — plugs into exactly this same for x in ...: syntax. You are learning one mechanism that will keep working, unmodified, as your data structures get richer through the rest of this track.
range() — All Three Forms, and the Off-By-One Mistakes That Come With Them
range() generates a sequence of integers, and it is by far the most common way to write a "run this N times" loop in Python. It comes in three forms depending on how many arguments you give it, and each form has a specific, easy-to-misremember rule about which numbers it includes.
range(5) # 0, 1, 2, 3, 4 — stop only: starts at 0, stops BEFORE 5
range(2, 8) # 2, 3, 4, 5, 6, 7 — start, stop: starts at 2, stops BEFORE 8
range(0, 10, 2) # 0, 2, 4, 6, 8 — start, stop, step: counts by 2, stops BEFORE 10The single rule to memorise: range() always excludes its stop value. range(5) produces five numbers — 0 through 4 — never 5. This trips up nearly every beginner at least once, usually while trying to loop "from 1 to 10 inclusive" and getting nine numbers instead of ten, or getting an index one past the end of a collection.
# Wanting the numbers 1 through 10, inclusive
for i in range(1, 10): # WRONG — this gives 1 through 9
print(i)
for i in range(1, 11): # RIGHT — stop must be one past the last value you want
print(i)
# Wanting to loop over every index of a 5-item list
items = ["a", "b", "c", "d", "e"]
for i in range(len(items)): # RIGHT — len(items) is 5, so this gives 0..4, exactly the valid indices
print(items[i])
for i in range(len(items) - 1): # WRONG — silently skips the last itemN to be included, the stop argument must be N + 1. When you want every valid index of a collection of length N, the stop argument is exactly N (since indices run from 0 to N-1) — do not subtract 1 a second time, that is the mistake shown above.range() with a negative step — counting downward
A negative step counts backwards. The same stop-is-excluded rule still applies, which means counting down to (and including) 1 requires a stop of 0, not 1.
for i in range(10, 0, -1):
print(i)
# 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
# Stop is 0, not 1 — because 0 is excluded, and we want 1 to be the last value printed.One more property worth knowing: range() does not build a full list of numbers in memory up front — it is a lazy sequence that computes each value only as the loop asks for it. range(10_000_000) takes essentially no memory to create, unlike building an actual list of ten million integers. This matters more once you reach the Performance and Generators modules later in this track, but it is worth knowing now that range() is cheap regardless of how large the range is.
while Loops — Repeating Until a Condition Becomes False
Where for is built around "walk through this collection," while is built around "keep repeating as long as this condition is true." Use while when you do not know in advance how many iterations you need — waiting for user input to satisfy some rule, retrying a network request until it succeeds, or processing a queue until it is empty are all situations where a fixed count does not make sense, but a condition does.
count = 0
while count < 5:
print(count)
count += 1
# 0 1 2 3 4
# The loop checks "count < 5" BEFORE every iteration, including the first.
# Once count reaches 5, the condition is false and the loop stops.The infinite loop danger — and why it is entirely your responsibility
Unlike a for loop, which automatically terminates once its iterable is exhausted, a while loop has no built-in stopping point at all. If the condition never becomes false, the loop runs forever — this is called an infinite loop, and it is one of the single most common bugs written by engineers of every experience level, not just beginners.
count = 0
while count < 5:
print(count)
# forgot to increment count!
# This never stops. count is always 0, so "count < 5" is always True.
# In a real terminal, this would hang the program until you interrupt it (Ctrl+C).while True: — an intentional infinite loop with an internal exit
Sometimes an infinite loop is exactly what you want — deliberately, with an explicit break somewhere inside it to exit. This pattern is extremely common for things like a command-line menu that keeps prompting the user until they choose "quit."
while True:
command = input("Enter a command (or 'quit'): ")
if command == "quit":
break
print(f"Running: {command}")
print("Goodbye!")This is not the same mistake as the accidental infinite loop above — the difference is that break gives the loop an explicit, readable exit path. A reviewer can look at while True: immediately followed by an if ...: break and understand exactly how the loop ends, which is the entire point.
break and continue — Changing a Loop's Path Mid-Iteration
break and continue both work inside for and while loops, and both let you change the loop's normal flow without a wall of extra conditionals wrapping the entire loop body.
numbers = [4, 8, 15, 16, 23, 42]
for n in numbers:
if n == 16:
print("Found 16, stopping search")
break
print(f"Checking {n}...")
# Checking 4...
# Checking 8...
# Checking 15...
# Found 16, stopping search
# Note: 23 and 42 are never even looked at — break exits the loop completely.numbers = [1, 2, 3, 4, 5, 6, 7, 8]
for n in numbers:
if n % 2 != 0:
continue # skip odd numbers — jump straight to the next iteration
print(n)
# 2 4 6 8
# continue does not exit the loop — it only skips the remaining lines
# in the CURRENT pass, then moves on to the next item as normal.The distinction is easy to state but worth being precise about: break ends the loop — no more iterations happen, and execution continues on the line right after the loop. continue ends only the current iteration — the loop keeps going with the next item, exactly as if you had wrapped the rest of the loop body in an if that skipped it.
for x in items: if is_valid(x): process(x) against for x in items: if not is_valid(x): continue \\n process(x). The second form is a guard clause applied to a loop body — the exact same readability idea from the Control Flow module, just inside a loop instead of a function.Both break and continue work identically inside while loops — there is nothing special about their behaviour with for specifically. In a nested loop (Part 06), both only affect the innermost loop they are written inside — a detail that matters more than it might first appear.
The Loop else Clause — Python's Most Skipped-Over Feature
This is a genuinely obscure corner of Python that most tutorials skip entirely, and most working engineers have never used — but it solves a real, specific problem cleanly enough that it is worth understanding properly rather than avoiding out of unfamiliarity. Both for and while loops can have an else clause attached. The rule that governs it is precise and, once you see it once, easy to remember: the else block runs if the loop finished normally — that is, if it was never stopped early by a break.
for x in range(5):
print(x)
else:
print("Loop completed without a break")
# 0 1 2 3 4
# Loop completed without a break
# The else ran because the loop reached the end on its own.for x in range(5):
if x == 3:
break
print(x)
else:
print("Loop completed without a break")
# 0
# 1
# 2
# (nothing else prints — the else is SKIPPED because break fired)The problem this solves: searching for something in a loop, and needing to know whether the search succeeded or ran out of items to check. Without the loop else, this normally requires a separate flag variable set before the loop and checked after it.
numbers = [4, 8, 15, 16, 23, 42]
target = 99
found = False
for n in numbers:
if n == target:
found = True
break
if not found:
print(f"{target} was not in the list")numbers = [4, 8, 15, 16, 23, 42]
target = 99
for n in numbers:
if n == target:
print(f"Found {target}")
break
else:
print(f"{target} was not in the list")
# The "else" here effectively means: "if the for loop never broke, run this."
# It reads almost like "for...else" is asking "did we NOT find it?"for loop with no break at all will always run its else, every single time, once it finishes.Most experienced Python engineers still avoid this feature in real code — not because it is broken, but because so few readers recognise it on sight that it tends to slow a reviewer down rather than help them. Knowing it exists and reading it correctly when you encounter it in someone else's code is genuinely valuable; reaching for it as your default pattern is a judgment call you will develop over time.
Nested Loops — And Why Their Performance Cost Compounds
A loop can contain another loop inside its body — this is called nesting, and it is the standard way to work through two-dimensional data, like a grid, a table, or every possible pair of items from two separate collections.
for row in range(3):
for col in range(3):
print(f"({row}, {col})", end=" ")
print() # newline after each row
# (0, 0) (0, 1) (0, 2)
# (1, 0) (1, 1) (1, 2)
# (2, 0) (2, 1) (2, 2)The inner loop runs to completion for every single iteration of the outer loop — this is the detail that matters for performance. A 3×3 nested loop runs the inner body 9 times total (3 outer iterations × 3 inner iterations each). This multiplies, not adds: a nested loop over two lists of 1,000 items each does not run 2,000 times — it runs 1,000,000 times.
list_a = list(range(1000))
list_b = list(range(1000))
count = 0
for a in list_a:
for b in list_b:
count += 1
print(count) # 1,000,000 — not 2,000
# This is what engineers mean by "O(n squared)" — covered properly
# in the Operators module's discussion of algorithmic complexity, and
# again in depth once you reach the Algorithms phase of this track.break only exits the innermost loop
A detail that catches people off guard the first time: break inside a nested loop only stops the loop it is directly written inside — the loop or loops surrounding it keep running normally.
for row in range(3):
for col in range(3):
if col == 1:
break # only exits the INNER loop
print(f"({row}, {col})")
# (0, 0)
# (1, 0)
# (2, 0)
# The outer loop still ran all 3 times — break never touched it.There is no built-in "break out of both loops at once" keyword in Python. The standard fix is to pull the nested loops into a function and use return to exit both levels at once, or to set a flag variable that the outer loop checks after the inner loop finishes.
enumerate() — Getting the Index and the Value Together
A very common early-Python mistake is reaching for range(len(items)) whenever both the index and the value are needed inside a loop. It works, but it is not the idiomatic way to write it, and it is noticeably less readable.
fruits = ["apple", "banana", "cherry"]
for i in range(len(fruits)):
print(f"{i}: {fruits[i]}")
# 0: apple
# 1: banana
# 2: cherry
# Works — but "i" is only ever used to re-index back into "fruits". That's the smell.fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
# 0: apple
# 1: banana
# 2: cherry
# Identical output, but "fruit" is handed to you directly — no manual re-indexing.enumerate() wraps any iterable and hands back pairs of (index, value) on each pass, which you unpack directly into two loop variables. It is a small change, but it is exactly the kind of idiom a Python code reviewer expects to see, and range(len(...)) used purely to fetch an index is one of the most common review comments given to engineers new to the language.
for i, fruit in enumerate(fruits, start=1):
print(f"{i}: {fruit}")
# 1: apple
# 2: banana
# 3: cherry
# Useful for human-facing output — numbering a printed list starting at 1, not 0.zip() — Looping Over Multiple Collections in Parallel
zip() takes two or more iterables and walks through them together, handing back one item from each on every pass — the loop equivalent of lining up two lists side by side and reading across both at once.
names = ["Maria", "Jordan", "Priya"]
scores = [92, 85, 78]
for name, score in zip(names, scores):
print(f"{name}: {score}")
# Maria: 92
# Jordan: 85
# Priya: 78Without zip(), the same result requires manually indexing into both lists using a shared index from range() — noticeably clumsier, and a step further from what the code is actually trying to express.
for i in range(len(names)):
print(f"{names[i]}: {scores[i]}")
# Same result, but two separate lookups per iteration instead of one direct unpack.zip() stops at the shortest iterable — silently
If the collections passed to zip() are different lengths, it stops as soon as the shortest one runs out — it does not raise an error, and it does not warn you. Extra items in the longer collection are simply never visited.
names = ["Maria", "Jordan", "Priya", "Sam"]
scores = [92, 85, 78] # one shorter than "names" — maybe Sam's score is still pending
for name, score in zip(names, scores):
print(f"{name}: {score}")
# Maria: 92
# Jordan: 85
# Priya: 78
# Sam is silently dropped — no error, no warning. This is exactly the kind of bug
# that looks fine in a demo with matched-length test data and then loses real
# records once the two lists genuinely diverge in production.itertools.zip_longest() is the safer choice — it continues through the longest iterable and fills in a placeholder (None by default) for any collection that ran out early, making the mismatch visible instead of quietly hiding it.A Denver Ski-Resort Booking Platform's Nightly Job Goes From 40 Minutes to 4 Seconds
A Denver-based ski-resort booking platform runs a nightly job that cross-references every newly created reservation against a list of resorts currently flagged for maintenance closures, to automatically send affected customers a rebooking notice. Early on, with a handful of resorts and a few hundred daily reservations, the job ran in under a second. Two seasons later, with thousands of daily reservations and hundreds of resorts on the platform, the same nightly job is taking 40 minutes and regularly runs past its scheduled window.
What the on-call engineer finds
The job's core logic is a nested loop, written when the data was tiny and never revisited: for every reservation, loop over the entire list of flagged resorts checking for a match. Exactly the pattern from Part 06 — two collections that started small enough that the nested loop's cost was invisible, and grew until the multiplication caught up with the team. With 5,000 reservations and 300 flagged resorts, that is 1.5 million comparisons every night, and climbing every season.
flagged_resort_ids = get_flagged_resorts() # returns a list
for reservation in get_new_reservations():
for resort_id in flagged_resort_ids:
if reservation.resort_id == resort_id:
send_rebooking_notice(reservation)
breakThe fix
The engineer converts flagged_resort_ids from a list to a set — covered in full in the next module, but the relevant idea is simple enough to use here already: checking whether a value exists in a set is dramatically faster than scanning through a list, because a set does not need to check every item one by one. The nested loop collapses into a single loop with a fast membership check.
flagged_resort_ids = set(get_flagged_resorts()) # a set, not a list
for reservation in get_new_reservations():
if reservation.resort_id in flagged_resort_ids:
send_rebooking_notice(reservation)The nightly job drops from 40 minutes to about 4 seconds. Nothing about the business logic changed — the fix was entirely about recognising a nested loop that had quietly become an O(n × m) operation over data that had grown past the point where that mattered, and replacing the inner loop with a lookup that does not scale the same way. This exact diagnosis — "why did the nightly job suddenly get slow" turning out to be a nested loop over data that grew — is one of the most common performance investigations in real backend engineering.
Four Misconceptions About Loops
5 Interview Questions — With Complete Answers
Loop Mistakes Beginners Make Constantly
Errors You Will Hit With Loops — And Exactly Why
🎯 Key Takeaways
- ✓A for loop walks through any iterable — a string, range, list, or anything else Python can iterate — one value at a time. It is not a counting loop the way for loops are in C-style languages.
- ✓range() always excludes its stop value. range(5) gives 0-4, not 0-5. When N should be included, the stop argument is N + 1.
- ✓while loops repeat as long as a condition is true, and have no automatic stopping point — you are entirely responsible for ensuring the condition eventually becomes False.
- ✓break exits a loop immediately; continue skips only the rest of the current iteration. Both only affect the innermost loop they are written inside.
- ✓The else clause on a for/while loop runs when the loop finishes WITHOUT hitting a break — remember it as "else, no break." It removes the need for a separate found/not-found flag variable.
- ✓Nested loops multiply their iteration counts (n × m, not n + m). This is the single most common cause of code that is fast in testing and slow in production once real data volume arrives.
- ✓enumerate(items) is the idiomatic way to get index and value together — prefer it over range(len(items)).
- ✓zip() pairs up multiple iterables and silently stops at the shortest one — use itertools.zip_longest() when mismatched lengths should be visible rather than silently tolerated.
What comes next
Module 07 covers functions — how to stop repeating yourself, parameters and default arguments (including a classic gotcha that catches even experienced engineers), return values, and the basics of variable scope.
Module 07 → Functions — Defining, Parameters, Return ValuesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.