<< Home

Chapter 6: Flow Control

Every program is a story told through execution. Flow control determines how that story unfolds—what happens first, what happens next, and under what conditions different paths are taken. It defines the logic that drives computation from one line of code to another. Without flow control, a program would simply run from top to bottom without choice, repetition, or direction.

All programming languages provide ways to shape this flow. The most common are conditional statements (which choose between alternatives), loops (which repeat actions while a condition remains true), and control keywords (which alter or exit those structures). Together, they give programs their rhythm and responsiveness, allowing them to make decisions, react to data, and adapt to changing circumstances.

Python’s approach to flow control is clear and minimal. Where some languages use punctuation or braces, Python uses indentation to group related code blocks. This makes the structure of decisions and loops visible at a glance, and enforces consistency that prevents many common errors. The syntax reads almost like natural language, with keywords such as if, for, and while forming the backbone of logical expression.

This chapter explores using if, elif, and else to make decisions based on conditions, controlling repetition with for and while structures, and using break, continue, and pass to manage loop execution precisely. Also covered are expressing loops and conditional logic compactly in a single expression, and understanding how try, except, and finally provide structured handling of unexpected events. Together, these features let you write programs that think and react. Flow control is the difference between static code (instructions that merely exist) and living logic (instructions that decide).

Conditional constructs

Conditionals allow a program to make choices. They control which blocks of code run based on whether an expression evaluates to true or false. In Python, conditional logic is expressed with the keywords if, elif, and else. Each introduces a block of code whose execution depends on a Boolean test. This pattern is one of the cornerstones of all programming: it lets your code respond to data rather than follow a single fixed path.

The simplest form uses only if. If the condition evaluates to true, the indented block that follows is executed; otherwise, it is skipped.

temperature = 30

if temperature > 25:
  print("It’s warm today.")

If the condition is false, nothing happens and execution continues after the block. You can extend the structure with an else clause to provide an alternative branch that runs when the condition is not met.

temperature = 18

if temperature >= 25:
  print("It’s warm.")
else:
  print("It’s cool.")

Python uses indentation to group statements, so there are no braces or keywords to mark where the blocks begin or end. Consistent indentation (two spaces in this book for narrow-screen readability) is essential, since it defines the structure of your logic directly.

elif chains

When you need to test multiple conditions in sequence, use elif (short for “else if”). Each test runs only if all previous conditions were false. The first condition that evaluates to true decides which block executes, and the rest are ignored.

score = 72

if score >= 90:
  grade = "A"
elif score >= 80:
  grade = "B"
elif score >= 70:
  grade = "C"
else:
  grade = "D"

print("Grade:", grade)

This structure reads naturally, and Python’s indentation makes the branching hierarchy visually clear. Only one block in the chain will ever run, even if later conditions would also be true.

Truth values and implicit tests

Any expression can serve as a condition, not just comparisons. Python determines truth or falsity using its truthiness rules. Nonzero numbers, nonempty sequences, and most objects evaluate as true; zero, empty collections, and None evaluate as false. This allows concise, readable conditions.

name = ""

if not name:
  print("Name is missing.")

Here, the empty string counts as false, so the condition succeeds. You can use the same logic with lists, dictionaries, or any other container type.

Nesting and readability

Conditionals can be nested to handle more complex logic. However, deep nesting quickly reduces clarity, so it’s often better to combine conditions or use elif chains instead.

user = "admin"
logged_in = True

if logged_in:
  if user == "admin":
    print("Access granted.")

This can be written more clearly as:

if logged_in and user == "admin":
  print("Access granted.")
💡 Prefer combining related conditions with logical operators (and, or, not) instead of nesting multiple if statements. This keeps your control flow shallow and expressive.

Python encourages clarity over cleverness. Simple, flat logic is easier to read and maintain, especially when the indentation itself represents program structure.

Ternary expressions

Sometimes you need to choose between two values within a single line of code rather than writing a full if statement. Python provides a compact syntax for this called a conditional expression, often referred to as a ternary expression because it involves three parts: a value if the condition is true, the condition itself, and a value if the condition is false.

status = "open" if is_active else "closed"

This expression reads naturally: if is_active is true, status becomes "open"; otherwise, it becomes "closed". It performs the same logical test as a full if/else block, but it produces a value instead of controlling a block of statements.

# equivalent long form
if is_active:
  status = "open"
else:
  status = "closed"
⚠️ In some other languages such as JavaScript and C, the ternary operator uses the syntax condition ? true_value : false_value. Python’s version reverses the order for readability (true_value if condition else false_value) and treats it as an expression rather than a separate operator. This makes it read more like plain English.

Conditional expressions are especially useful inside assignments, return statements, and function arguments, where you want to select one of two results based on a simple condition without breaking the flow of the surrounding code.

def absolute_value(x):
  return x if x >= 0 else -x

print(absolute_value(-5)) # 5

The structure of a ternary expression is:

value_if_true if condition else value_if_false

Only one of the two values is evaluated at runtime, depending on whether the condition is true or false. This makes it efficient and safe even when the expressions involve function calls or computations.

def get_data():
  print("Fetching data...")
  return [1, 2, 3]

use_cache = True
data = ["cached"] if use_cache else get_data()
print(data)  # ['cached']

Here, get_data() is never called because the condition is true, so the expression short-circuits exactly like a normal if statement would.

⚠️ Avoid using nested or complex ternary expressions, as they quickly become difficult to read. When logic involves more than a simple choice between two outcomes, use a normal if/elif/else block instead.

Pattern matching

Python 3.10 introduced structural pattern matching, a powerful new way to handle conditional logic. It extends the idea of if/elif branching by allowing entire data structures to be compared against patterns, not just single values. The match and case keywords together form a control structure that can dispatch logic based on both the shape and content of an object.

This idea is similar to switch statements found in languages such as C or JavaScript, but Python’s version is far more expressive. It lets you match against values, types, sequence layouts, and even partial structures. It is particularly useful for processing data that arrives in structured forms such as tuples, dictionaries, or objects.

Basic pattern matching

A simple match block checks a single subject value against multiple case patterns. The first pattern that matches is executed, and the rest are skipped.

command = "start"

match command:
  case "start":
    print("System starting...")
  case "stop":
    print("System stopping...")
  case "pause":
    print("System pausing...")
  case _:
    print("Unknown command.")

The underscore (_) acts as a wildcard pattern that matches anything, similar to a default branch in other languages. This ensures that all possibilities are handled, even unexpected ones.

Matching structures

Patterns can look inside compound objects such as tuples or lists. The number and arrangement of elements must match for the pattern to succeed.

point = (3, 4)

match point:
  case (0, 0):
    print("Origin")
  case (x, 0):
    print(f"On the x-axis at {x}")
  case (0, y):
    print(f"On the y-axis at {y}")
  case (x, y):
    print(f"Point at ({x}, {y})")

Here, variables like x and y capture corresponding values from the matched structure. Each successful match can both test and unpack data in a single step, reducing the need for nested if statements or separate assignments.

⚠️ Pattern matching does not replace all uses of if/elif logic. For simple comparisons or short decisions, ordinary conditionals are clearer. Use match when you are selecting behaviour based on structured data, not just a single variable.

Guards (conditional matches)

You can refine a pattern with an additional condition by using the if keyword after a case. This is known as a guard, and it allows more specific tests once a pattern shape has matched.

number = 7

match number:
  case n if n < 0:
    print("Negative")
  case n if n == 0:
    print("Zero")
  case n if n % 2 == 0:
    print("Even")
  case _:
    print("Odd")

Guards are evaluated only if the pattern itself matches. This gives precise control over flow while keeping code compact and readable.

Matching data types and classes

Pattern matching can also inspect class instances by matching on their type and attributes. This works with any class that defines a suitable __match_args__ sequence or uses keyword patterns.

class Point:
  def __init__(self, x, y):
    self.x = x
    self.y = y

p = Point(1, 2)

match p:
  case Point(x=0, y=0):
    print("Origin")
  case Point(x, y):
    print(f"Coordinates: {x}, {y}")
  case _:
    print("Not a point")

When used carefully, pattern matching can replace long chains of if/elif statements with clear, data-driven logic. It becomes especially powerful when parsing structured input, responding to messages, or analysing trees of nested data such as JSON.

💡 The underscore pattern (_) is special as it matches any value but does not bind it to a name. Always include it as a final case when a match may encounter unexpected input.

Loop control and keywords

Loops let a program repeat actions, but real programs often need to adjust how those loops behave. Python provides three simple keywords (pass, continue, and break) to give fine control over loop execution. These are small tools, but they make iteration predictable and expressive.

pass

pass is the simplest of the three. It does nothing at all. It exists as a placeholder for code that will be written later, or for a branch that intentionally performs no action. Python requires at least one statement inside an indented block, so pass is often used to satisfy that rule without changing behaviour.

for item in []:
  pass # nothing to process yet

You can also use pass in function or class definitions when you want to outline structure before filling in details.

def placeholder_function():
  pass

class Empty:
  pass

Because pass executes cleanly and leaves no trace, it is useful during early development or when defining minimal blocks for later expansion.

continue

continue skips the rest of the current loop iteration and moves directly to the next cycle. Any statements that follow it within the loop body are ignored for that iteration.

for number in range(6):
  if number % 2 == 0:
    continue
  print(number)

This loop prints only the odd numbers because continue causes Python to skip printing when the number is even. The loop itself keeps running, starting the next iteration immediately after the skip.

continue works the same way in while loops, letting you bypass certain cases without ending the loop entirely.

n = 0
while n < 5:
  n += 1
  if n == 3:
    continue
  print(n)

break

break exits the current loop entirely, regardless of where the loop counter stands. Control passes to the first statement following the loop.

for number in range(10):
  if number == 5:
    break
  print(number)

Here, the loop stops as soon as number reaches five, printing only values 0 through 4. Once a break executes, the loop condition is no longer checked.

In nested loops, break only exits the innermost one. If you need to exit multiple levels, structure the code so that the outer loop’s condition also becomes false or raise an exception to signal a stop.

Using else with loops

Python allows a unique but often overlooked feature: an else clause attached to a loop. The else block runs only if the loop finishes normally without hitting a break statement.

for n in range(5):
  if n == 3:
    break
else:
  print("Loop completed without break.")

In this example, the else block is skipped because the loop was terminated by break. This construct is most useful when searching for a value. If the loop ends without finding it, the else section can handle the “not found” case.

for item in [1, 2, 3]:
  if item == 4:
    print("Found it.")
    break
else:
  print("Not found.")
💡 Use break for early exit when the goal of a loop has been reached, continue to skip specific cases, and pass when a block must exist syntactically but should do nothing.

These three small keywords give you precision control over repetition. They allow loops to remain simple yet flexible, letting your code handle both expected and exceptional situations clearly and directly.

Basic exception handling

Even well-written programs encounter problems; files may be missing, input may be invalid, or a calculation may fail. In Python, such situations raise exceptions, special objects that signal that something went wrong during execution. If not handled, an exception stops the program immediately and prints a traceback describing the error. To prevent abrupt termination, Python provides structured tools to detect and respond to these conditions cleanly.

The try and except structure

The simplest way to handle an exception is with a try and except block. The statements inside the try section are executed normally. If an error occurs, control jumps to the except block instead of halting the program.

try:
  result = 10 / 0
  print("This line will not run.")
except ZeroDivisionError:
  print("Cannot divide by zero.")

When the division by zero triggers an exception, the program continues gracefully inside the except section. Without this structure, the program would have crashed. Only exceptions that match the named type (in this case ZeroDivisionError) are caught; others continue to propagate upward.

Catching multiple exceptions

You can handle several possible error types either by listing them in parentheses or by stacking multiple except blocks. This lets you respond appropriately to different failure modes.

try:
  value = int(input("Enter a number: "))
  result = 10 / value
except (ValueError, ZeroDivisionError):
  print("Invalid input or division by zero.")

Alternatively, you can handle each case separately:

try:
  value = int(input("Enter a number: "))
  result = 10 / value
except ValueError:
  print("You must enter a valid number.")
except ZeroDivisionError:
  print("You cannot divide by zero.")

This structure keeps your logic clear and targeted. Each except block addresses a specific risk, improving both readability and maintainability.

Using else and finally

A try block can include optional else and finally clauses. The else section runs only if no exception was raised, while finally always runs, whether or not an error occurred. These are useful for code that should execute after a safe operation, or for cleanup tasks that must happen regardless of outcome.

try:
  f = open("data.txt")
  content = f.read()
except FileNotFoundError:
  print("File not found.")
else:
  print("File read successfully.")
finally:
  f.close()

In this example, finally ensures that the file is closed even if an error happens. This guarantees that external resources such as files or network connections are released properly.

General exception catching

To catch any kind of exception, you can use a bare except clause, though this should be reserved for situations where any error must be caught. More commonly, the built-in Exception base class is used, which covers most runtime errors without intercepting system-level events.

try:
  risky_operation()
except Exception as e:
  print("Something went wrong:", e)

Here, e holds the exception object itself, allowing you to print or log details about what failed. Catching all exceptions can be convenient, but it can also hide unexpected problems, so use it sparingly.

⚠️ Avoid using a bare except: without specifying the type. It can catch system signals and interrupt events, making programs harder to stop or debug. Always catch the narrowest category of errors that makes sense.

Why exception handling matters

Exception handling separates normal logic from error recovery, keeping code readable and predictable. It lets you deal with problems as part of the program’s flow instead of as catastrophic failures. This preview shows only the basics. Later in this book we will explore Python’s full exception model; how exceptions are defined, raised, chained, and managed in complex systems.

💡 Use exceptions for exceptional situations, not for normal control flow. Reserve them for cases where ordinary conditional logic cannot easily describe the problem.

Handled properly, exceptions turn fragile programs into resilient ones. They allow software to fail gracefully, communicate clearly, and continue operating safely under unexpected conditions.