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)
- Conditional statements
- The
matchstatement (Python 3.10+) forloopswhileloops- Loop control:
break,continue,pass - Comprehensions and generator expressions
- Manual iteration protocol
- Nested loops and complexity
- Common interview loop patterns
- 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). elifandelseare optional; there is noswitchkeyword (usematchor 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:
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):
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#
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 regularforloop - 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#
Mutating collections during iteration#
Code & explanation — four fixes
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#
- When does
for/elserun itselseclause? - What is the difference between
breakandcontinuein a nested loop? - How do you iterate a dict by key-value pairs without double lookup?
- When is a generator expression better than a list comprehension?
- 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 |