Skip to content

Control flow

Every program you have written so far runs the same way every time: top to bottom, one line at a time. That works for simple scripts, but real programs need to make decisions and repeat work. A quiz needs to check whether the answer is right. A game needs to keep running until the player wins or loses. This chapter covers how to make your program branch and repeat.

Control flow shapes the execution path of your program. Conditions (if/elif/else) select between branches. Loops (while, for) repeat blocks. Python's for loop is an iterator protocol, not an index-based counter. Understanding both the syntax and the underlying models makes your code cleaner.

Python's control flow primitives are if/elif/else, while, and for. for invokes __iter__ on the iterable and calls next() until StopIteration. while evaluates a condition object via __bool__ or __len__. break and continue affect the nearest enclosing loop; loop-else runs only when the loop exhausts without a break.

Comparisons

Before you can make a decision, you need to compare things. Comparison operators return True or False. The most important one to get right early: = assigns a value, == checks whether two values are equal. Mixing them up is one of the most common beginner mistakes.

Comparison operators call the corresponding dunder methods (__eq__, __lt__, etc.) and return a bool. Python supports chained comparisons: 0 < x < 10 is evaluated as 0 < x and x < 10, not left-to-right like most languages. String comparison is lexicographic, based on Unicode code points.

Comparison operators call rich comparison methods: __eq__, __ne__, __lt__, __le__, __gt__, __ge__. Python supports chained comparisons that short-circuit: a < b < c becomes a < b and b < c, but b is only evaluated once. is checks object identity (id() equality), not value equality; use == for value comparisons and is only for None, True, False.

python
5 > 3     # True
5 < 3     # False
5 == 5    # True   (note: double equals; = is assignment, == is comparison)
5 != 3    # True   ("not equal to")
5 >= 5    # True   ("greater than or equal to")
5 <= 4    # False  ("less than or equal to")

The = vs == distinction trips up almost everyone early on. Assignment (=) stores a value; comparison (==) checks whether two values are the same.

You can compare strings too. Python compares them alphabetically:

python
"apple" == "apple"   # True
"apple" < "banana"   # True  (a comes before b)
"apple" == "Apple"   # False (case-sensitive)

Combining conditions

and, or, and not combine comparisons. and requires both sides to be true. or requires at least one side. not flips the result. These let you express real-world conditions like "score is passing AND user is active".

and and or short-circuit: and stops at the first falsy operand, or stops at the first truthy one. They return the actual operand value, not just True or False. not calls __bool__ on the operand and returns the inverted result.

and evaluates left to right and returns the first falsy operand, or the last operand if all are truthy. or returns the first truthy operand, or the last if all are falsy. This short-circuit behaviour is a guarantee: the right side is not evaluated if the left side determines the result. not x is equivalent to True if not bool(x) else False, but Python optimises it.

python
age   = 25
score = 88

age >= 18 and score >= 80    # True  (both must be true)
age < 18 or score >= 80      # True  (at least one must be true)
not age >= 18                # False (flips the result)

and requires both sides. or requires at least one side. not inverts.

Truthy and falsy

Every value in Python has a boolean interpretation, even if it is not True or False. Empty strings, zero, empty lists, and None all behave like False in a condition. Everything else behaves like True. This means if results: checks whether a list is non-empty without writing if len(results) > 0:.

Python's truthiness rules: falsy values are False, 0, 0.0, "", [], (), {}, set(), and None. Everything else is truthy. Conditions call __bool__ on the object, falling back to __len__ if __bool__ is not defined. An object with a zero-length __len__ is falsy.

Truth testing calls __bool__. If __bool__ is not defined, Python falls back to __len__: an object with __len__ == 0 is falsy. Custom classes control truthiness by implementing either method. The standard falsy values are: False, 0, 0.0, 0j, "", b"", [], (), {}, set(), frozenset(), None, and any object whose __bool__ returns False or __len__ returns 0.

python
# These all behave like False in a condition:
False, 0, 0.0, "", [], {}, (), None

# Everything else behaves like True

This means if results: is a natural way to say "if the list is not empty", and if name: checks whether a string has any content.

if / elif / else

