Chapter 7: Looping Constructs
In every programming language, looping is how we make the computer repeat work. It’s the bridge between static logic and dynamic behaviour. Where conditionals let a program make choices, loops let it persist by cycling through data, retrying tasks, or progressing until some condition is met. Without looping, every computation would end after a single step.
Python’s looping mechanisms are concise, expressive, and integrated tightly with the language’s object model. Instead of relying on counters and manual iteration, Python treats looping as a conversation between an iterable object and a loop construct. This design makes loops both powerful and readable, reflecting Python’s guiding philosophy that code should be clear about what it is doing, not just how.
Two primary forms of looping exist in Python: the for loop, which iterates directly over sequences and other iterable objects, and the while loop, which continues as long as a given condition remains true. Both use indentation to define their controlled blocks and can be combined with optional else clauses for post-loop actions. Supporting statements such as break, continue, and pass provide fine-grained control over loop execution.
In this chapter we explore these constructs in depth: how iteration works under the hood, how Python’s iterator protocol makes loops flexible and memory-efficient, and how to write clear looping logic that avoids off-by-one errors and infinite cycles. We’ll also look at practical examples, from processing lists and dictionaries to reading files and using comprehensions as compact looping expressions.
for loops don’t need explicit counters unless you want them. They iterate directly over data, which makes most loops cleaner and less error-prone than traditional for–index patterns found in languages like C or Java.
for and while loops
Python provides two main looping constructs: the for loop and the while loop. Between them they cover nearly every kind of repetition a program might need. The for loop is typically used when you want to iterate over a sequence or other iterable object. The while loop is used when you want to repeat an action until a condition changes.
The for loop
The for loop is one of Python’s most distinctive features. Unlike many languages that use loops controlled by counters, Python’s for loop iterates directly over the items of an iterable object such as a list, tuple, string, dictionary, or range. This design makes loops easier to read and avoids many off-by-one errors common in other languages.
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
This loop reads as if written in plain English: “for each fruit in fruits, print it.” The variable fruit takes each value from the iterable in turn until the sequence is exhausted.
Any object that supports iteration can be used in a for loop. This includes ranges, which are efficient representations of integer sequences:
for i in range(3):
print("Iteration", i)
The range() function produces integers starting from zero by default. You can supply a start, stop, and step value as in range(start, stop, step) to control iteration precisely:
for n in range(2, 10, 2):
print(n)
When looping over dictionaries, the for statement iterates through keys by default. You can use the .items() method to access both keys and values together:
person = {"name": "Ada", "age": 36}
for key, value in person.items():
print(key, "→", value)
for loops are powered by the iterator protocol. Under the surface, Python calls iter() on the object, then repeatedly calls next() until a StopIteration exception signals the end. Understanding this mechanism helps explain why loops work with lists, files, generators, and many other objects.
The while loop
The while loop repeats a block of code as long as its condition remains true. It is ideal when you don’t know in advance how many iterations are needed, or when you want to keep looping until some external event occurs.
count = 0
while count < 3:
print("Count:", count)
count += 1
Each time the condition is checked, and if it is still true, the loop body runs again. When the condition becomes false, control moves to the next statement after the loop. If the condition never becomes false, the loop will run forever, so care must be taken to ensure that something inside the loop changes the state being tested. The condition does not need to be a simple comparison. Any expression that evaluates to a Boolean value can be used. For example, this loop continues until the user provides an empty input:
while True:
text = input("Enter text (or press Enter to quit): ")
if not text:
break
print("You entered:", text)
while loop never becomes false. Always make sure that something inside the loop modifies the variables or state being tested. Using break can also provide a clean way to exit when a specific condition is met.
Both for and while loops can include an optional else block that runs only if the loop completes normally (without hitting a break). This is a unique Python feature that can simplify certain patterns, such as searching for an item:
for n in range(5):
if n == 3:
print("Found it!")
break
else:
print("Not found")
In this example, the else block runs only if the loop ends without a break. Although not common, it can make intent clear when a loop is performing a search or check.
These two loop types form the basis of all repetition in Python. The for loop handles most cases elegantly by working directly with iterables, while the while loop remains the flexible fallback for open-ended repetition based on conditions. Together they give Python its clear, natural rhythm of iteration and control.
range() and iteration patterns
The range() function is one of Python’s most widely used tools for creating sequences of integers to drive iteration. Although it looks simple, it is implemented in a way that is both memory-efficient and flexible. A range object represents a sequence of numbers without storing them all in memory. Instead, it generates each value on demand as the loop progresses.
for i in range(5):
print(i)
This example prints numbers from 0 up to, but not including, 5. By default, range() starts at 0 and increases by 1 each time. You can also supply explicit start and step values:
for i in range(2, 10, 2):
print(i)
The above produces 2, 4, 6, and 8. Negative steps work too, allowing you to count backwards:
for i in range(10, 0, -3):
print(i)
Because range produces an immutable sequence type, it can also be converted to a list or tuple when you need to see all its values at once:
nums = list(range(5))
print(nums) # [0, 1, 2, 3, 4]
range is evaluated lazily. It calculates values only when needed, which means even a very large range such as range(1_000_000) uses almost no memory until iterated.
Enumerating items
When you need both the index and the value of each item in a sequence, the built-in enumerate() function provides a clean solution. Instead of managing a manual counter, enumerate() wraps any iterable and yields pairs of index and item.
fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits):
print(index, fruit)
You can also specify a starting index with a second argument:
for index, fruit in enumerate(fruits, start=1):
print(index, fruit)
This pattern is clearer and safer than incrementing a counter variable inside the loop, since Python automatically handles the index progression.
Looping over multiple sequences
Sometimes you need to iterate over two or more sequences in parallel. The zip() function combines multiple iterables into tuples, yielding one element from each at a time. The iteration stops when the shortest sequence is exhausted.
names = ["Ada", "Grace", "Linus"]
languages = ["Python", "C", "Linux"]
for name, project in zip(names, languages):
print(name, "→", project)
If the sequences differ in length, the extra elements in the longer ones are ignored. For strict pairing where unequal lengths should raise an error, use itertools.zip_longest() from the standard library.
Iterating in reverse or sorted order
Two built-in functions make it easy to control iteration order. reversed() returns an iterator that yields items in reverse sequence, and sorted() produces a new list of items in ascending or custom order.
for n in reversed(range(3)):
print(n)
letters = ["b", "c", "a"]
for l in sorted(letters):
print(l)
Both of these functions leave the original sequence unchanged. reversed() works on any object that supports the sequence protocol, while sorted() can take a key argument to customise sorting logic.
Comprehension loops
Many loop patterns can be expressed more succinctly using comprehensions. A list comprehension creates a new list by looping through an iterable and applying an expression to each element.
numbers = [1, 2, 3, 4]
squares = [n * n for n in numbers]
print(squares) # [1, 4, 9, 16]
Comprehensions are not just shorthand—they are also slightly faster because they run in C-level code internally. The same concept applies to set and dictionary comprehensions, and to generator expressions that produce values lazily.
for loop is often easier to read and maintain.
Loop else clauses
Python’s for and while loops both support an optional else clause. This feature is unique to Python and often surprises those coming from other languages. The else block runs only if the loop finishes normally, that is, without encountering a break statement. It does not run when the loop exits early due to break, an exception, or program termination.
for n in range(5):
if n == 3:
print("Found it!")
break
else:
print("Not found")
In this example, the else block would only execute if the loop completed all iterations without the break. Because n == 3 triggers the break, the else block is skipped. This pattern is especially useful when searching for something within a collection and needing to know afterward whether it was found.
The same logic applies to while loops:
count = 0
while count < 3:
if count == 2:
print("Stopping early")
break
count += 1
else:
print("Loop completed normally")
Here, the else runs only if the condition becomes false without hitting a break. If break executes, the else is skipped. This distinction lets you write clear, single-loop structures that combine iteration and post-check logic without additional flags or variables.
else as an “if not broken” clause. It runs when the loop ends naturally and skips when the loop is interrupted by break.
Although it can make code more concise, the loop else is best used in situations where the behaviour is clearly understood. Overusing it in complex loops can make logic harder to follow. When used with purpose (especially in search patterns or validation checks) it keeps code elegant and eliminates the need for additional state tracking.
names = ["Ada", "Grace", "Linus"]
for name in names:
if name == "Turing":
print("Found Turing!")
break
else:
print("Turing not found")
Here the loop runs through every name. Because no break occurs, the else executes and reports that “Turing not found.” This simple structure communicates intent cleanly and is a hallmark of Python’s design: readable logic expressed in natural order.
Iteration best practices
Good looping style in Python is about clarity and intention. Because the language offers many ways to express repetition, it helps to choose patterns that reveal meaning at a glance. The goal is always to make loops readable, efficient, and free from unnecessary state or side effects.
Iterate over data, not indices
Whenever possible, loop directly over the items you need rather than using numeric indices. This avoids manual bookkeeping and makes the loop easier to read.
# Preferred
for fruit in fruits:
print(fruit)
# Avoid unless you really need the index
for i in range(len(fruits)):
print(fruits[i])
If both index and value are required, use enumerate() rather than managing a counter variable yourself.
Use comprehensions for simple transformations
List, set, and dictionary comprehensions are compact forms of iteration that build new collections in a single readable expression. They should be used when the logic fits neatly into one line.
squares = [n * n for n in range(10)]
even_squares = [n for n in squares if n % 2 == 0]
For more complex logic or multiple nested conditions, a traditional loop is usually clearer.
Avoid modifying a collection while iterating
Changing a list or dictionary as you iterate over it can cause skipped items or unexpected behaviour. Instead, create a new collection or iterate over a copy.
# Problematic
for n in numbers:
if n % 2 == 0:
numbers.remove(n)
# Safer
numbers = [n for n in numbers if n % 2 != 0]
Prefer built-in functions over manual loops
Many looping tasks can be expressed more clearly using built-ins such as sum(), any(), all(), max(), and min(). These communicate intent directly and often run faster because they are implemented in C.
# Instead of this
total = 0
for n in numbers:
total += n
# Use this
total = sum(numbers)
Be explicit with termination
Always make it clear when and why a loop ends. Avoid deeply nested if and break combinations if a clearer condition or early return would do. When using while, ensure that the loop variable or state changes in a predictable way to prevent infinite loops.
Choose readability over cleverness
Python rewards expressive but straightforward looping. Avoid overly condensed expressions or obscure iterator tricks when a simple loop is clearer. Idiomatic Python code prioritises communication—loops should read almost like plain English descriptions of what the program is doing.
Following these guidelines keeps loops predictable, maintainable, and aligned with Python’s philosophy of readability and clarity. With practice, iteration becomes not just a means of repetition but a rhythm of thought that shapes elegant, expressive programs.