Module 3.3

Advanced Control Flow

Think of list comprehensions as Python's superpower for one-liner transformations. Instead of writing 5 lines to filter and transform data, you write 1 elegant line. Combined with generators for memory efficiency and the walrus operator for inline assignments, you will write code that makes other programmers say "wait, you can do that?" Let's unlock these Pythonic superpowers!

45 min
Intermediate
Hands-on
What You'll Learn
  • List comprehensions
  • Dict and set comprehensions
  • Generator expressions
  • Walrus operator (:=)
  • Break, continue, else clauses
Contents
01

List Comprehensions

Imagine you have a list of numbers and want to get only the even ones, doubled. With traditional code, you write a loop, an if statement, and an append. With list comprehensions, you do it in one readable line! It is Python's way of saying "transform this collection elegantly."

Key Concept

List Comprehension Anatomy

A list comprehension follows this pattern: [expression for item in iterable if condition]. Think of it as a compact factory: the iterable feeds items, the condition filters them, and the expression transforms them into the output.

Breaking it down:

  • expression: What to do with each item (the output) - like x * 2 or x.upper()
  • for item in iterable: The source data to loop through - like a list, range, or string
  • if condition: (Optional) Filter to include only items that pass this test
When to use: Use comprehensions when you want to create a new list by transforming or filtering an existing collection. If your logic is more complex (multiple statements, try/except, etc.), use a regular loop instead.
Visual: List Comprehension Anatomy
[
x * 2
for x in numbers
if x % 2 == 0
]
OUTPUT

x * 2

"Double each value"
ITERATION

for x in numbers

"Loop through list"
FILTER

if x % 2 == 0

"Keep only evens"
Reading Order: 2 for x in numbers 3 if x % 2 == 0 1 x * 2

Basic List Comprehension

# Traditional approach: 4 lines
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens_doubled = []
for x in numbers:
    if x % 2 == 0:
        evens_doubled.append(x * 2)
print(evens_doubled)  # Output: [4, 8, 12, 16, 20]

# List comprehension: 1 line!
evens_doubled = [x * 2 for x in numbers if x % 2 == 0]
print(evens_doubled)  # Output: [4, 8, 12, 16, 20]

Step-by-step comparison:

Traditional approach (4 lines):

  1. Create an empty list evens_doubled = []
  2. Loop through each number with for x in numbers
  3. Check if even with if x % 2 == 0
  4. Transform and add with evens_doubled.append(x * 2)

Comprehension approach (1 line):

  • x * 2: The expression that transforms each item (doubles it)
  • for x in numbers: Iterates through the source list
  • if x % 2 == 0: Filters to keep only even numbers
  • Result: A new list with transformed, filtered values
Reading order: When reading a comprehension, start with for x in numbers (what we are looping through), then if x % 2 == 0 (what we are filtering), then x * 2 (what we are outputting).

Nested Comprehensions

# Flatten a 2D list (matrix) into 1D
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flattened = [num for row in matrix for num in row]
print(flattened)  # Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]

# Create multiplication table
mult_table = [[i * j for j in range(1, 4)] for i in range(1, 4)]
print(mult_table)  # Output: [[1, 2, 3], [2, 4, 6], [3, 6, 9]]

Reading nested comprehensions (they can be confusing!):

Flattening a 2D list: [num for row in matrix for num in row]

  1. Read left-to-right like nested loops: "for each row in matrix, then for each num in that row"
  2. The outer for row in matrix runs first, giving us [1,2,3], then [4,5,6], etc.
  3. The inner for num in row unpacks each row into individual numbers
  4. num (the expression at the start) is what gets added to our output list

Creating a 2D list: [[i*j for j in range(1,4)] for i in range(1,4)]

  1. The outer comprehension creates rows (controlled by i)
  2. Each inner comprehension creates a row with values (controlled by j)
  3. Result: A list of lists - our multiplication table!
Pro tip: If nesting gets confusing, write it as regular loops first to understand the logic, then compress into a comprehension. Never sacrifice readability for cleverness!

Dictionary and Set Comprehensions

# Dictionary comprehension: {key: value for item in iterable}
words = ['apple', 'banana', 'cherry']
word_lengths = {word: len(word) for word in words}
print(word_lengths)  # Output: {'apple': 5, 'banana': 6, 'cherry': 6}