The if statement runs a block of code only when its condition is True. elif adds more conditions to check if the first one was false. else catches everything that did not match any condition. Python uses indentation, not braces, to define what belongs inside each block.

if/elif/else evaluates conditions top to bottom and runs the first matching block. Python uses indentation (4 spaces by convention) to define block scope; inconsistent indentation is a SyntaxError. Only one branch runs: once a condition matches, all subsequent elif and else are skipped.

Python uses indentation as block delimiters, enforced by the parser. The interpreter generates SETUP_BLOCK bytecodes for each branch; exactly one branch executes. elif is syntactic sugar: it avoids the nesting that a chain of bare if statements would create. Each condition is evaluated lazily, only if all preceding conditions were falsy.

python
score = 87

if score >= 90:
    print("A grade")
elif score >= 80:
    print("B grade")
elif score >= 70:
    print("C grade")
else:
    print("Below C")

The rules:

  • if is required and always comes first
  • elif (short for "else if") is optional and you can have as many as you need
  • else is optional, handles everything that did not match, and comes last
  • Python uses indentation (4 spaces) to mark what belongs inside each block; there are no braces

The indentation is not optional or cosmetic. Python uses it to define structure. Inconsistent indentation is a syntax error.

One-line conditions

For simple yes/no assignments, Python has a compact one-line form called a ternary expression: value_if_true if condition else value_if_false. Use it only when the logic is genuinely simple and reads like a sentence.

The conditional expression (ternary operator) evaluates to one of two values based on a condition. It is an expression, not a statement, so it can appear anywhere a value is expected: inside an f-string, as a function argument, in an assignment. Use it for simple yes/no cases; for anything involving elif, write the full version.

The conditional expression x if condition else y is a single expression that evaluates the condition and returns x or y without executing the other branch. It maps to a combination of POP_JUMP_IF_FALSE bytecodes. Unlike if/else blocks, it cannot span multiple statements and cannot contain elif; for complex branching, full if/elif/else blocks are clearer.

python
label = "pass" if score >= 50 else "fail"

This is a ternary expression; it reads like a sentence. Use it when the logic is genuinely simple. For anything involving elif, write the full version.

while loops

A while loop repeats its block as long as its condition is True. Use it when you do not know in advance how many times the loop should run, for example waiting for valid input or retrying until a job succeeds.

while evaluates its condition before each iteration and runs the block only when the condition is truthy. Use it for loops where the exit condition depends on something that changes inside the loop. When the number of iterations is known or you are iterating a collection, for is usually cleaner.

while calls __bool__ (or __len__) on the condition expression before each iteration. The body can modify the condition. while True with an interior break is the idiomatic "loop-until" pattern when the exit condition must be evaluated in the middle or end of the body. Infinite loops missing a break are a common source of hangs.

python
lives = 3

while lives > 0:
    print(f"Lives remaining: {lives}")
    lives -= 1

print("Game over")

while is best when you do not know in advance how many times the loop will run. When you do know, or when you are iterating over a collection, for is cleaner.

break and continue

break exits the loop immediately, no matter how many iterations remain. continue skips the rest of the current iteration and jumps back to the condition check. Both only affect the innermost loop they are inside.

break terminates the nearest enclosing loop, transferring control to the first statement after it. continue skips the remainder of the current loop body and restarts from the condition check (or the next iteration in a for loop). Both only affect the innermost enclosing loop.

break emits a BREAK_LOOP bytecode that exits the loop's code block and skips any associated else clause. continue emits CONTINUE_LOOP (or JUMP_ABSOLUTE depending on context), resuming from the loop header. Both are scoped to the nearest enclosing loop; there is no labelled break in Python. For breaking out of nested loops, use a boolean flag or restructure into a function with return.

break exits the loop immediately:

python
target = 5
num    = 0

while True:
    num += 1
    if num == target:
        print(f"Found {target}")
        break   # stop the loop

while True: with a break is a valid and common pattern when the exit condition is complex or needs to happen at the end of the loop body.

continue skips the rest of the current iteration and goes back to the condition check:

python
num = 0

while num < 10:
    num += 1
    if num % 2 == 0:
        continue    # skip even numbers
    print(num)      # only odd numbers print: 1, 3, 5, 7, 9

for loops

