for Loops
Iteration, range, enumerate, and looping over collections.
What you will be able to do
- Iterate over lists, tuples, sets, strings, and dictionaries
- Use range() with a start, stop, and step - and remember the stop is excluded
- Reach for enumerate() instead of range(len(...)) when you need the index
- Use zip() to walk two collections together, and know what it does with unequal lengths
- Write the accumulator pattern, and know which built-in replaces it
- Explain why modifying a collection while iterating it goes wrong
The idea, in plain English
A for loop takes an iterable - something that can hand out its items one at a time - and runs the body once per item, with the loop variable holding the current one. That is the whole idea, and it is why the same statement works on a list, a string, a dictionary, a file, and a range.
This is different from the for loop in C or Java, which counts. Python’s asks the object for its items and stops when they run out, so there is no index to initialise, no bound to get wrong, and no way to run off the end.
It also means that `for i in range(len(items))` followed by `items[i]` is doing by hand what the loop already does for you. When you want the index as well, enumerate gives you both; when you want two collections in step, zip does.
One rule matters more than the rest: do not add to or remove from a collection while you are looping over it. The loop is tracking a position, the collection is shifting underneath it, and the result is silently wrong rather than an error.
Worked example: Numbering a course’s lessons without a manual counter.
Iterating a dictionary gives you the keys
A plain `for key in user:` walks the keys, not the values and not the pairs. That surprises people once, and then the three methods make sense: keys(), values(), and items().
items() is the one you will use most, because it unpacks into two names in the loop header - `for key, value in user.items():` - and saves a lookup inside the body.
Since Python 3.7 dictionaries keep insertion order, so iteration is predictable. Sets do not: their order depends on hashing and can change between runs. If order matters for a set, sort it.
for k in user:The keys. Iterating a dict directly gives keys - the shortest form, and the idiomatic one.for k in user.keys():The same keys, said explicitly. Useful when the intent would otherwise be unclear.for v in user.values():The values, when you do not need the keys.for k, v in user.items():Both, unpacked in the header. The one you want most of the time.for r in sorted(roles):A set has no reliable order - sort it when the output order matters.range, enumerate, zip
range produces numbers, not a list - it generates them as asked, which is why range(1_000_000) costs nothing until you loop over it. Like slicing, the stop value is excluded: range(1, 5) gives 1, 2, 3, 4.
enumerate hands you the index alongside the item, and takes a start so a numbered list can begin at 1. It replaces the range(len(...)) pattern entirely.
zip walks several iterables in step, stopping at the shortest. That silent truncation is a real hazard - if two lists are meant to be the same length and are not, zip hides the bug. Python 3.10 added strict=True, which raises instead.
range(5)0 1 2 3 4. Starts at 0, stops before 5.range(1, 6)1 2 3 4 5. Explicit start, exclusive stop.range(0, 10, 2)0 2 4 6 8. The third value is the step.range(5, 0, -1)5 4 3 2 1. A negative step counts down.enumerate(xs)Pairs of (index, item), starting at 0.enumerate(xs, start=1)The same, numbered from 1 - what a displayed list wants.zip(a, b)Pairs, stopping at the shorter. Extra items are dropped silently.zip(a, b, strict=True)Raises ValueError on unequal lengths. Python 3.10+, and the safer default.Tip: Use `for _ in range(3):` when you want repetition but not the number. The underscore says "I know there is a value and I am ignoring it".
Do not change a collection while looping over it
A list iterator tracks a position. Remove an item and everything after it shifts down one, but the position does not - so the next item is skipped entirely. The loop finishes without error and the result is quietly wrong.
Removing 2 from [1, 2, 2, 3] leaves [1, 2, 3]: the first 2 is removed, the second shuffles into its place, and the iterator has already moved past that index. One of them survives.
Dictionaries and sets are stricter - changing their size during iteration raises RuntimeError immediately, which is friendlier than the list’s silence. The fix for all of them is the same: build a new collection, or iterate over a copy.
Removing from a listSkips items silently. No error, wrong answer - the worst combination.Appending to a listThe loop keeps going into the new items, often forever.Changing a dict sizeRuntimeError: dictionary changed size during iteration. At least it tells you.Changing a set sizeThe same RuntimeError.Build a new collectionThe fix. Filter into a fresh list rather than editing in place.Iterate over a copyfor x in items[:] or list(d.items()) - fine for small collections.The accumulator, and the built-in that replaces it
Start with an empty total, add to it each time round, use it afterwards. That shape - initialise, accumulate, use - turns up constantly: totals, counts, maximums, building a new list.
It is worth writing by hand once for each, because that is how you understand what sum, max, min and len actually do. After that, use the built-in: it is shorter, faster, and cannot be got wrong.
The one to be careful with is finding a maximum by hand. Starting from 0 breaks on all-negative data; start from the first element instead, or just call max().
Tip: Building a list with append inside a loop is the pattern that list comprehensions replace - that is lesson 21. Recognising the shape now makes comprehensions obvious later.
Syntax and examples
for course in ["Python", "React", "AWS"]: # list
print(course)
for color in ("red", "green"): # tuple
print(color)
for character in "Py": # string - one character at a time
print(character)
for role in sorted({"admin", "teacher"}): # set - sort it if order matters
print(role)
for number in range(3): # range
print(number)user = {"name": "Chandu", "role": "admin", "active": True}
for key in user: # keys - iterating a dict gives keys
print(key)
for value in user.values(): # values
print(value)
for key, value in user.items(): # both, unpacked in the header
print(f"{key}: {value}")
# name: Chandu
# role: admin
# active: Truefor n in range(5): print(n, end=" ") # 0 1 2 3 4
print()
for n in range(1, 6): print(n, end=" ") # 1 2 3 4 5
print()
for n in range(0, 10, 2): print(n, end=" ") # 0 2 4 6 8
print()
for n in range(5, 0, -1): print(n, end=" ") # 5 4 3 2 1
print()
# range generates numbers as needed - it does not build a list
numbers = range(1_000_000)
print(numbers) # range(0, 1000000) - costs almost nothing
print(list(range(5))) # [0, 1, 2, 3, 4] - only now is a list built
# Repetition where the number is irrelevant
for _ in range(3):
print("Hello")lessons = ["Introduction", "Variables", "Data Types"]
# The manual way - three chances to get it wrong
for index in range(len(lessons)):
print(index, lessons[index])
# The same thing, said properly
for index, lesson in enumerate(lessons):
print(index, lesson)
# And numbered for a human, starting at 1
for number, lesson in enumerate(lessons, start=1):
print(f"{number}. {lesson}")
# 1. Introduction
# 2. Variables
# 3. Data Typesstudents = ["Ravi", "Anu", "Priya"]
scores = [85, 92, 78]
for student, score in zip(students, scores):
print(f"{student}: {score}")
# More than two works the same way
grades = ["B", "A", "C"]
for student, score, grade in zip(students, scores, grades):
print(student, score, grade)
# Unequal lengths stop at the shortest - silently
short = [85, 92]
print(list(zip(students, short))) # Priya is simply dropped
# When they should match, say so and let it fail loudly (Python 3.10+)
try:
list(zip(students, short, strict=True))
except ValueError as error:
print("caught:", error)
# To keep the extras instead, pad them
from itertools import zip_longest
print(list(zip_longest(students, short, fillvalue=0)))prices = [100, 200, 50, 75]
total = 0 # initialise
for price in prices:
total += price # accumulate
print(total) # 425 - use
print(sum(prices)) # 425 - what you would actually write
scores = [80, 45, 90, 30, 75]
passing = 0
for score in scores:
if score >= 50:
passing += 1
print(passing) # 3
print(sum(1 for s in scores if s >= 50)) # 3
# Finding a maximum - note the starting value
numbers = [-10, -25, -7]
largest = numbers[0] # NOT 0, or all-negative data breaks it
for number in numbers:
if number > largest:
largest = number
print(largest, max(numbers)) # -7 -7
# Building a new list - the shape comprehensions will replace
squares = []
for number in [1, 2, 3, 4]:
squares.append(number ** 2)
print(squares) # [1, 4, 9, 16]# The iterator holds a position; removing shifts everything after it down,
# so the next item is stepped over completely.
numbers = [1, 2, 2, 3]
for number in numbers:
if number == 2:
numbers.remove(number)
print(numbers) # [1, 2, 3] <- one 2 survived, and nothing errored
# Build a new list instead
numbers = [1, 2, 2, 3]
kept = []
for number in numbers:
if number != 2:
kept.append(number)
print(kept) # [1, 3]
# A dict is stricter - it tells you
user = {"a": 1, "b": 2}
try:
for key in user:
del user[key]
except RuntimeError as error:
print("caught:", error) # dictionary changed size during iteration
# Iterating a copy is safe
user = {"a": 1, "b": 2}
for key in list(user):
del user[key]
print(user) # {}courses = {
"Python": ["Variables", "Loops"],
"React": ["Components", "Hooks"],
}
for course, lessons in courses.items():
print(course)
for lesson in lessons: # the inner loop runs fully each time
print(" -", lesson)
# Filtering a list of records, the way API data arrives
users = [
{"name": "Ravi", "role": "student"},
{"name": "Anu", "role": "teacher"},
{"name": "Priya", "role": "student"},
]
for user in users:
if user["role"] == "teacher":
print(user["name"]) # Anu
# Rows unpack straight into names
rows = [["Ravi", 80], ["Anu", 92]]
for name, score in rows:
print(f"{name} scored {score}")Watch out: The loop variable outlives the loop. After `for user in users:` finishes, user still holds the last item - which is a subtle source of bugs when a later line uses it by accident.
Loop helpers
range(stop)Numbers from 0 up to but not including stop. Generates as it goes - never a list.
range(5) # 0..4
range(start, stop, step)Full form. A negative step counts down.
range(5, 0, -1)
enumerate(xs, start=0)Pairs of index and item. start=1 for a numbered display list.
for i, x in enumerate(xs, 1):
zip(a, b)Walks several iterables together, stopping at the shortest.
for a, b in zip(xs, ys):
zip(a, b, strict=True)Raises when lengths differ rather than truncating. Python 3.10+.
zip(xs, ys, strict=True)
zip_longest(a, b)From itertools - pads the short one instead of stopping.
zip_longest(xs, ys, fillvalue=0)
sorted(xs)A sorted list, for when a set or dict needs a reliable order.
for r in sorted(roles):
reversed(xs)The same items backwards, without building a copy.
for x in reversed(xs):
Loop by hand, or use the built-in
Write each by hand once to understand it, then use the built-in - it is shorter, faster, and harder to get wrong.
Totaltotal += x in a loop, or sum(xs).
Count matchingcount += 1 under an if, or sum(1 for x in xs if cond).
Largest / smallestCompare against the first element, or max(xs) / min(xs).
How manyA counter, or len(xs).
Any match / all matchA flag and a break, or any(...) / all(...).
A new transformed listappend in a loop - or a list comprehension, in lesson 21.
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“print(list(range(1,5)), list(range(5)))”
“print(list(zip([1,2,3],["a","b"])))”
“n=[1,2,2,3] for x in n: if x==2: n.remove(x) print(n)”
“for i,c in enumerate("abc", 1): print(i,c)”
What usually goes wrong
It builds an index you then use once to look the item back up. Iterate the collection directly, or use enumerate if you genuinely need the position.
✗ for i in range(len(courses)):
print(courses[i])✓ for course in courses:
print(course)range(1, 5) gives 1, 2, 3, 4. The stop is exclusive, exactly as with slicing - which is what makes range(len(xs)) line up with the valid indexes.
✗ for n in range(1, 5): # expecting 1..5✓ for n in range(1, 6): # 1..5The iterator keeps its position while the list shrinks under it, so the item after each removal is skipped. No error is raised - the answer is just wrong.
✗ for n in numbers:
if n % 2 == 0:
numbers.remove(n)✓ numbers = [n for n in numbers if n % 2]Set order comes from hashing and is not the insertion order. It can differ between runs, so code that happens to work on your machine can print differently elsewhere.
✗ for role in roles:
print(role)✓ for role in sorted(roles):
print(role)zip stops at the shortest and says nothing. If the two lists are supposed to correspond, a missing entry disappears without trace.
✗ for name, score in zip(names, scores):✓ for name, score in zip(names, scores, strict=True):It works until the data is all negative, and then it returns 0 - a value that was never in the list. Start from the first element, or use max().
✗ largest = 0
for n in numbers:
if n > largest: largest = n✓ largest = numbers[0]
for n in numbers:
if n > largest: largest = nprint shows a value and discards it. If anything downstream needs the results, they have to go into a list, or be returned from a function.
✗ for name in names:
print(name.upper())✓ upper = []
for name in names:
upper.append(name.upper())Best practices
- Iterate the collection directly; reach for enumerate only when you need the index.
- Name the loop variable after one item: for student in students, not for x in students.
- Use .items() when you need both key and value from a dict.
- Sort a set before iterating if the output order matters.
- Pass strict=True to zip when the lengths are supposed to match.
- Never add to or remove from the collection you are looping over - build a new one.
- Prefer sum, max, min, any and all over hand-written accumulators.
- Keep the body short; if it grows past a screen, extract a function.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Print the numbers 1 to 10 using range().
Show solutionHide solution
for number in range(1, 11):
print(number)From numbers = [10, 15, 20, 25, 30], print only the even ones.
Show solutionHide solution
numbers = [10, 15, 20, 25, 30]
for number in numbers:
if number % 2 == 0:
print(number)
# 10 20 30Print students = ["Ravi", "Anu", "Priya", "Rahul"] as a numbered list starting at 1.
Show hintHide hint
enumerate takes a start argument.
Show solutionHide solution
students = ["Ravi", "Anu", "Priya", "Rahul"]
for number, student in enumerate(students, start=1):
print(f"{number}. {student}")Print every key and value of {"name": "Ravi", "age": 21, "course": "Python"} as "key: value".
Show solutionHide solution
student = {"name": "Ravi", "age": 21, "course": "Python"}
for key, value in student.items():
print(f"{key}: {value}")Total prices = [100, 250, 50, 75] with a loop, then show the built-in that does the same.
Show solutionHide solution
prices = [100, 250, 50, 75]
total = 0
for price in prices:
total += price
print(total) # 475
print(sum(prices)) # 475Pair students = ["Ravi", "Anu", "Priya"] with scores = [85, 92] so a length mismatch raises rather than passing silently.
Show hintHide hint
zip takes a strict argument in Python 3.10+.
Show solutionHide solution
students = ["Ravi", "Anu", "Priya"]
scores = [85, 92]
try:
for student, score in zip(students, scores, strict=True):
print(student, score)
except ValueError as error:
print("Lengths do not match:", error)Remove every 2 from [1, 2, 2, 3] correctly, and show why doing it in place does not work.
Show solutionHide solution
# Broken - one 2 survives, and nothing warns you
numbers = [1, 2, 2, 3]
for number in numbers:
if number == 2:
numbers.remove(number)
print(numbers) # [1, 2, 3]
# Correct - build a new list
numbers = [1, 2, 2, 3]
kept = []
for number in numbers:
if number != 2:
kept.append(number)
print(kept) # [1, 3]Course progress processor
Take a course with a list of lessons and produce a progress report: every lesson numbered with its status, then the counts and a percentage. Everything displayed must be computed, not typed in.
- Print the course title, then each lesson numbered from 1 with Completed or Incomplete
- Count completed and incomplete lessons in the same pass
- Work out the completion percentage from the counts
- Use enumerate for the numbering rather than a manual counter
- Guard against a course with no lessons, so the percentage does not divide by zero
course = {
"title": "Core Python",
"lessons": [
{"title": "Introduction", "completed": True},
{"title": "Variables", "completed": True},
{"title": "Data Types", "completed": True},
{"title": "Operators", "completed": False},
{"title": "Loops", "completed": False},
],
}
empty_course = {"title": "Empty", "lessons": []}Show one solutionHide solution
def report(course):
lessons = course["lessons"]
print(course["title"])
print()
completed = 0
for number, lesson in enumerate(lessons, start=1):
status = "Completed" if lesson["completed"] else "Incomplete"
print(f"{number}. {lesson['title']} - {status}")
if lesson["completed"]:
completed += 1
total = len(lessons)
incomplete = total - completed
print()
print(f"Completed: {completed}")
print(f"Incomplete: {incomplete}")
# A course with no lessons would otherwise divide by zero
if total:
print(f"Progress: {completed / total:.0%}")
else:
print("Progress: no lessons yet")
course = {
"title": "Core Python",
"lessons": [
{"title": "Introduction", "completed": True},
{"title": "Variables", "completed": True},
{"title": "Data Types", "completed": True},
{"title": "Operators", "completed": False},
{"title": "Loops", "completed": False},
],
}
report(course)
report({"title": "Empty", "lessons": []})
# Core Python
#
# 1. Introduction - Completed
# 2. Variables - Completed
# 3. Data Types - Completed
# 4. Operators - Incomplete
# 5. Loops - Incomplete
#
# Completed: 3
# Incomplete: 2
# Progress: 60%Key points
- A for loop walks an iterable and runs its body once per item.
- Lists, tuples, sets, strings, dicts, files, and ranges are all iterable.
- Iterating a dict gives its keys; use .values() or .items() for the rest.
- Dicts keep insertion order since Python 3.7; sets have no reliable order - sort them if it matters.
- range generates numbers rather than building a list, and its stop value is excluded.
- enumerate gives index and item together, and takes start=1 for display.
- zip walks several iterables together and stops at the shortest - pass strict=True when they should match.
- The accumulator pattern is initialise, accumulate, use - and sum, max, min usually replace it.
- Start a manual maximum from the first element, never from 0.
- Removing from a list while looping over it skips items silently; changing a dict or set raises RuntimeError.
- The fix is to build a new collection or iterate over a copy.
- The loop variable still exists after the loop, holding the last item.
Quick check before you move on
Interview questions
What actually happens when Python runs a for loop?
It calls iter() on the object to get an iterator, then calls next() on that repeatedly, binding each result to the loop variable, until next() raises StopIteration - which the loop catches and treats as the end. That is the whole protocol, and it is why anything implementing __iter__ works with for, including your own classes and generators.
What is the difference between an iterable and an iterator?
An iterable can produce an iterator - it implements __iter__. An iterator is the thing that actually yields values, implementing __next__ and holding the position. A list is iterable but not an iterator: you can loop it twice and each loop gets a fresh iterator. A generator is its own iterator, so looping it a second time yields nothing.
Why is range memory-efficient?
It stores only the start, stop and step, and computes each value on demand. range(1_000_000) is a few dozen bytes, where the equivalent list is megabytes. It also supports len and indexing without materialising anything, which is why range(10**12)[5] is instant.
How would you iterate two collections together when they may differ in length?
Decide what the mismatch means. If it is a bug, zip(strict=True) raises. If the shorter one should be padded, itertools.zip_longest with a fillvalue. If only the overlap matters, plain zip is correct - but say so in a comment, because a reader cannot tell a deliberate truncation from an overlooked one.
Why is a nested loop a performance concern?
The work multiplies: a loop over n inside a loop over m is n times m iterations, so it grows quadratically when both are the same collection. The usual fix is to replace the inner loop with a lookup - build a dict or set from one collection first, turning an O(n squared) scan into an O(n) pass with O(1) checks.
When would you use enumerate over just tracking an index?
Almost always. A manual counter has to be initialised, incremented, and incremented in every branch - miss one continue and it drifts out of step with the data. enumerate cannot drift because the index comes from the iteration itself. Keep a manual counter only when it counts something other than position, such as how many items passed a test.
Quiz
- 1.
What is an iterable?
- 2.
Does range(5) include 5?
- 3.
What is the difference between iterating a dict, .keys(), .values() and .items()?
- 4.
What does enumerate() do, and why prefer it to range(len(...))?
- 5.
What happens when zip() gets iterables of different lengths?
- 6.
Why is modifying a collection while iterating it a problem?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned