Skip to content

Control Flow#

Control flow is how you branch, loop, and skip execution. Interview solutions depend on choosing the right structure — a clean for loop beats a clever one-liner when clarity matters, but knowing multiple equivalent patterns lets you adapt under pressure.

How to use this page

Master if/for/while first, then match, loop else, and comprehensions. Prerequisites: Operators, Python Language Fundamentals.

At a glance
Track Python Basics
Sections 10 major topics
Outline Use the right-hand TOC to jump

Topics: Conditional statements · The match statement (Python 3.10+) · for loops · while loops · Loop control: break, continue, pass · Comprehensions and generator expressions · Manual iteration protocol · Nested loops and complexity · … (+2 more)

  1. Conditional statements
  2. The match statement (Python 3.10+)
  3. for loops
  4. while loops
  5. Loop control: break, continue, pass
  6. Comprehensions and generator expressions
  7. Manual iteration protocol
  8. Nested loops and complexity
  9. Common interview loop patterns
  10. Mutating collections during iteration

Conditional statements#

if / elif / else#

def grade(score: int) -> str:
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    else:
        return "F"

Rules:

  • No parentheses around conditions unless grouping sub-expressions.
  • Every block starts with : and uses consistent indentation (4 spaces).
  • elif and else are optional; there is no switch keyword (use match or dict dispatch).

Multiple ways to branch on discrete values#

Approach 1: if/elif chain — best when conditions are ranges or complex logic.

Approach 2: dict dispatch — O(1) lookup for exact key → action mapping:

def handle_command(cmd: str) -> str:
    handlers = {
        "start": lambda: "starting",
        "stop": lambda: "stopping",
        "pause": lambda: "pausing",
    }
    fn = handlers.get(cmd)
    if fn is None:
        raise ValueError(f"unknown command: {cmd}")
    return fn()

Approach 3: match (3.10+) — structural patterns (see below).

Approach 4: ternary / short-circuit — two-outcome cases only.

Choose based on readability: dict dispatch for many string commands; if/elif for numeric ranges; match for destructuring.

Ternary expression#

status = "pass" if score >= 60 else "fail"
max_val = a if a >= b else b

# Nested — avoid in interviews
# label = "big" if x > 100 else "medium" if x > 10 else "small"

Ternary is an expression (returns a value); if/else blocks are statements.

Truthiness — three validation styles#

count = 0
name = ""
data = None

# Style 1: truthiness (fails when 0 or "" are valid)
if count:
    process(count)

# Style 2: explicit comparison (when 0 is valid)
if count is not None and count >= 0:
    process(count)

# Style 3: sentinel check for None only
if name is not None:
    greet(name)   # allows empty string
Value if x: if x is not None:
None falsy False
0 falsy True
"" falsy True
[] falsy True

Guard clauses (early return)#

Flatten nested logic by handling invalid cases first:

# Nested — harder to read
def process(data: list[int] | None) -> int:
    if data is not None:
        if len(data) > 0:
            if all(x >= 0 for x in data):
                return sum(data)
    return 0

# Guard clauses — preferred
def process(data: list[int] | None) -> int:
    if data is None:
        return 0
    if len(data) == 0:
        return 0
    if not all(x >= 0 for x in data):
        return 0
    return sum(data)

The match statement (Python 3.10+)#

Structural pattern matching — matches shape and type, not just equality.

Basic patterns#

def describe(value):
    match value:
        case 0:
            return "zero"
        case [x, y]:
            return f"pair: {x}, {y}"
        case [x, y, z]:
            return f"triple starting with {x}"
        case {"name": name, "age": age}:
            return f"{name} is {age}"
        case int() | float() as n if n > 0:
            return f"positive number: {n}"
        case str() as s if s.isdigit():
            return f"numeric string: {s}"
        case _:
            return "something else"
Pattern Matches
Literal Exact value (0, "ok", True)
Capture case x: — binds whole value to x
Sequence Fixed-length [x, y] or [*rest]
Mapping {"key": val} — extra keys allowed unless ** rest used
Class Point(x, y) — positional or keyword attrs
\| (OR) Multiple alternatives
Guard case x if x > 0:
Wildcard _ — discard, always matches
AS case [x, y] as pair:

Sequence patterns with splats#

match items:
    case []:
        print("empty")
    case [single]:
        print(f"one: {single}")
    case [first, *middle, last]:
        print(f"first={first}, middle={middle}, last={last}")

Mapping patterns#

match config:
    case {"host": h, "port": p}:
        connect(h, p)
    case {"host": h}:           # port optional
        connect(h, 8080)
    case {"host": h, **rest}:    # capture extra keys
        print("extras:", rest)

When to use match vs if/elif#

Use match Use if/elif
Destructuring lists/tuples/dicts Numeric range checks
Type + shape branching Complex boolean logic
AST/token parsing Simple comparisons

Most DSA problems still use if/for/while — deploy match when structure is the primary discriminator.


for loops#

Three ways to iterate with index#

nums = [10, 20, 30]

# 1. Direct iteration — preferred when index unused
for n in nums:
    print(n)

# 2. range(len) — avoid unless modifying by index
for i in range(len(nums)):
    nums[i] *= 2

# 3. enumerate — preferred when index needed
for i, n in enumerate(nums):
    print(i, n)

# 4. enumerate with start offset
for i, n in enumerate(nums, start=1):
    print(f"item {i}: {n}")

range() in depth#

range(5)            # 0, 1, 2, 3, 4
range(2, 5)         # 2, 3, 4
range(0, 10, 2)     # 0, 2, 4, 6, 8
range(5, 0, -1)     # 5, 4, 3, 2, 1
range(0, 10, 3)     # 0, 3, 6, 9

list(range(3))      # materialize
len(range(1_000_000))  # 1000000 — no memory allocated for elements
3 in range(0, 10, 2)   # False — membership is O(1) math, not iteration

range objects are immutable sequences — they support indexing and slicing:

r = range(2, 10, 2)
r[0]    # 2
r[-1]   # 8
list(r[1:3])  # [4, 6]

Iterating dicts — four views#

freq = {"a": 1, "b": 2, "c": 3}

for key in freq:                    # keys (default)
    print(key, freq[key])

for key, val in freq.items():       # preferred — no double lookup
    print(key, val)

for val in freq.values():
    print(val)

for i, key in enumerate(freq):
    print(i, key)

Insertion order is preserved (3.7+). For sorted iteration: for k in sorted(freq):.

Parallel iteration: zip and alternatives#

names = ["Alice", "Bob", "Charlie"]
scores = [90, 85, 92]

# zip — stops at shortest
for name, score in zip(names, scores):
    print(name, score)

# With index
for i, (name, score) in enumerate(zip(names, scores)):
    print(i, name, score)

# Unequal lengths — pad with fillvalue
from itertools import zip_longest
list(zip_longest([1, 2], [10, 20, 30], fillvalue=0))
# [(1, 10), (2, 20), (0, 30)]

# Manual index (when zip isn't enough)
for i in range(max(len(names), len(scores))):
    n = names[i] if i < len(names) else None
    s = scores[i] if i < len(scores) else None

Iterating multiple sequences — map#

list(map(lambda a, b: a + b, [1, 2, 3], [10, 20, 30]))
# [11, 22, 33] — prefer zip + comprehension in modern Python
[a + b for a, b in zip([1, 2, 3], [10, 20, 30])]

Reverse iteration — three approaches#

nums = [1, 2, 3, 4, 5]

for x in reversed(nums):
    print(x)

for i in range(len(nums) - 1, -1, -1):
    print(nums[i])

for i, x in enumerate(reversed(nums)):
    print(i, x)

Reverse iteration is useful when mutating a list while iterating (deleting safely):

for i in range(len(nums) - 1, -1, -1):
    if nums[i] % 2 == 0:
        del nums[i]

while loops#

Use when iteration count is unknown or driven by a condition.

Classic patterns#

# Binary search
while lo <= hi:
    mid = (lo + hi) // 2
    ...

# Process until sentinel
while True:
    line = input()
    if line == "quit":
        break
    process(line)

# Two pointers
while lo < hi:
    ...

Simulating do-while (execute at least once)#

Python has no do-while. Two idioms:

# Approach 1: break in middle
while True:
    data = read()
    process(data)
    if not should_continue():
        break

# Approach 2: walrus + condition at end
data = read()
while data:
    process(data)
    data = read()

Avoiding infinite loops#

Common causes: forgetting to update loop variable, off-by-one in pointer movement, waiting on condition that never becomes false. Always trace lo/hi/i updates on paper for two-pointer problems.


Loop control: break, continue, pass#

for x in nums:
    if x < 0:
        continue          # skip to next iteration
    if x == 0:
        break             # exit innermost loop only
    process(x)

pass                      # no-op — syntax placeholder

break/continue in nested loops#

break/continue affect only the innermost enclosing loop. To break outer loop:

# Approach 1: flag
found = False
for i in range(n):
    for j in range(m):
        if condition(i, j):
            found = True
            break
    if found:
        break

# Approach 2: helper function with return
def search(matrix):
    for i, row in enumerate(matrix):
        for j, val in enumerate(row):
            if val == target:
                return (i, j)
    return None

# Approach 3: for-else (see below)

else on loops (high interview value)#

The else clause runs when the loop completes without break:

def is_prime(n: int) -> bool:
    if n < 2:
        return False
    for d in range(2, int(n ** 0.5) + 1):
        if n % d == 0:
            return False   # early return — else skipped
    return True

# for-else style
def is_prime_for_else(n: int) -> bool:
    if n < 2:
        return False
    for d in range(2, int(n ** 0.5) + 1):
        if n % d == 0:
            break
    else:
        return True    # no divisor found
    return False
Pattern Meaning
for ... break ... else else = not found
while ... break ... else same semantics

while/else is rare but valid — else runs if loop ended without break.