A for loop goes through a sequence one item at a time: a list, a string, a range of numbers. The variable you name after for receives each item in turn. You do not manage a counter or check the length yourself.

for invokes iter() on the iterable to get an iterator, then calls next() on it until StopIteration. This means for works on anything that implements the iterator protocol: lists, strings, dicts, files, ranges, and custom objects. It is not limited to indexed sequences.

for target in iterable calls iter(iterable) to get an iterator object, then repeatedly calls next(iterator) and binds the result to target until StopIteration is raised. The for loop catches StopIteration internally. Any object implementing __iter__ (or both __iter__ and __next__) is iterable. This includes lazy objects like generators and file objects.

python
players = ["Alice", "Bob", "Charlie"]

for player in players:
    print(f"Hello, {player}!")

for loops also work on strings (iterating character by character) and on any other sequence type.

range()

range() generates a sequence of numbers for you to loop over. range(5) gives you 0, 1, 2, 3, 4. You can control the start, end, and step size. Use it when you need a loop to run a specific number of times.

range(start, stop, step) produces integers from start up to (but not including) stop, stepping by step. It is a lazy sequence: it does not create a list, it generates numbers on demand. This makes range(10_000_000) memory-efficient. All three forms accept negative arguments for reverse counting.

range is a type, not a function. range(n) creates a range object that computes membership and indexing in O(1) without materialising the sequence. It supports len(), in, slicing, and reversed iteration. Internally it stores only start, stop, and step. Prefer it to list(range(n)) when you only need iteration.

python
for i in range(5):
    print(i)    # 0, 1, 2, 3, 4

range() has three forms:

CallWhat it produces
range(5)0, 1, 2, 3, 4
range(2, 6)2, 3, 4, 5
range(0, 10, 2)0, 2, 4, 6, 8 (step of 2)
range(5, 0, -1)5, 4, 3, 2, 1 (counting down)

range() does not create a list. It produces numbers one at a time, which is efficient even for very large ranges.

enumerate()

enumerate() gives you both the index and the value while you loop, so you do not need to track a counter separately. The i, player part automatically receives a pair of values on each iteration.

enumerate(iterable, start=0) wraps any iterator and yields (index, value) tuples. The start parameter offsets the counter but does not change the underlying index. Prefer enumerate() over managing a counter variable; it is cleaner and less error-prone.

enumerate wraps any iterator in an enumerate object that yields (count, value) pairs. The start argument sets the initial counter value. Unpacking in the for header (for i, v in enumerate(...)) works because each yielded item is a tuple. enumerate is O(1) per step with no extra memory allocation beyond the counter.

python
players = ["Alice", "Bob", "Charlie"]

for i, player in enumerate(players):
    print(f"{i + 1}. {player}")
# 1. Alice
# 2. Bob
# 3. Charlie

The i, player syntax is called unpacking. Python splits the (index, value) pair into two names automatically.

By default enumerate() starts at 0. Pass a start value to change that:

python
for i, player in enumerate(players, start=1):
    print(f"{i}. {player}")    # starts at 1

Nested loops

You can put a loop inside another loop. The inner loop runs fully for each single iteration of the outer loop. This is how you process grids, combinations, or any data with two levels of structure.

Nested loops have an O(m × n) iteration count for outer length m and inner length n. break and continue inside a nested loop only affect the innermost loop. For breaking out of multiple levels, use a flag variable or restructure into a function.

Each for loop call creates a new iterator object. Nested loops compose their iterators independently. break only exits the innermost loop; there is no labelled break in Python. The common workaround is a flag variable or encapsulating the inner loop in a function and using return. For Cartesian products, itertools.product is more readable than nested for loops.

python
rows = [1, 2, 3]
cols = ["A", "B"]

for row in rows:
    for col in cols:
        print(f"{col}{row}", end=" ")
    print()   # newline after each row
# A1 B1
# A2 B2
# A3 B3

break and continue inside a nested loop only affect the innermost loop.

Loop-else

Python loops can have an else clause that runs only if the loop finished without hitting a break. It is not commonly used, but it is the cleanest way to write "search a list, and if nothing was found, do this".

The else on a for or while loop executes if the loop completes normally (exhausts the iterable or condition becomes false) without hitting a break. It is the idiomatic pattern for "search and report if not found" without needing a separate found-flag variable.

