= ['potato','pumpkin']
vegetables
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:
= ['potato','pumpkin']
vegetables
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:
v
v
The 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:
= ['potato','pumpkin']
vegetables = ['boil','fry']
method
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.
range
In 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):
**2)
squares.append(nprint(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:
sorted
We 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:
= [9,3,2]
xs 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:
= [9,3,2]
xs 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}")
# Modifies xs in-place
xs.sort() 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:
= {'a':1, 'b':2}
d 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:
= {'a':1, 'b':2}
d 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:
= {'a':1, 'b':2}
d 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.
= [1,3,4,6,2]
xs 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:
= [-1,1,3,-5]
xs 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:
= 42
x 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 = 10
N for n in range(2,N+1):
= [p for p in primes if n%p == 0]
divisors 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 primes
primes = [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 emptyprimes
primes = [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 primes
primes
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 = 10
N for n in range(2,N+1):
= any(n%p==0 for p in primes)
is_composite 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 primes
primes = [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 False
primes
primes = [2, 3]
When n = 4:
primes
contains [2, 3]
is_composite = any(4%p==0 for p in primes)
starts checking divisorsany()
immediately returns True
is_composite
is True
, 4 is not added to primes
primes
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 < 10
break
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 = 10
N for n in range(2,N+1):
= n**0.5
sqrt_n = False
is_composite for p in primes:
if p > sqrt_n:
break
if n%p == 0:
= True
is_composite 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 numbers
2
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 >= 3
0
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
.
= 2025
number = 0
digits while number > 0:
//= 10
number += 1
digits 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:
= 2025
number print(f"Starting number: {number}")
= number // 10 # 2025 ÷ 10 = 202.5 → 202 (removes 5)
number print(f"After first //10: {number}")
= number // 10 # 202 ÷ 10 = 20.2 → 20 (removes 2)
number print(f"After second //10: {number}")
= number // 10 # 20 ÷ 10 = 2.0 → 2 (removes 0)
number print(f"After third //10: {number}")
= number // 10 # 2 ÷ 10 = 0.2 → 0 (removes 2)
number 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:
= 1
n while n != 0:
+= 1 n
This 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:
= [1, 2, 3]
numbers = 0
i while i < len(numbers):
if numbers[i] == 2:
2) # Adds to the list we're iterating over!
numbers.append(+= 1 i
This creates an infinite loop because:
i=1
, we find numbers[1] == 2
numbers
, 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:
= [(0,0), (1,1), (2,0), (0,2)]
points for point in points:
match point:
0, y):
case (print(f"Point on y-axis at y={y}")
0):
case (x, 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:
= 5
counter while counter > 0:
print(counter)
-= 1 counter
What 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.