pass, ..., and raise NotImplementedError#

def todo():
    pass              # stub

def todo2():
    ...               # Ellipsis — also valid stub (common in type stubs)

def todo3():
    raise NotImplementedError("coming soon")

Comprehensions and generator expressions#

List comprehension anatomy#

[expression for item in iterable if condition]

# Examples
squares = [x * x for x in range(10)]
evens = [x for x in nums if x % 2 == 0]
matrix = [[0] * cols for _ in range(rows)]   # not [[0]*cols]*rows

Dict and set comprehensions#

index_map = {v: i for i, v in enumerate(nums)}
freq = {ch: s.count(ch) for ch in set(s)}     # O(n²) — use Counter
unique_lengths = {len(w) for w in words}

Nested comprehensions#

pairs = [(x, y) for x in range(3) for y in range(3)]
flatten = [x for row in matrix for x in row]

Read nested comprehensions left to right like nested loops.

Generator expressions (lazy)#

total = sum(x * x for x in range(10_000_000))   # no intermediate list
any_negative = any(x < 0 for x in nums)         # short-circuits
Construct Eager/Lazy Syntax
List comprehension Eager (builds list) [... for x in it]
Generator expression Lazy (... for x in it)
map / filter Lazy (iterator) map(fn, it)

Rule: use generator when passing directly to a single consuming function (sum, max, any, all, join with str gen).

When not to use comprehensions#

  • Multi-step logic with intermediate variables
  • Side effects (print, mutation) — use a regular for loop
  • Nested comprehensions beyond 2 levels — extract helper

Manual iteration protocol#

Every iterable responds to iter() and produces an iterator with next():

it = iter([1, 2, 3])
next(it)   # 1
next(it)   # 2
next(it)   # 3
next(it)   # StopIteration

# Manual loop is equivalent to:
iterator = iter(collection)
while True:
    try:
        item = next(iterator)
    except StopIteration:
        break
    process(item)

Useful for understanding generators and custom iterators — see Custom Iterators.


Nested loops and complexity#

# O(n²) brute force — always state complexity aloud
for i in range(n):
    for j in range(i + 1, n):
        if nums[i] + nums[j] == target:
            return [i, j]

Before nesting, ask:

Can you reduce? Technique
Sorted array + pair sum Two Pointers → O(n)
Unsorted pair sum Hash map → O(n)
Subarray with constraint Sliding window → O(n)

Common interview loop patterns#

Two pointers#

def two_sum_sorted(nums: list[int], target: int) -> list[int]:
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        s = nums[lo] + nums[hi]
        if s == target:
            return [lo, hi]
        if s < target:
            lo += 1
        else:
            hi -= 1
    return []

Variants: same direction (slow/fast), partition (Dutch flag), merge sorted arrays.

Sliding window#

def length_of_longest_substring(s: str) -> int:
    seen: dict[str, int] = {}
    best = left = 0
    for right, ch in enumerate(s):
        if ch in seen and seen[ch] >= left:
            left = seen[ch] + 1
        seen[ch] = right
        best = max(best, right - left + 1)
    return best

Fixed-size vs variable-size windows — see Sliding Window.

Prefix accumulation in loop#

prefix = 0
for x in nums:
    prefix += x
    # use prefix for subarray sums ending at current index

Mutating collections during iteration#

Code & explanation — four fixes

nums = [1, 2, 3, 4, 5, 6]

# BUG: forward iteration + remove skips elements
for x in nums:
    if x % 2 == 0:
        nums.remove(x)

# FIX 1: iterate copy
for x in nums[:]:
    if x % 2 == 0:
        nums.remove(x)

# FIX 2: reverse index loop
for i in range(len(nums) - 1, -1, -1):
    if nums[i] % 2 == 0:
        del nums[i]

# FIX 3: list comprehension (preferred)
nums = [x for x in nums if x % 2 != 0]

# FIX 4: filter + list (equivalent)
nums = list(filter(lambda x: x % 2 != 0, nums))

Interview traps (quick reference)#

Trap What goes wrong Safe approach
Modifying list while iterating forward Skipped elements Copy, reverse delete, or comprehension
range(len(x)) habit Verbose, error-prone enumerate(x) or direct iteration
Deep nesting Unreadable Guard clauses, helper functions
for/else confusion else runs after normal completion, not on return Use when break = found
Off-by-one in range IndexError range(n) is 0..n-1
Nested break Only breaks inner loop Function + return, or flag
Comprehension side effects Hard to debug Use explicit loop
zip length mismatch Silent truncation zip_longest if needed

Mental model checklist#

  1. When does for/else run its else clause?
  2. What is the difference between break and continue in a nested loop?
  3. How do you iterate a dict by key-value pairs without double lookup?
  4. When is a generator expression better than a list comprehension?
  5. How do you safely delete items from a list while iterating?

What's next#

Topic Page
String iteration and slicing Strings
Functions and helpers Functions and Code Reuse
Collections in loops Data Structures
DSA loop patterns Programming