Loop else is controlled by the BREAK_LOOP bytecode: if a break fires, the else block's setup code is not entered. If the loop exhausts, the else block runs. This is semantically different from a plain else on an if. The main practical use is the search-with-break pattern; outside that, it is rarely seen and can confuse readers unfamiliar with it.

python
target = "Dave"
names  = ["Alice", "Bob", "Charlie"]

for name in names:
    if name == target:
        print(f"Found {target}")
        break
else:
    print(f"{target} not in list")   # runs because break never fired

If break runs, the else is skipped. If the loop exhausts the sequence, else runs. It is a niche pattern but cleaner than a flag variable.

Sorting

sorted() returns a new sorted list and leaves the original unchanged. .sort() sorts the list in place and returns None. The key= argument lets you sort by something other than the raw value. For example, sorting names case-insensitively or sorting player tuples by their score.

sorted() is the safe default: it never modifies the original. .sort() modifies in place and returns None. Both accept reverse=True for descending order. The key= argument takes a function applied to each element before comparison. This separates the sorting criterion from the data.

Both use Timsort: O(n log n) worst case, O(n) on nearly sorted data, stable. .sort() returns None deliberately (command-query separation). The key= function is called once per element, not once per comparison, so expensive key computations are not repeated. key=str.lower is an unbound method reference; key=lambda p: p[1] is an inline function for accessing a specific field.

python
scores = [87, 42, 96, 55, 71]

ranked = sorted(scores)           # [42, 55, 71, 87, 96] (new list)
scores.sort()                     # sorts the original list, returns None
scores.sort(reverse=True)         # [96, 87, 71, 55, 42]

Both accept a key= argument: a function applied to each item before comparison:

python
names = ["Charlie", "Alice", "Bob"]
sorted(names, key=str.lower)       # case-insensitive sort

players = [("Alice", 87), ("Bob", 96), ("Charlie", 55)]
sorted(players, key=lambda p: p[1])   # sort by score

What's a lambda?

lambda p: p[1] is a one-line function. It takes a player tuple and returns the score. Lambda functions are covered in the Lambda, comprehensions, and zip chapter.

For simple cases, use sorted(). For lists where you want to modify in place, use .sort().

In practice

Loop through scores, accumulate a total, count passing grades, and print a summary:

python
raw_scores = [87, 42, 96, 55, 71, 63]

total   = 0
passing = 0

for score in raw_scores:
    total += score
    if score >= 60:
        passing += 1

average = total / len(raw_scores)
print(f"Average: {average:.1f}")
print(f"Passing: {passing}/{len(raw_scores)}")
print(f"Top score: {sorted(raw_scores, reverse=True)[0]}")

Process a list of files in sorted order, skip ones that are too large, and report how many were skipped:

python
files = [
    {"name": "report_jan.csv", "size_mb": 12},
    {"name": "report_feb.csv", "size_mb": 850},
    {"name": "report_mar.csv", "size_mb": 7},
]

MAX_SIZE = 100
skipped  = 0

for f in sorted(files, key=lambda x: x["name"]):
    if f["size_mb"] > MAX_SIZE:
        print(f"Skipping {f['name']} ({f['size_mb']} MB, too large)")
        skipped += 1
    else:
        print(f"Processing {f['name']}...")

print(f"\nDone. {skipped} file(s) skipped.")

Scan a request log for errors, then use a retry loop that exits on success or when the attempt limit is hit:

python
requests = [
    {"method": "GET",  "path": "/users",  "status": 200},
    {"method": "POST", "path": "/users",  "status": 201},
    {"method": "GET",  "path": "/broken", "status": 500},
]

errors = []

for req in requests:
    if req["status"] >= 400:
        errors.append(req)

if errors:
    print(f"{len(errors)} error(s) in request log:")
    for err in errors:
        print(f"  {err['method']} {err['path']} -> {err['status']}")
else:
    print("All requests succeeded")

attempts    = 0
max_retries = 3
success     = False

while attempts < max_retries and not success:
    attempts += 1
    print(f"Attempt {attempts}...")
    success = attempts >= 2   # simulate success on second try

print("Connected" if success else "Failed after all retries")