# Set comprehension: {expression for item in iterable}
numbers = [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
unique_squares = {x ** 2 for x in numbers}
print(unique_squares)  # Output: {1, 4, 9, 16}

Same pattern, different brackets - here is how to remember:

TypeSyntaxResultExample
List[ ]Ordered, allows duplicates[x*2 for x in nums]
Dict{ key: value }Key-value pairs{x: x*2 for x in nums}
Set{ }Unordered, no duplicates{x*2 for x in nums}
Generator( )Lazy iterator(x*2 for x in nums)

Dictionary comprehension breakdown:

{word: len(word) for word in words} means "for each word, create a key (the word) and a value (its length)"

Set comprehension breakdown:

{x ** 2 for x in numbers} means "square each number, and automatically remove duplicates" (notice 2, 3, 4 appear multiple times but their squares only appear once)

Best Practice: Keep comprehensions readable. If your one-liner spans more than 80 characters or has 3+ nested loops, use traditional loops instead.
Common Mistakes to Avoid
Side effects in comprehensions
# BAD: Appending inside comprehension
result = []
[result.append(x) for x in data]  # Creates list of None!

# GOOD: Just use a regular loop
for x in data:
    result.append(x)

Comprehensions are for creating new lists, not for side effects.

Too complex comprehensions
# BAD: Hard to read!
[[x*y for y in range(1,5) if y%2==0] for x in range(1,10) if x%3==0]

# GOOD: Break it down
result = []
for x in range(1, 10):
    if x % 3 == 0:
        row = [x*y for y in range(1,5) if y%2==0]
        result.append(row)

If it needs a comment to explain, use regular loops.

Forgetting generator exhaustion
# BAD: Generator already consumed!
gen = (x*2 for x in range(5))
print(list(gen))  # [0, 2, 4, 6, 8]
print(list(gen))  # [] - Empty! Already used!

# GOOD: Recreate or use list
nums = [x*2 for x in range(5)]  # Use list if need multiple iterations

Generators can only be iterated once.

Walrus in wrong context
# BAD: Assignment where expression expected
x := 5  # SyntaxError! 

# GOOD: Use in context that expects expression
if (x := 5) > 3:  # OK - in if condition
    print(x)

y = (x := 10)  # OK - parenthesized

Walrus needs context: if, while, comprehensions, etc.

Practice Makes Perfect

These exercises progress from simple to challenging. Here is how to get the most out of them:

  1. Try first without looking at hints - struggle is where learning happens
  2. Compare your solution to the expected output - make sure they match
  3. If stuck, reveal the hint - then try again before looking at the answer
  4. After solving, try variations - what if the input was different?

Tip: Write your code in VS Code or Python IDLE, not just in your head. Running the code yourself is essential!

Practice: Comprehensions

Task: Given a list of words, create a new list containing only words longer than 3 characters, converted to uppercase.

Show Solution
# Input list
words = ['hi', 'hello', 'hey', 'python', 'go', 'javascript']

# Filter words > 3 chars, convert to uppercase
result = [word.upper() for word in words if len(word) > 3]
# result iterates each word
# if len(word) > 3 filters short words
# word.upper() transforms to uppercase

print(result)  # Output: ['HELLO', 'PYTHON', 'JAVASCRIPT']

Task: Combine a list of names and a list of ages into a dictionary using dict comprehension.

Show Solution
# Two parallel lists
names = ['Alice', 'Bob', 'Charlie']
ages = [25, 30, 35]

# Combine using zip() and dict comprehension
person_ages = {name: age for name, age in zip(names, ages)}
# zip() pairs names and ages together
# name: age creates key-value pairs

print(person_ages)
# Output: {'Alice': 25, 'Bob': 30, 'Charlie': 35}

Task: Convert a list of [key, value] pairs into a dictionary, but only include pairs where the value is positive.

Show Solution
# List of [key, value] pairs
data = [['a', 10], ['b', -5], ['c', 20], ['d', -3], ['e', 15]]

# Dict comprehension with filter
result = {k: v for k, v in data if v > 0}
# k, v unpacks each [key, value] pair
# if v > 0 filters out negative values
# k: v creates the dict entry

print(result)  # Output: {'a': 10, 'c': 20, 'e': 15}

Task: Transpose a matrix (swap rows and columns) using nested list comprehension.

Show Solution
# Original matrix (3 rows x 4 columns)
matrix = [
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12]
]

# Transpose using nested comprehension
transposed = [[row[i] for row in matrix] for i in range(len(matrix[0]))]
# Outer: for i in range(4) - iterate column indices
# Inner: for row in matrix - collect that column from each row
# row[i] - get element at column i

for row in transposed:
    print(row)
# Output: [1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]
02

Generator Expressions

What if you have a billion numbers and want to process them one at a time without loading all of them into memory? Generators are lazy iterators that produce values on-demand. Instead of creating the entire list upfront, they generate each item only when you ask for it. Perfect for big data!

Key Concept

What is a Generator?

A generator is a special kind of iterator that computes values on-the-fly instead of storing them all in memory. Think of it like a vending machine - it only dispenses one item at a time when you request it, rather than dumping everything out at once.

Two ways to create generators:

  • Generator expressions: Like list comprehensions but with () instead of []
  • Generator functions: Functions that use yield instead of return
Important: Generators can only be consumed once! After you iterate through all values, the generator is "exhausted" and empty. To go through again, you must create a new generator.
Visual: List vs Generator Memory Usage
List Comprehension
[x for x in range(1000000)]
Memory: ~8 MB (all items stored)
          
RAM Used
Generator Expression
(x for x in range(1000000))
Memory: ~120 bytes (one item at a time)
          
RAM Used

Generator Expressions

# List comprehension: Creates entire list in memory
squares_list = [x ** 2 for x in range(1000000)]
print(type(squares_list))  # 

# Generator expression: Creates generator object (lazy)
squares_gen = (x ** 2 for x in range(1000000))
print(type(squares_gen))   # 

# Generators are iterable - use in loops or convert to list
for i, sq in enumerate(squares_gen):
    if i >= 5: break
    print(sq)  # Output: 0, 1, 4, 9, 16

Understanding lazy vs eager evaluation:

AspectList Comprehension [ ]Generator Expression ( )
MemoryStores ALL values at onceStores ONE value at a time
SpeedFaster for small data (already in memory)Faster for huge data (no memory pressure)
ReusabilityCan iterate multiple timesCan only iterate ONCE (then exhausted)
TypeReturns a list objectReturns a generator object

When to use generators:

  • Processing large files line by line (do not load entire file into memory)
  • Streaming data from APIs or databases
  • Creating infinite sequences (like Fibonacci numbers)
  • Any time you only need each value once

Generator Functions with yield

# Generator function using yield
def countdown(n):
    print("Starting countdown!")
    while n > 0:
        yield n    # Pauses here, returns n
        n -= 1     # Resumes here on next iteration
    print("Blastoff!")

# Create generator object
counter = countdown(5)
print(next(counter))  # Output: Starting countdown! 5
print(next(counter))  # Output: 4
print(next(counter))  # Output: 3

How yield works (step by step):

  1. counter = countdown(5) - Creates a generator object, but the function does NOT run yet!
  2. next(counter) - NOW the function runs until it hits yield n
  3. At yield n, the function PAUSES and returns the value (5)
  4. The function's state (variables, position) is saved
  5. Next next(counter) call RESUMES from exactly where it paused
  6. It continues from n -= 1, loops back, and yields the next value (4)
  7. This continues until the function ends (reaches Blastoff!)

yield vs return:

  • return: Ends the function completely. All local variables are lost.
  • yield: Pauses the function, saves everything, and can resume later.
Use case: Generators are perfect for reading huge files line-by-line, streaming data from APIs, or any situation where you want to process items one at a time without loading everything into memory.
Warning: Generators can only be consumed once! After iteration, they are exhausted and return nothing.

Practice: Generators

Task: Use a generator expression to sum the squares of numbers from 1 to 100.

Show Solution
# Generator expression inside sum()
total = sum(x ** 2 for x in range(1, 101))
# No need for extra parentheses when generator is only argument
# Computes each square on-demand, never stores 100 values

print(f"Sum of squares 1-100: {total}")
# Output: Sum of squares 1-100: 338350

Task: Create a generator function that yields Fibonacci numbers indefinitely.

Show Solution
def fibonacci():
    a, b = 0, 1      # Initialize first two numbers
    while True:      # Infinite generator
        yield a      # Yield current number
        a, b = b, a + b  # Compute next pair

# Get first 10 Fibonacci numbers
fib = fibonacci()
first_ten = [next(fib) for _ in range(10)]
# next() gets one value at a time
# Generator never runs out (infinite loop)

print(first_ten)  # Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Task: Create a generator that reads a file and yields non-empty lines (stripped of whitespace).

Show Solution
def read_lines(filepath):
    with open(filepath, 'r') as file:
        for line in file:          # Files are iterators too!
            stripped = line.strip()
            if stripped:           # Skip empty lines
                yield stripped     # Yield cleaned line

# Usage example (memory-efficient for huge files)
# for line in read_lines('huge_file.txt'):
#     process(line)  # Only one line in memory at a time

Task: Create a pipeline of generators: generate numbers, filter evens, square them, take first 5.

Show Solution
def numbers(n):
    for i in range(1, n + 1):
        yield i

def filter_evens(gen):
    for x in gen:
        if x % 2 == 0:
            yield x

def square(gen):
    for x in gen:
        yield x ** 2

# Chain generators together (lazy pipeline)
pipeline = square(filter_evens(numbers(100)))
# Nothing computed yet - all lazy!

result = [next(pipeline) for _ in range(5)]
# Only computes as many values as needed
print(result)  # Output: [4, 16, 36, 64, 100]
Real-World Use Cases: When Generators Shine
Reading Large Log Files
def read_errors(logfile):
    """Read only ERROR lines from huge log file"""
    with open(logfile) as f:
        for line in f:  # File is iterator!
            if 'ERROR' in line:
                yield line.strip()

# Process millions of lines without loading all into memory
for error in read_errors('server.log'):
    print(error)

The file is never fully loaded - each line is processed then discarded.

Streaming API Data
def fetch_pages(api_url):
    """Fetch paginated API results lazily"""
    page = 1
    while True:
        response = requests.get(f"{api_url}?page={page}")
        data = response.json()
        if not data['results']:
            break
        yield from data['results']  # yield each item
        page += 1

# Stop early if we find what we need
for user in fetch_pages('/api/users'):
    if user['name'] == 'Alice':
        break  # No more pages fetched!

Pages are fetched only as needed - early exit stops fetching.

Infinite Sequences
def prime_numbers():
    """Generate primes forever"""
    n = 2
    while True:
        if all(n % i != 0 for i in range(2, int(n**0.5)+1)):
            yield n
        n += 1

# Get first 10 primes
primes = prime_numbers()
first_10 = [next(primes) for _ in range(10)]
print(first_10)  # [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]

Cannot create a "list of all primes" - but a generator works perfectly!

Data Transformation Pipelines
def clean(data):
    for item in data:
        yield item.strip().lower()

def filter_valid(data):
    for item in data:
        if item and not item.startswith('#'):
            yield item

# Chain transformations - each is lazy!
with open('data.txt') as f:
    pipeline = filter_valid(clean(f))
    for line in pipeline:
        process(line)

Each stage processes one item at a time - memory efficient!

03

Walrus Operator (:=)

Ever wanted to assign a value and use it in the same expression? The walrus operator (:=) lets you do exactly that! Introduced in Python 3.8, it is called "walrus" because := looks like a walrus face turned sideways. It helps avoid redundant calculations and makes certain patterns more elegant.

Key Concept

The Walrus Operator (:=)

The walrus operator := (officially called the "assignment expression") lets you assign a value to a variable AND use that value in the same expression. The pattern is: (variable := expression)

Why it is useful:

  • Avoid repeated calculations: Compute once, use multiple times in the same statement
  • Cleaner while loops: Assign and check in one line
  • Better comprehensions: Store intermediate results for both filtering and output
Readability first: Use the walrus operator when it makes code clearer. If it makes your code harder to understand, stick with separate assignment statements.
Visual: Walrus Operator Patterns
Without Walrus
n = len(data)
if n > 10:
    print(f"Got {n} items")
          
With Walrus
if (n := len(data)) > 10:
    print(f"Got {n} items")
          

Basic Usage

# Without walrus: compute twice or use extra line
data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# Bad: computes len() twice
if len(data) > 5:
    print(f"List has {len(data)} items")  # Redundant call

# Better with walrus: assign and use in one expression
if (n := len(data)) > 5:
    print(f"List has {n} items")  # n is available here

The walrus pattern explained: (variable := expression)

How it works:

  1. The expression on the right (len(data)) is evaluated
  2. The result is assigned to the variable on the left (n)
  3. The SAME result is also returned, so it can be used in the if condition
  4. The variable n is now available in the if block (and after!)

Why parentheses are needed:

Without parentheses, Python might parse it incorrectly:

  • if n := len(data) > 5 would be parsed as if n := (len(data) > 5) - n would be True/False!
  • if (n := len(data)) > 5 correctly assigns the length to n, THEN compares
Name origin: It is called the "walrus operator" because := looks like a walrus with tusks turned sideways! 🦭

Common Use Cases

# Use case 1: While loops with assignment
while (line := input("Enter text (q to quit): ")) != "q":
    print(f"You entered: {line}")

# Use case 2: List comprehensions with expensive operations
import re
text = "Email: test@example.com and info@site.org"
# Only include matches that exist
emails = [m.group() for s in [text] if (m := re.search(r'\S+@\S+', s))]

# Use case 3: Avoid repeated function calls
data = [5, 12, 3, 18, 7, 25]
filtered = [y for x in data if (y := x * 2) > 10]
print(filtered)  # Output: [24, 36, 14, 50]
Understanding Each Use Case
While Loops

Without walrus:

line = input()
while line != "q":
  ...
  line = input()
Duplicate input call!

With walrus:

while (line := input()) != "q": Assign AND check in one expression
Expensive Operations

When you need to compute, filter, AND include the result:

[y for x in data if (y := expensive(x)) > threshold]
Without walrus, you'd call expensive(x) twice!
Intermediate Results
[y for x in data if (y := x * 2) > 10]
  • Compute x * 2
  • Store in y
  • Filter by y > 10
  • Output y
When NOT to use walrus: Don't use it just because you can! If it makes your code harder to read, use separate lines. Clarity beats cleverness.
Python 3.8+ Required: The walrus operator is only available in Python 3.8 and later versions.

Practice: Walrus Operator

Task: Rewrite this code using the walrus operator to avoid calling len() twice.

# Original code
items = [1, 2, 3, 4, 5]
if len(items) > 3:
    print(f"Too many: {len(items)} items")
Show Solution
# Using walrus operator
items = [1, 2, 3, 4, 5]
if (n := len(items)) > 3:
    print(f"Too many: {n} items")
# len() is computed once, stored in n
# n is used both in condition and print

# Output: Too many: 5 items

Task: Create a list of squared values from numbers 1-10, but only include squares greater than 20.

Show Solution
# Using walrus to avoid computing square twice
result = [sq for x in range(1, 11) if (sq := x ** 2) > 20]
# sq := x ** 2 computes and stores the square
# if sq > 20 filters using the stored value
# sq is used as the output (no recomputation)

print(result)  # Output: [25, 36, 49, 64, 81, 100]

Task: Extract all numbers from a string, but only include those greater than 50.

Show Solution
import re

text = "Scores: 45, 82, 33, 91, 67, 12, 55"

# Find all numbers, filter those > 50
numbers = [
    num for match in re.finditer(r'\d+', text)
    if (num := int(match.group())) > 50
]
# match.group() gets the string match
# int() converts to number and stores in num
# Filter and output use the same num value

print(numbers)  # Output: [82, 91, 67, 55]
04

Loop Control Statements

Sometimes you need to break out of a loop early, skip certain iterations, or know if a loop completed without interruption. Python provides break, continue, pass, and the unique else clause on loops for fine-grained control over iteration.

Key Concept

Loop Control Statements

Python gives you three keywords to control loop execution, plus a unique else clause:

break - Exit immediately

Completely exits the loop. No more iterations. Code continues after the loop.

continue - Skip to next

Skips the rest of this iteration. Loop continues with the next item.

pass - Do nothing

A placeholder that does nothing. Used when syntax requires a statement.

else - No break occurred

Runs only if the loop completed all iterations without hitting break.

Break, Continue, and Pass

# break: Exit the loop immediately
for i in range(10):
    if i == 5:
        break           # Exit when i is 5
    print(i, end=" ")   # Output: 0 1 2 3 4

