← Back to Core Python
Lesson 12 · Operators and Control Flow

for Loops

Iteration, range, enumerate, and looping over collections.

Beginner35 min

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.

Looping over a dict
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.

The three loop helpers
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.

Changing a collection mid-loop
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

Anything iterable
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)
Dictionaries: keys, values, and both
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: True
range, and why it is not a list
for 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")
enumerate beats range(len(...))
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 Types
zip, and the truncation to watch for
students = ["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)))
The accumulator patterns
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]
Changing a list while looping over it
# 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) # {}
Nested loops over real data
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.

Total

total += x in a loop, or sum(xs).

Count matching

count += 1 under an if, or sum(1 for x in xs if cond).

Largest / smallest

Compare against the first element, or max(xs) / min(xs).

How many

A counter, or len(xs).

Any match / all match

A flag and a break, or any(...) / all(...).

A new transformed list

append 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.

The stop is excluded

“print(list(range(1,5)), list(range(5)))”

zip drops the extras

“print(list(zip([1,2,3],["a","b"])))”

Removing while looping

“n=[1,2,2,3] for x in n: if x==2: n.remove(x) print(n)”

enumerate from 1

“for i,c in enumerate("abc", 1): print(i,c)”

What usually goes wrong

Using range(len(...)) when you only need the values

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)
Expecting range to include the stop value

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..5
Removing items from the list you are looping over

The 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]
Assuming a set iterates in a useful order

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)
Letting zip hide a length mismatch

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):
Starting a maximum search at zero

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 = n
Printing when you meant to collect

print 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.

1.

Print the numbers 1 to 10 using range().

Show solution
for number in range(1, 11): print(number)
2.

From numbers = [10, 15, 20, 25, 30], print only the even ones.

Show solution
numbers = [10, 15, 20, 25, 30] for number in numbers: if number % 2 == 0: print(number) # 10 20 30
3.

Print students = ["Ravi", "Anu", "Priya", "Rahul"] as a numbered list starting at 1.

Show hint

enumerate takes a start argument.

Show solution
students = ["Ravi", "Anu", "Priya", "Rahul"] for number, student in enumerate(students, start=1): print(f"{number}. {student}")
4.

Print every key and value of {"name": "Ravi", "age": 21, "course": "Python"} as "key: value".

Show solution
student = {"name": "Ravi", "age": 21, "course": "Python"} for key, value in student.items(): print(f"{key}: {value}")
5.

Total prices = [100, 250, 50, 75] with a loop, then show the built-in that does the same.

Show solution
prices = [100, 250, 50, 75] total = 0 for price in prices: total += price print(total) # 475 print(sum(prices)) # 475
6.

Pair students = ["Ravi", "Anu", "Priya"] with scores = [85, 92] so a length mismatch raises rather than passing silently.

Show hint

zip takes a strict argument in Python 3.10+.

Show 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)
7.

Remove every 2 from [1, 2, 2, 3] correctly, and show why doing it in place does not work.

Show 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]
Coding challenge

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.

It should
  • 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
Start here
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 solution
One 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

What does range(1, 5) produce?
1, 2, 3, 4. The start is included and the stop is excluded, exactly as with slicing.
What does `for key in user:` give you - keys, values, or pairs?
Keys. Use .values() for the values and .items() for both.
What happens with zip(["a","b","c"], [1,2])?
You get two pairs and "c" is dropped, with no warning. zip stops at the shortest iterable. Pass strict=True to raise instead.
Why does removing 2s from [1, 2, 2, 3] in place leave one behind?
The iterator holds a position. Removing the first 2 shifts the second into that index, but the iterator has already moved past it, so that item is never examined.

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. 1.

    What is an iterable?

  2. 2.

    Does range(5) include 5?

  3. 3.

    What is the difference between iterating a dict, .keys(), .values() and .items()?

  4. 4.

    What does enumerate() do, and why prefer it to range(len(...))?

  5. 5.

    What happens when zip() gets iterables of different lengths?

  6. 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...