A for loop is the workhorse of Python, and the difference between a loop that reads cleanly and one that fights you comes down to three decisions: what you iterate over, whether you need the position, and what you do when the loop ends early. This lesson covers the mechanics of range(), the two helper functions that make loops pleasant, and the off-by-one habits worth unlearning.
How does a for loop work?
Underneath, a for loop asks the object it was given for an iterator and then pulls one value at a time until the iterator is exhausted. Any object that supports that protocol can be looped over: lists, tuples, strings, dictionaries, sets, files, and anything you write yourself.
languages = ['Python', 'Rust', 'Go']
for language in languages:
print(f'I write {language}')
print('the loop variable survives the loop ->', language)
for character in 'Tutorsbot':
print(character, end=' ')
print()
Two side effects of that mechanism are worth knowing. The loop variable is still bound to its last value once the loop finishes, which is occasionally useful and occasionally a trap if the loop never ran at all. And the loop variable does not have to be unique: assigning to it inside the body simply overwrites it, so using a name like i inside a nested loop will clobber the outer one.
One rule saves a lot of debugging: do not modify the sequence you are looping over. Python iterates a list by tracking an index, so removing an item shifts the remaining elements left and the loop can skip one, while appending to the list can keep the loop going far longer than you expect. If you need to filter, build a new list with a comprehension, or collect the items you want into a separate list and act on it afterwards.
How do I use range() correctly?
range() generates a sequence of numbers. It takes a start, a stop and a step, and the start is inclusive while the stop is exclusive. That single rule explains every surprise people have with it: range(1, 5) is 1, 2, 3, 4 and never 5, because the stop value marks where the range stops rather than a value it includes.
print('range(5) ->', list(range(5)))
print('range(2, 6) ->', list(range(2, 6)))
print('range(0, 10, 2) ->', list(range(0, 10, 2)))
print('range(5, 0, -1) ->', list(range(5, 0, -1)))
print('len of a big range ->', len(range(1000000)))
squares = range(1, 6)
print('type of range ->', type(squares).__name__)
print('sum of squares ->', sum(number * number for number in squares))
Since Python 3, range() does not build a list. It is a small object that remembers its start, stop and step and produces values on demand, so a range covering a million numbers costs almost nothing in memory. That is why range(1000000) is safe in a loop while list(range(1000000)) allocates eight megabytes.
A negative step counts backwards, and the same exclusive-stop rule applies, which is why range(5, 0, -1) ends at 1 and does not include 0. If you want the last element too, write range(len(items) - 1, -1, -1).
Finally, a note on choosing between for and while. Use for whenever you are walking through a known collection of things, because the loop variable, the termination condition and the step are all handled for you. Use while only when the number of iterations genuinely depends on something computed as you go, such as retrying an operation until it succeeds. That case gets its own lesson next.
One more habit worth forming early: let the loop read as a sentence about the data rather than about the mechanics. for customer in customers says what you are doing; for i in range(len(customers)) says how you are counting. The first version survives a change in the underlying data structure, and it keeps working if somebody later hands you a generator instead of a list.
Why should I use enumerate() instead of counting by hand?
Manual counters work right up until the loop grows a continue, and then they are off by one. enumerate() hands you the position and the value together, so there is no counter to maintain and no way for it to drift.
items = ['keyboard', 'monitor', 'cable']
for index, item in enumerate(items):
print(f'{index}: {item}')
print()
for number, item in enumerate(items, start=1):
print(f'{number}. {item}')
print()
# The hand-rolled version, which must be updated by hand in every branch.
index = 0
for item in items:
if item == 'monitor':
break
index += 1
print('manual count before the break ->', index)
print('enumerate says the same thing ->', items.index('monitor'))
The start argument is what makes enumerate() useful for human-facing output, where numbering from one is the convention. The same function also works with any iterable, including a dictionary, where it numbers the keys.
The deeper habit here is to let the language supply what you need. Python has a function for getting the index, a function for pairing two sequences, and a function for reversing an iterable. Reaching for them is faster than reimplementing them and far harder to get wrong.
What does zip() do, and what is strict=True for?
zip() walks several iterables in parallel and yields tuples of their corresponding items. It stops when the shortest one runs out, which quietly discards extra data.
names = ['Ada', 'Grace', 'Alan']
scores = [98, 95, 91]
for name, score in zip(names, scores):
print(f'{name:<8}{score:>4}')
print()
print('zip stops at the shortest ->', list(zip(names, scores, [1, 2])))
try:
list(zip(names, [1, 2], strict=True))
except ValueError as error:
print('strict=True complained ->', error)
strict=True, available since Python 3.10, turns a silent truncation into an error. When the iterables have different lengths, zip raises ValueError instead of quietly dropping the extras. That single keyword turns a data-alignment bug that would otherwise show up as a mysterious empty field into an immediate, obvious failure.
When you are working with dictionaries, items() gives you the same pairing in a tidier form, and sorted() accepts a key function for ordering by something other than the default.
stock = {'apples': 12, 'pears': 7, 'plums': 30}
for fruit, count in stock.items():
print(f'{fruit:<8}{count:>4}')
print()
print('sorted by name ->', sorted(stock))
print('sorted by count ->', sorted(stock.items(), key=lambda pair: pair[1], reverse=True))
print('reversed ->', list(reversed(['a', 'b', 'c'])))
Your turn: build an index table
- Write a script with a list of task names and a matching list of owners, and print a numbered table using
enumerate(names, start=1)andzip()together so each line shows the number, the task and the owner. - Add a check with
zip(..., strict=True)and then deliberately shorten one of the lists. Read theValueErrorand explain in a comment why you would rather see it than silently lose a row. - Replace the manual counter in your first draft with
enumerate()and confirm the output is identical. - Loop over a dictionary of stock levels with
items()and print only the items below a threshold, usingifinside the loop rather than filtering the dictionary first. - Finish with a
forloop overrange(1, 6)that prints the first five multiples of a number you choose. Check that it stops before the sixth multiple, and explain the exclusive stop in a comment.
Loop recipes
- A
forloop pulls one value at a time from any iterable, and the loop variable keeps its last value afterwards. range()has an inclusive start and an exclusive stop, supports a negative step, and does not build a list in memory.- Use
enumerate()instead of a hand-rolled counter, and passstart=1for human-facing numbering. zip()pairs sequences and stops at the shortest; passstrict=Trueto turn truncation into an error.- Use
items()for dictionaries,sorted(key=...)for custom ordering, andreversed()to walk backwards.