# continue: Skip to next iteration
for i in range(10):
    if i % 2 == 0:
        continue        # Skip even numbers
    print(i, end=" ")   # Output: 1 3 5 7 9

# pass: Do nothing (placeholder)
for i in range(5):
    if i == 2:
        pass            # Placeholder - does nothing
    print(i, end=" ")   # Output: 0 1 2 3 4

Understanding each control statement:

break example trace:

i=0: print 0, i=1: print 1, i=2: print 2, i=3: print 3, i=4: print 4, i=5: BREAK! Loop exits immediately. Numbers 6-9 never run.

continue example trace:

i=0: even, CONTINUE (skip print), i=1: odd, print 1, i=2: even, CONTINUE, i=3: odd, print 3... etc.

The loop runs all 10 times, but print only happens for odd numbers.

pass example trace:

i=0: print 0, i=1: print 1, i=2: pass (nothing happens), print 2, i=3: print 3, i=4: print 4

pass is a placeholder - Python requires something in the if block, so we use pass to say "do nothing here"

Common use of pass: When you are writing code incrementally - you want to define a function or class but have not written the body yet. pass prevents syntax errors.

The Loop else Clause

# else runs when loop completes WITHOUT break
def find_prime(numbers):
    for n in numbers:
        if n < 2:
            continue
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                break           # Not prime, exit inner loop
        else:
            return n            # else runs = no break = prime found
    return None

print(find_prime([4, 6, 8, 9, 11, 12]))  # Output: 11

The for-else pattern explained (often misunderstood!):

Think of else on a loop as a "no-break" clause. It runs only if the loop finishes naturally without hitting a break.

How the prime finder works:

  1. is_prime(n) checks if a number is prime by testing divisibility
  2. If any divisor is found, break exits the loop and else is SKIPPED
  3. If NO divisor is found, loop finishes naturally and else runs → returns True

Trace with is_prime(11):

i=2: 11%2≠0, i=3: 11%3≠0, i=4: 11%4≠0... no break hits → else runs → returns True

Trace with is_prime(12):

i=2: 12%2=0 → break! → else SKIPPED → returns False (after the loop)

Mental model: Instead of reading "for...else" as "if loop finishes then", read it as "for...nobreak" - the else runs when there is no break.

Nested Loop Control

# Break only exits the innermost loop
found = False
for i in range(5):
    for j in range(5):
        if i * j == 12:
            print(f"Found at ({i}, {j})")
            found = True
            break       # Only exits inner loop
    if found:
        break           # Need this to exit outer loop

# Alternative: Use a function with return
def find_product(target):
    for i in range(10):
        for j in range(10):
            if i * j == target:
                return (i, j)  # Exits both loops
    return None

print(find_product(12))  # Output: (2, 6)

Breaking out of nested loops - three approaches:

1. Flag variable (shown first):

  • Set found = True in inner loop, then check it in outer loop
  • Works but adds clutter - you need to check the flag after every inner loop

2. Function + return (cleanest - shown second):

  • Wrap the nested loops in a function
  • return immediately exits the entire function, breaking all loops at once
  • This is the recommended approach - clean and Pythonic

3. Exception (not shown - not recommended):

  • Raise a custom exception in inner loop, catch it outside both loops
  • Works but goes against the principle "exceptions for exceptional cases, not control flow"
Best practice: If you find yourself needing to break out of multiple loops, refactor the loops into a function and use return. It is cleaner and makes your intent obvious.
Real-World Use Cases: Loop Control in Action
Search with Early Exit
def find_user(users, email):
    """Find user by email - stop when found"""
    for user in users:
        if user['email'] == email:
            return user  # Early exit!
    return None

# Or with for-else:
for user in users:
    if user['email'] == target:
        print(f"Found: {user['name']}")
        break
else:
    print("User not found")

No need to scan entire list once target is found.

Input Validation Loop
def get_positive_number():
    """Keep asking until valid input"""
    while True:
        try:
            n = int(input("Enter positive number: "))
            if n <= 0:
                print("Must be positive!")
                continue  # Skip to next iteration
            return n  # Valid - exit loop
        except ValueError:
            print("Invalid number!")
            continue

continue skips to re-prompt, return exits when valid.

