A for Loop Walks Over Items, Not Indexes
Python's for loop is not the C-style loop with a counter, a condition and an increment. It says: take this collection, hand me one item at a time, and run this block for each. The loop variable holds the item, not its position. That is why for fruit in fruits: reads like a sentence, and why an entire family of off-by-one errors simply cannot happen.
Anything Python can hand out one piece at a time works here — a list, a tuple, a string (giving you characters), a set, a dictionary, a file object (giving you lines), or a range. The word for such a thing is iterable, and the fact that they all behave identically in a for loop is one of the reasons Python code stays short.
Two helpers cover the cases where the plain form is not enough. enumerate() gives you the position alongside the item, with start=1 when you want human-friendly numbering. zip() walks two or more collections in step, which is how you pair a list of names with a list of marks. Between them they remove almost every reason to write for i in range(len(items)) — a pattern that is legal, noisy, and the usual place index bugs are born.
Looping over a dictionary gives you its keys, not its values. That surprises people once and then never again. Use .values() when you want the values and .items() when you want both, which unpacks neatly into two loop variables.
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
for char in "Python":
print(char, end=" ") # P y t h o n
print()
# When you genuinely need the position, use enumerate
for position, fruit in enumerate(fruits, start=1):
print(f"{position}. {fruit}")
# What not to write: noisy, and one typo away from an IndexError
for i in range(len(fruits)):
print(fruits[i])
# Two collections in step
names = ["Asha", "Ravi", "Meera"]
marks = [87, 92, 78]
for name, mark in zip(names, marks):
print(f"{name}: {mark}")
# Dictionaries hand you the KEYS
scores = {"Asha": 87, "Ravi": 92}
for name in scores:
print(name) # Asha, Ravi
for name, mark in scores.items():
print(name, mark) # Asha 87, Ravi 92 for item in collection:— the loop variable is the item itselfenumerate(items)— pairs each item with its position;start=1for human numberingzip(a, b)— walks two collections together, stopping at the shorter one- Looping a dict gives keys; use
.values()or.items()for the rest - Strings give characters, files give lines — the same loop works on all of them
zip()stops silently at the end of the shortest input, so a mismatch in length loses data with no warning. From Python 3.10 you can passstrict=Trueto make an unequal length raise aValueErrorinstead.
range(): Lazy, and Its Stop Value Is Excluded
range() produces a sequence of whole numbers, and it is what you use when you want to repeat something a fixed number of times rather than walk over existing data. It takes up to three arguments in the same order as a slice: start, stop, step.
The rule that causes almost every mistake with it is that the stop value is excluded. range(5) gives 0, 1, 2, 3, 4 — five numbers, none of them 5. range(1, 5) gives 1, 2, 3, 4, so "one to five" in ordinary speech is range(1, 6) in Python. The compensation is that range(n) produces exactly n values and its numbers line up perfectly with list positions, which is why range(len(items)) is always in bounds.
A negative step counts down, and the same exclusion rule applies at the bottom: range(10, 0, -1) gives 10 down to 1, not 0. If the start and stop cannot be reached in the direction of the step, you get an empty range rather than an error — range(5, 1) is simply empty, so a loop over it runs zero times and your program carries on quietly. That silence is worth knowing about when a loop mysteriously produces no output.
range does not build a list. It is a lazy object that works out each number as it is asked for, so range(1_000_000) uses the same tiny amount of memory as range(5). Printing one shows you range(0, 5) rather than the numbers; wrap it in list() when you actually want them.
for i in range(5):
print(i, end=" ") # 0 1 2 3 4 — 5 is NOT included
print()
for i in range(2, 8):
print(i, end=" ") # 2 3 4 5 6 7
print()
for i in range(0, 10, 2):
print(i, end=" ") # 0 2 4 6 8
print()
for i in range(10, 0, -1):
print(i, end=" ") # 10 9 8 7 6 5 4 3 2 1 — 0 excluded
print()
# "1 to 5" in English is range(1, 6) in Python
print(list(range(1, 5))) # [1, 2, 3, 4]
print(list(range(1, 6))) # [1, 2, 3, 4, 5]
# An impossible range is empty, not an error
print(list(range(5, 1))) # []
# range is lazy, not a list
print(range(5)) # range(0, 5)
print(list(range(5))) # [0, 1, 2, 3, 4]
print(len(range(1000000))) # 1000000 — instant, nothing was built range(stop)— 0 up to but not includingstoprange(start, stop)— starts where you say, still excludesstoprange(start, stop, step)— a negative step counts downrange(n)yields exactlynvalues- It is lazy: use
list(range(...))when you need the numbers themselves
rangeonly handles whole numbers. There is norange(0, 1, 0.1)— it raises a TypeError. For fractional steps, loop over integers and divide, or usenumpy.arangeonce you reach numerical work.
while: Repeat Until Something Changes
A while loop repeats as long as its condition stays true, and it is the right tool when you do not know in advance how many rounds you need. Asking a user for valid input, retrying a network call, or working out how many years an investment takes to double are all while problems: the stopping point depends on what happens inside the loop.
That freedom carries the one danger that for loops do not have. Something inside the body must eventually make the condition false. Forget the line that increases the counter and the loop runs forever, printing until you stop it with Ctrl+C. When a program appears to hang, an unchanging while condition is the first thing to check — and the specific question to ask is "which line is supposed to change the value being tested?"
A deliberately infinite loop is a legitimate design, not a mistake, as long as it has a clear way out. while True: with a break inside is the standard shape for menu-driven programs and input validation, and it often reads better than trying to squeeze the exit condition into the header. The rule is simply that the break must be reachable — an exit the user can always get to.
As a rough guide: if you are counting or walking over data, use for. If you are waiting for a condition, use while. Rewriting a for loop as a while with a manual counter is a step backwards, because you have taken on the bookkeeping the for loop was doing for you.
count = 0
while count < 5:
print(count, end=" ")
count += 1 # forget this line and the loop never ends
print() # 0 1 2 3 4
# The stopping point depends on what happens inside
balance = 10000
years = 0
while balance < 20000:
balance *= 1.08
years += 1
print(f"{years} years at 8% to double")
# while True with a clear exit — the menu-loop shape
while True:
command = input("Command (or 'quit'): ").strip().lower()
if command == "quit":
break
print(f"running {command}")
# An accidental infinite loop
n = 5
while n > 0:
print(n)
# n -= 1 <- missing: n never changes, so n > 0 stays true forever - Ctrl+C stops a runaway loop in the terminal and raises
KeyboardInterrupt. If your loop is also printing, the output may take a moment to stop scrolling — that is the output buffer draining, not the loop continuing.
break, continue and the Loop else
break leaves the loop immediately — no further items, no further passes. continue abandons the rest of the current pass and goes straight to the next item. Both work in for and while loops. continue is at its best as a filter at the top of a loop body: skip the rows you do not care about early, and the interesting work below stays unindented.
The detail that catches people is that break only leaves the innermost loop it is inside. In a nested pair, a break in the inner loop returns you to the outer loop, which then carries on with its next item. There is no labelled break in Python. To leave both, either set a flag and test it in the outer loop, or — usually cleaner — move the nested loops into a function and return.
Python has one loop feature most languages lack: else on a loop. The else block runs when the loop finishes normally and is skipped when a break fired. Read it as "if we never broke out". Its one genuinely good use is search: loop looking for something, break when you find it, and put the not-found message in the else. That removes the found-flag variable that would otherwise be needed. It is admittedly a confusing keyword choice — nobreak would have been clearer — so add a comment when you use it.
# break leaves the loop entirely
for n in range(10):
if n == 5:
break
print(n, end=" ") # 0 1 2 3 4
print()
# continue skips the rest of this pass only
for n in range(10):
if n % 2 == 0:
continue
print(n, end=" ") # 1 3 5 7 9
print()
# continue as an early filter keeps the real work unindented
rows = ["101,87", "", "# comment", "102,92"]
for row in rows:
if not row or row.startswith("#"):
continue
roll, mark = row.split(",")
print(roll, int(mark))
# break leaves only the INNER loop
for i in range(3):
for j in range(3):
if j == 1:
break # ends the j loop; the i loop continues
print(i, j) # 0 0 / 1 0 / 2 0
# for/else: else runs only if no break happened
roll_numbers = [101, 102, 103]
target = 105
for r in roll_numbers:
if r == target:
print("found")
break
else:
print(f"{target} is not in the list") # this runs break— leave the loop now; only the innermost onecontinue— skip to the next item; the loop carries onelseon a loop — runs only if nobreakfired; read it as "if not found"- Python has no labelled break; use a function and
returnto escape nested loops continueat the top of a body is the tidiest way to skip unwanted rows
The Trap: Changing a Collection While You Loop Over It
This one produces wrong answers rather than errors, which is why it deserves its own section. When you loop over a list, Python keeps an internal position counter and moves it forward after every pass. If you remove an item during the loop, everything after it shifts down one place — and the counter has already moved on, so the item that shifted into the vacated position is never examined.
The result is that filtering a list by removing from it while iterating skips roughly every second match. With the marks below, one of the two zeros survives. No exception is raised, the code looks reasonable, and the bug hides until the day two unwanted items happen to sit next to each other.
Dictionaries and sets are stricter: changing their size during iteration raises RuntimeError: dictionary changed size during iteration. That is a kindness, since it fails loudly rather than quietly.
There are two correct fixes and one bad habit to avoid. The best fix is to build a new collection rather than editing the old one — marks = [m for m in marks if m != 0] says exactly what you mean and is what you would write anyway once you know comprehensions. The alternative is to iterate over a copy, with for m in marks[:] for a list or for key in list(scores) for a dictionary, so the thing you are walking and the thing you are editing are different objects. The bad habit is switching to an index-based while loop and adjusting the index by hand; it works, and it is a reliable source of off-by-one bugs.
# WRONG — removing while iterating
marks = [0, 0, 87, 92]
for m in marks:
if m == 0:
marks.remove(m)
print(marks) # [0, 87, 92] — one zero survived
# Fix 1 (best): build a new list
marks = [0, 0, 87, 92]
marks = [m for m in marks if m != 0]
print(marks) # [87, 92]
# Fix 2: iterate over a copy
marks = [0, 0, 87, 92]
for m in marks[:]: # marks[:] is a separate copy
if m == 0:
marks.remove(m)
print(marks) # [87, 92]
# Dictionaries refuse outright
scores = {"Asha": 87, "Ravi": 0, "Meera": 78}
# for name, mark in scores.items():
# if mark == 0:
# del scores[name] # RuntimeError: dictionary changed size
for name in list(scores): # list(scores) is a snapshot of the keys
if scores[name] == 0:
del scores[name]
print(scores) # {'Asha': 87, 'Meera': 78} - Adding to a list while looping over it is worse than removing: the loop keeps reaching the newly added items and never ends. If you need to grow a collection based on what you are reading, write into a second list.
Loops You Should Not Write
A surprising share of hand-written loops are re-implementations of a built-in function. Adding up a list, finding the largest value, counting how many items pass a test, checking whether any of them do — Python already has all of these, and the built-in version is shorter, harder to get wrong, and faster because the work happens in compiled code.
sum(), max(), min() and len() cover the arithmetic. any() and all() answer "is at least one true" and "are they all true", and they short-circuit, so any() stops at the first true item rather than checking the rest. sorted() orders a collection without changing the original. The habit worth building is to pause before writing a loop and ask whether a built-in already says it.
Two small facts about the loop variable, because they cause confusing errors. It still exists after the loop ends, holding whatever it had on the final pass — handy occasionally, and a trap when you reuse the same name in two loops. And if the collection was empty, the variable is never created at all, so touching it afterwards raises NameError rather than giving you an obvious empty value.
Lesson 21 takes this further with comprehensions, which compress the common "loop, filter, collect" pattern into a single expression. Until then, write the loop plainly; the point here is only that total = 0 followed by four lines of addition is not the shortest true statement of what you meant.
marks = [78, 84, 91, 65]
# The hand-written version
total = 0
for m in marks:
total += m
print(total) # 318
# The same thing, said once
print(sum(marks)) # 318
print(max(marks), min(marks)) # 91 65
print(sum(marks) / len(marks)) # 79.5
# Questions about a whole collection
print(any(m > 90 for m in marks)) # True — did anyone score above 90?
print(all(m >= 40 for m in marks)) # True — did everyone pass?
print(sorted(marks, reverse=True)) # [91, 84, 78, 65]
print(marks) # unchanged: sorted() returns a new list
# The loop variable outlives the loop
for i in range(3):
pass
print(i) # 2
# ...but only if the loop ran at least once
for j in []:
pass
# print(j) # NameError: name 'j' is not defined sorted(x)returns a new list and leavesxalone;x.sort()reordersxin place and returnsNone. Writingx = x.sort()is a common slip that replaces your list withNone.
