vegetables = ['potato','pumpkin']
for v in vegetables:
print(v)potato
pumpkin
Control structures are fundamental building blocks in Python that determine the flow of program execution. They allow you to make decisions (using if statements), repeat actions (using loops), and create sophisticated branching logic (using match statements). This chapter explores these essential structures, showing how they can be combined to create powerful and flexible programs. We’ll start with loops, which are crucial for performing repetitive tasks efficiently.
for loopsOne of the most fundamental ways to repeat actions in Python is by using a for loop. A for loop systematically goes through each element of a sequence (like a list, tuple, or other iterable) and performs operations on each element in turn. This structure is particularly appropriate when you know, in advance, how many times you need to perform the repetition or when you simply want to process all elements in a collection.
When you use a for loop, you specify a loop variable that successively takes on the values in your collection. Each time the loop variable is assigned a new value, the block of code inside the loop executes. This repeated execution lets you, for instance, print each item in a list, modify a series of elements, or combine different objects in all possible ways.
Below is an example illustrating how a for loop can be applied to iterate through a list of vegetables:
vegetables = ['potato','pumpkin']
for v in vegetables:
print(v)potato
pumpkin
Let’s walk through how this code executes step by step. First, Python creates a list vegetables containing two strings. The for statement (the loop header) establishes that we’ll iterate through this list, using v as our loop variable. The indented print(v) forms the loop body - the code that will run for each item.
When execution begins, Python:
vvThe indentation is crucial here - it tells Python exactly which statements should be repeated as part of the loop body. Without proper indentation, Python wouldn’t know which code belongs to the loop.
Python also permits the nesting of loops. A nested loop is a for loop that appears inside another for loop. This approach is helpful for scenarios where you need to pair or combine every element in one list with every element in another, or more generally, when multiple dimensions of iteration are required. Below is an example:
vegetables = ['potato','pumpkin']
method = ['boil','fry']
for m in method:
for v in vegetables:
print(f"How to {m} {v}")How to boil potato
How to boil pumpkin
How to fry potato
How to fry pumpkin
Let’s examine how this nested structure executes. The deeper indentation of the inner loop’s body shows it belongs to both loops - it runs for each combination of method and vegetable. Here’s the step-by-step execution:
m = "boil"
v = "potato" → prints “How to boil potato”v = "pumpkin" → prints “How to boil pumpkin”m = "fry"
v = "potato" → prints “How to fry potato”v = "pumpkin" → prints “How to fry pumpkin”The deeper indentation level clearly shows which code is part of the inner loop, making it run 4 times total (2 methods × 2 vegetables).
Python’s for loops work smoothly with iterables. An iterable is any Python object that can return its elements one at a time. Lists, tuples, dictionaries, ranges, and more are all iterable. Whenever you see a for statement, you can often substitute any iterable in place of a list.
rangeIn many repetitive tasks, we might not have a predefined list but still need to iterate a specific number of times or generate a sequence of integers. Python’s built-in range function is perfect for this scenario. The range function returns a sequence of integers that can be iterated over. By default, it starts at 0 and ends one value before its argument. Below is an example:
squares = []
for n in range(11):
squares.append(n**2)
print(squares)[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Here, range(11) generates the integers 0, 1, 2, …, 10. In each loop iteration, the variable n is assigned one of those integers, and the code block inside calculates the square of n and appends it to the list squares. By the end of the loop, squares contains the squares of the integers 0 through 10.
The range function actually accepts up to three arguments:
range(stop): Generates numbers from 0 up to (but not including) stoprange(start, stop): Generates numbers from start up to (but not including) stoprange(start, stop, step): Generates numbers from start up to stop, counting by stepHere are examples of each:
# Count down from 10 to 1
for n in range(10, 0, -1):
print(n, end=' ')
print() # New line
# Count by 2s from 1 to 10
for n in range(1, 11, 2):
print(n, end=' ')
print()
# Start at 5, go up to (but not including) 10
for n in range(5, 10):
print(n, end=' ')
print()10 9 8 7 6 5 4 3 2 1
1 3 5 7 9
5 6 7 8 9
The step argument is particularly useful for tasks like:
sortedWe sometimes wish to loop through the elements of a collection in sorted order. The sorted function returns a list of the elements in ascending order, leaving the original collection unchanged. When combined with a for loop, it makes your code straightforward for tasks where ordering is essential:
xs = [9,3,2]
for x in sorted(xs):
print(x)2
3
9
Although [9,3,2] is initially unsorted, the sorted function changes its order to [2,3,9]. The loop then processes these sorted values one by one.
The key difference is that sorted(xs) creates a new sorted sequence while leaving xs unchanged, whereas xs.sort() modifies xs directly. You can also sort in reverse order by adding the reverse=True argument:
xs = [9,3,2]
print(f"Original xs: {xs}")
print(f"sorted(xs): {sorted(xs)}")
print(f"sorted(xs, reverse=True): {sorted(xs, reverse=True)}")
print(f"xs is still: {xs}")
xs.sort() # Modifies xs in-place
print(f"After xs.sort(): {xs}")Original xs: [9, 3, 2]
sorted(xs): [2, 3, 9]
sorted(xs, reverse=True): [9, 3, 2]
xs is still: [9, 3, 2]
After xs.sort(): [2, 3, 9]
Dictionaries are another example of an iterable, but when you loop over a dictionary directly, Python iterates through its keys. Keys are the identifiers you used when creating each key-value pair in the dictionary:
d = {'a':1, 'b':2}
for x in d:
print(x)a
b
Since d is {'a':1, 'b':2}, Python automatically retrieves the keys "a" and "b". The code inside the loop prints each key on its own line. This iteration ignores the dictionary’s values.
You can also iterate over both keys and values by using the .items() method, which returns a collection of (key, value) pairs. This is particularly helpful in many data processing tasks where you need the key to address a specific entry and the value to do further computations or checks:
d = {'a':1, 'b':2}
for k,v in d.items():
print(f"The key {k} has value {v}")The key a has value 1
The key b has value 2
This loop retrieves each pair (key, value) from d. The loop variable k is assigned the key, while v is assigned the corresponding value. The print statement then displays a string describing that relationship.
You can also iterate over just the values using the .values() method:
d = {'a':1, 'b':2}
for v in d.values():
print(f"Found value {v}")Found value 1
Found value 2
This loop retrieves only the values (1 and 2) from the dictionary, ignoring the keys completely. This is useful when you need to process the values but don’t need their associated keys.
if statementWhile loops handle repetition, the if statement controls conditional branching, meaning it chooses whether certain code should execute based on a condition. The most basic form is the single if, which checks a condition. If the condition is true, the code indented beneath the if runs; otherwise, Python skips it entirely.
xs = [1,3,4,6,2]
for x in xs:
if x > 2:
print(x)3
4
6
This snippet goes through each element in xs. Within the loop, the if x > 2 condition tests whether x exceeds 2. If so, print(x) is executed; if not, it is skipped. As a result, the output will be the subset of numbers from xs that are larger than 2.
Situations often arise that require more nuance than just a single condition. For example, you may need to do one thing if a condition is true, and do another thing otherwise. That’s where if and else combine:
xs = [-1,1,3,-5]
for x in xs:
if x>=0:
print(x)
else:
print(-x)1
1
3
5
The condition x >= 0 tests if the number is non-negative. If this condition is satisfied, the number is printed as is. Otherwise, Python executes the else part and prints -x, thus flipping negative numbers to positive ones.
Beyond simple if-else branching, Python provides the elif (else if) statement for handling multiple conditions in sequence. When you have several possible conditions to check, elif lets you chain them together clearly:
x = 42
if x < 0:
print("Negative")
elif x == 0:
print("Zero")
elif x < 100:
print("Small positive")
else:
print("Large positive")Small positive
Python checks these conditions in order: 1. First the if condition 2. Then each elif condition in sequence 3. Finally the else if no previous condition was true
Only the code block under the first true condition runs - once a condition is true, Python skips all remaining checks.
FizzBuzz is a classic programming problem that tests your understanding of conditional logic. The problem statement is:
Write a program that prints numbers from 1 to N. But for multiples of 3, print “Fizz” instead of the number, and for multiples of 5, print “Buzz”. For numbers that are multiples of both 3 and 5, print “FizzBuzz”.
This problem tests understanding of:
Here’s a solution using the if-elif-else construct:
for n in range(21): # Numbers 0 through 20
if n%3 == 0 and n%5 == 0: # Check for FizzBuzz first!
print("FizzBuzz")
elif n%3 == 0: # Then check for Fizz
print("Fizz")
elif n%5 == 0: # Then check for Buzz
print("Buzz")
else: # If none of the above, print the number
print(n)Let’s break down how this works:
% gives us the remainder after divisionn%3 == 0 tests if n is divisible by 3The order of these checks is critical for correct behavior. If we were to put the “Fizz” check first, numbers like 15 (which should print “FizzBuzz”) would only print “Fizz”. This happens because 15 is divisible by 3, so it would match the first condition and skip all subsequent checks. Similarly, putting the “Buzz” check first would cause 15 to incorrectly print “Buzz”. This illustrates a common principle in programming: when conditions can overlap, always check the most specific case first (numbers divisible by both 3 and 5) before checking more general cases (numbers divisible by just 3 or just 5).
A prime number is a number greater than 1 that has no positive divisors other than 1 and itself. Generating prime numbers is an illustrative exercise to show how looping and condition checks can be combined. Here’s a first attempt at finding prime numbers up to a chosen limit N:
primes = []
N = 10
for n in range(2,N+1):
divisors = [p for p in primes if n%p == 0]
if not divisors:
primes.append(n)
print(primes)[2, 3, 5, 7]
Let’s walk through how this code works for the first few numbers:
When n = 2:
primes is empty []divisors = [p for p in primes if 2%p == 0] creates an empty list since there are no primes to checkdivisors is empty, 2 is added to primesprimes = [2]When n = 3:
primes contains [2]divisors = [p for p in primes if 3%p == 0] checks if 3 is divisible by 2divisors remains emptyprimesprimes = [2, 3]When n = 4:
primes contains [2, 3]divisors = [p for p in primes if 4%p == 0] checks if 4 is divisible by 2 or 3divisors becomes [2]divisors is not empty, 4 is not added to primesprimes remains [2, 3]This approach works because any composite number must have at least one prime factor smaller than itself. By building our list of primes incrementally, we only need to check division by previously found primes.
While this code works, it has a significant inefficiency: it creates a complete list of all divisors before making a decision. This means even after finding one divisor that proves a number is composite, it continues checking all remaining primes unnecessarily. For example, when checking if 12 is prime, after finding it’s divisible by 2, there’s no need to check if it’s divisible by 3.
We can improve this using Python’s any() function, which stops checking as soon as it finds a True value:
primes = []
N = 10
for n in range(2,N+1):
is_composite = any(n%p==0 for p in primes)
if not is_composite:
primes.append(n)
print(primes)[2, 3, 5, 7]
The any() function takes an iterable and returns True if any element is True. In our solution, we use a generator expression n%p==0 for p in primes - a memory-efficient way to create an iterable. When a generator expression is the only argument to a function like any(), all(), or sum(), Python allows us to omit the extra parentheses that would normally be required. Unlike a list comprehension which uses square brackets [...] and creates the entire list at once, a generator expression normally uses parentheses (...) and generates values one at a time as needed. This is particularly efficient because:
False only after checking all elementsLet’s walk through how this improved solution works:
When n = 2:
primes is empty []is_composite = any(2%p==0 for p in primes) evaluates to False because there are no primes to checkis_composite is False, 2 is added to primesprimes = [2]When n = 3:
primes contains [2]is_composite = any(3%p==0 for p in primes) checks if 3 is divisible by 2is_composite is Falseprimesprimes = [2, 3]When n = 4:
primes contains [2, 3]is_composite = any(4%p==0 for p in primes) starts checking divisorsany() immediately returns Trueis_composite is True, 4 is not added to primesprimes remains [2, 3]The key improvement is that any() stops as soon as it finds a divisor, making the code more efficient than the previous version which always checked all primes.
Here are some simple examples of any() and its complement all():
# any() examples
any([False, False, True, False]) # Returns True, stops at first True
any([]) # Returns False for empty iterables
any(x > 5 for x in [1,2,3,4]) # Returns False, no number > 5
# all() examples
all([True, True, True]) # Returns True
all([True, False, True]) # Returns False, stops at False
all(x < 10 for x in [1,2,3]) # Returns True, all numbers < 10break statementThe break statement provides a way to exit a loop prematurely. It is particularly valuable when you already have the result you want or you find it unnecessary to continue further. In prime detection, you can stop checking as soon as you realize n is divisible by a smaller prime or once your prime factor checks exceed \(\sqrt{n}\).
This \(\sqrt{n}\) optimization is based on a fundamental property of composite numbers: if a number \(n\) is composite, it must have at least one prime factor less than or equal to its square root. Here’s why:
primes = []
N = 10
for n in range(2,N+1):
sqrt_n = n**0.5
is_composite = False
for p in primes:
if p > sqrt_n:
break
if n%p == 0:
is_composite = True
break
if not is_composite:
primes.append(n)
print(primes)[2, 3, 5, 7]
When the loop encounters p > sqrt_n, there is no need to look at larger primes because if none smaller than or equal to \(\sqrt{n}\) divides n, none beyond \(\sqrt{n}\) will either. This speeds up the search for primes.
continue statementWhile break finishes a loop altogether, continue stops processing the current loop iteration and moves on to the next one. It is a useful tool when you want to skip certain values without exiting the entire loop. For instance, in some filtering tasks, you might choose to continue and skip the remaining statements of the loop body if a condition is not met.
Here’s a simple example that prints only even numbers from 1 to 5:
for n in range(1, 6):
if n % 2 != 0: # if n is odd
continue # skip to next iteration
print(n) # only reached for even numbers2
4
When n is odd, the continue statement skips the rest of the loop body (the print statement) and moves to the next number. This results in printing only the even numbers 2 and 4.
pass statementThe pass statement is a null operation - it does nothing. It’s used as a placeholder when a statement is syntactically required but you don’t want any code to execute. This is particularly useful when:
Here’s a simple example using pass in an if statement:
for n in range(5):
if n < 3:
print(n)
else:
pass # Do nothing for n >= 30
1
2
While the else part of the if statement is optional, here the pass statement makes it clear that we intentionally want to do nothing in the else block. Without pass, but with else an empty block would cause a syntax error.
while loopsA while loop keeps running as long as a specified condition remains true. This control structure is ideal when the number of iterations cannot be easily determined beforehand or depends on changing conditions.
The following code counts the number of decimal digits in the number 2025.
number = 2025
digits = 0
while number > 0:
number //= 10
digits += 1
print(digits)In this example, the code initializes the variable number to 2025 and digits to 0. Then, as long as number exceeds 0, the loop executes.
The // operator performs integer division, always returning a whole number by discarding any decimal part. When dividing by 10, this effectively removes the rightmost digit. Let’s see how this works step by step:
number = 2025
print(f"Starting number: {number}")
number = number // 10 # 2025 ÷ 10 = 202.5 → 202 (removes 5)
print(f"After first //10: {number}")
number = number // 10 # 202 ÷ 10 = 20.2 → 20 (removes 2)
print(f"After second //10: {number}")
number = number // 10 # 20 ÷ 10 = 2.0 → 2 (removes 0)
print(f"After third //10: {number}")
number = number // 10 # 2 ÷ 10 = 0.2 → 0 (removes 2)
print(f"After fourth //10: {number}")Starting number: 2025
After first //10: 202
After second //10: 20
After third //10: 2
After fourth //10: 0
Each pass uses // to divide number by 10 and keep only the quotient, which removes the rightmost digit, and increments digits by 1. When number finally becomes 0, the loop’s condition no longer holds, causing the loop to terminate. The final value of digits (4) equals the number of times we had to divide by 10 to reach 0, which is the number of digits in 2025.
Sometimes bugs in our code can create infinite loops - loops that never terminate. Here’s a common example:
n = 1
while n != 0:
n += 1This loop will never end because n starts at 1 and keeps increasing - it will never equal 0. If you accidentally run such code, you’ll need to know how to stop it:
In a terminal/command prompt press Ctrl+C
In a Jupyter notebook:
Here’s another subtle example that looks correct but creates an infinite loop:
numbers = [1, 2, 3]
i = 0
while i < len(numbers):
if numbers[i] == 2:
numbers.append(2) # Adds to the list we're iterating over!
i += 1This creates an infinite loop because:
i=1, we find numbers[1] == 2numbers, making it [1, 2, 3, 4]i < len(numbers) is still trueTo fix this, you should avoid modifying a collection while iterating over it. Instead, create a new list or use a different approach.
match statementThe match statement, introduced in Python 3.10, is a powerful pattern matching feature that provides a more elegant way to express complex conditional logic. While similar to switch/case statements in other languages, Python’s match is more sophisticated, allowing pattern matching against data structures and multiple values simultaneously.
Let’s start with a simple example that demonstrates basic pattern matching:
for n in [0,1,5]:
match n:
case 0:
print("zero")
case 1:
print("one")
case _:
print("something else")zero
one
something else
Let’s break down the syntax and execution of this example:
The match statement starts with the keyword match followed by the value to match against (n in this case)
Each case line defines a pattern to match:
case 0: matches exactly the value 0case 1: matches exactly the value 1case _: is a special pattern that matches anything (like a default case)The indented code under each case runs when that pattern matches
When n=0:
case 0: patternprint("zero")When n=5:
case 0:case 1:case _: matches any valueprint("something else")The match statement can also handle multiple patterns in a single case using |:
for n in [0,1,3,6]:
match n:
case 0:
print("zero")
case 1 | 2:
print("small")
case 3 | 4 | 5:
print("medium")
case _:
print("large")zero
small
medium
large
One of the most powerful features of match is its ability to match against tuples or other structured patterns. This makes it particularly elegant for problems that involve multiple conditions. The FizzBuzz problem is a perfect example of this capability:
for n in range(21):
match n%3, n%5:
case 0,0:
print("FizzBuzz")
case 0,_:
print("Fizz")
case _,0:
print("Buzz")
case _:
print(n)FizzBuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
This FizzBuzz implementation showcases how match can elegantly handle multiple conditions. The expression n%3, n%5 creates a tuple of remainders, and each case pattern matches different combinations:
case 0,0: matches when both remainders are 0 (number divisible by both 3 and 5)case 0,_: matches when only the first remainder is 0 (divisible by 3)case _,0: matches when only the second remainder is 0 (divisible by 5)case _: matches anything elseThe underscore _ acts as a wildcard pattern that matches any value. The order of cases is important - more specific patterns should come before more general ones. In the FizzBuzz example, we check for numbers divisible by both 3 and 5 first, before checking for numbers divisible by just one of them.
The match statement can also capture values from patterns using variable names. Here’s an example using a list of coordinates:
points = [(0,0), (1,1), (2,0), (0,2)]
for point in points:
match point:
case (0, y):
print(f"Point on y-axis at y={y}")
case (x, 0):
print(f"Point on x-axis at x={x}")
case (x, y):
print(f"Point at x={x}, y={y}")Point on y-axis at y=0
Point at x=1, y=1
Point on x-axis at x=2
Point on y-axis at y=2
In this example, the variables x and y capture the actual values from the tuple being matched. When a coordinate like (0,2) matches the pattern (0, y), the value 2 is captured in the variable y. This allows us to use the captured values in our code.
While this example shows pattern matching with numbers, match can work with many other Python data types including strings, lists, and custom objects. This makes it a powerful tool for writing clear, maintainable code that handles complex conditional logic.
Looping with for
Consider the first five natural numbers 1 through 5. Write a for loop that prints the square of each number. Create a list [1, 2, 3, 4, 5] first, and then write a loop that calculates and prints each square. Explain why the loop ends after printing the square of 5.
Combining if and for
Suppose you have the list xs = [10, -3, 7, -1, 5]. Use a for loop to check each number in turn. Print the number only if it is positive. Next, modify your code so that if the number is negative, the code prints its absolute value and if it is non-negative, it simply prints the number. Reflect on how the if and else statements guide which code is run.
Testing while conditions
Look at the following fragment:
counter = 5
while counter > 0:
print(counter)
counter -= 1What is printed and why does the loop stop when it does? What changes, if any, occur if you switch the condition from while counter > 0: to while counter != 0:?
Exploring break
Write a loop that prints the numbers from 1 to 10. However, include a break statement that stops the loop when the number 7 is reached. Notice how none of the numbers greater than 7 get printed. Remove the break statement and see how the behavior changes.
Nested Loops
Consider two lists of numbers: [1, 2, 3] and [4, 5]. Print out every possible pair you can form with an element from the first list and an element from the second. Clarify how the outer loop goes through its elements and, for each element in the outer loop, the inner loop completes a full pass through the other list.
match usage
Write a small snippet that iterates over the list [0, 1, 2, 3, 4]. Use a match statement on each number n to do the following: print “zero” if it is 0, print “small” if it is 1 or 2, print “medium” if it is 3, and print “large” otherwise. Make sure to include a default case (using _) and think carefully about the order in which each case appears.
By developing a thorough understanding of for loops, while loops, conditionals like if and elif, and additional enhancements such as break, continue, and match, you build the foundation for sophisticated data processing and logic in Python. These control structures allow your programs to respond differently based on changing data and to repeat tasks as needed, making them indispensable for robust, flexible code. Once you master them, you will be better equipped to tackle more advanced operations, such as iterating over large datasets and implementing complex decision-making logic in your programs.