Skip Invalid Items
def process_records(records):
    """Process valid records, skip invalid ones"""
    results = []
    for record in records:
        if not record.get('id'):
            continue  # Skip records without ID
        if record.get('status') == 'deleted':
            continue  # Skip deleted records
        # Only reach here for valid records
        results.append(transform(record))
    return results

continue acts like a filter - skip unwanted items cleanly.

Rate Limiting / Max Attempts
def fetch_with_retry(url, max_attempts=3):
    """Try API call with retries"""
    for attempt in range(1, max_attempts + 1):
        try:
            response = requests.get(url)
            response.raise_for_status()
            return response.json()  # Success - exit
        except RequestException:
            if attempt == max_attempts:
                raise  # Give up after max attempts
            time.sleep(2 ** attempt)  # Exponential backoff
            continue  # Try again

Retry logic with break on success, continue on failure.

Practice: Loop Control

Task: Find the first even number in a list using break.

Show Solution
numbers = [1, 3, 5, 7, 8, 9, 10]
first_even = None

for num in numbers:
    if num % 2 == 0:
        first_even = num
        break  # Stop searching after finding first

print(f"First even: {first_even}")  # Output: First even: 8

Task: Sum only positive numbers from a list using continue.

Show Solution
numbers = [10, -5, 20, -3, 15, -8, 25]
total = 0

for num in numbers:
    if num < 0:
        continue  # Skip negative numbers
    total += num

print(f"Sum of positives: {total}")  # Output: Sum of positives: 70

Task: Check if all items in a list are positive. Use loop-else to print appropriate message.

Show Solution
def check_all_positive(numbers):
    for num in numbers:
        if num <= 0:
            print(f"Found non-positive: {num}")
            break  # Exit loop, skip else
    else:
        # Only runs if no break occurred
        print("All numbers are positive!")

check_all_positive([1, 2, 3, 4, 5])   # All positive!
check_all_positive([1, 2, -3, 4, 5]) # Found non-positive: -3

Task: Find the position of a target value in a 2D grid. Return (row, col) or None if not found.

Show Solution
def find_in_grid(grid, target):
    for row_idx, row in enumerate(grid):
        for col_idx, val in enumerate(row):
            if val == target:
                return (row_idx, col_idx)  # Found! Exit both
    return None  # Not found after checking all

grid = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]
print(find_in_grid(grid, 5))   # Output: (1, 1)
print(find_in_grid(grid, 10))  # Output: None

Quick Reference Cheat Sheet

Comprehension Syntax
List:[expr for x in iter]
List + filter:[expr for x in iter if cond]
Dict:{key: val for x in iter}
Set:{expr for x in iter}
Generator:(expr for x in iter)
Nested:[expr for a in X for b in a]
Generator Patterns
Expression:(x*2 for x in range(10))
Function:def gen(): yield value
Get next:next(gen_obj)
To list:list(gen_obj)
Iterate:for item in gen_obj:
Walrus Operator
In if:if (n := func()) > 5:
In while:while (x := input()) != "q":
In comprehension:[y for x in L if (y := f(x))]
Loop Control
break:Exit loop immediately
continue:Skip to next iteration
pass:Do nothing (placeholder)
for...else:Runs if no break executed
while...else:Same - runs on normal exit

Knowledge Check

Quick Quiz

Test what you've learned about advanced control flow

1 What is the main difference between a list comprehension and a generator expression?
2 What does the walrus operator (:=) do?
3 When does the else clause of a for loop execute?
4 What is the output of: [x for x in range(5) if x % 2]?
5 What happens when you try to iterate over a generator twice?
6 Which statement is true about break in nested loops?
Answer all questions to check your score

Key Takeaways

List Comprehensions are Pythonic

Transform multi-line loops into elegant one-liners. Use [expr for x in iter if cond] for cleaner, faster code.

Generators Save Memory

Use generator expressions (expr for x in iter) when processing large datasets - they compute values on-demand.

Walrus Operator Reduces Repetition

Use := to assign and use a value in one expression - perfect for while loops and conditional checks.

Break Exits, Continue Skips

Use break to exit loops early, continue to skip iterations, and pass as a placeholder.

Loop Else is Powerful

The else clause after for/while runs only if no break occurred - great for search patterns.

Nested Comprehensions

Read nested comprehensions left-to-right like nested loops: [x for row in matrix for x in row].