Skip to main content
Published / updated

Control Flow

Before you start

You need: variables and types (Article 01).

Time: about 45 minutes, plus the practice.

Learning objective

Write correct conditions and loops, and choose the right construct instead of reaching for while or an index every time.

Topics

  • if, elif, else
  • Conditional expressions
  • match
  • for and iteration
  • range
  • while
  • break, continue, else
  • Comprehensions
  • Enumerate, zip and unpacking

if, elif, else

if percentage >= 90:
grade = "A+"
elif percentage >= 80:
grade = "A"
elif percentage >= 70:
grade = "B"
elif percentage >= passing_marks:
grade = "C"
else:
grade = "Fail"

elif, not else if. The colon and indentation replace braces.

Order matters. Conditions are tested top to bottom and the first match wins, so the absent check must come before any grade check:

if result.is_absent:
grade = "Absent"
elif result.marks_obtained >= passing_marks:
grade = "Pass"
else:
grade = "Fail"

Reversing the first two lines marks an absent student — whose marks are None — as having failed an exam they never sat. That is the same absent-versus-zero bug this curriculum keeps returning to, and it appears on a real result sheet.

Guard clauses

# Nested — hard to follow
def save_student(student):
if student is not None:
if student.name:
if student.roll_number:
repository.save(student)

# Guard clauses — flat
def save_student(student):
if student is None:
raise ValueError("student is required")

if not student.name:
raise ValueError("name is required")

if not student.roll_number:
raise ValueError("roll number is required")

repository.save(student)

Return or raise early. The happy path ends up unindented at the bottom, which is far easier to read than four levels of nesting.

Conditional expressions

status = "Active" if student.is_active else "Inactive"
display = student.address if student.address else "Not recorded"
display = student.address or "Not recorded" # shorter, same result

X if condition else Y is Python's ternary, written in that order deliberately — the common case reads first.

or for a default is concise and has the usual trap: it also replaces 0, "" and False. When those are valid values, test for None:

marks = result.marks_obtained if result.marks_obtained is not None else 0

match

Python 3.10 and later. More capable than a C# switch.

match exam_type:
case "UnitTest" | "Assignment":
weight = 0.2
case "MidTerm":
weight = 0.3
case "Final" | "Practical":
weight = 0.5
case _:
raise ValueError(f"Unknown exam type: {exam_type}")

case _ is the default. Include it — without one, an unmatched value falls through silently and the variable keeps whatever it held before.

It destructures, which is where it beats a chain of elif:

match student:
case {"name": str(name), "marks": int(marks)} if marks >= 35:
print(f"{name} passed with {marks}")
case {"name": str(name), "is_absent": True}:
print(f"{name} was absent")
case _:
print("Unrecognised record")
match point:
case (0, 0):
print("Origin")
case (x, 0):
print(f"On the x-axis at {x}")
case (x, y):
print(f"At {x}, {y}")

For a simple value comparison, elif is clearer. Reach for match when you are matching shape as well as value.

for

Python's for iterates a sequence directly — there is no C-style index loop.

for student in students:
print(student.name)

for character in "Ravi":
print(character)

for key, value in student_dict.items():
print(f"{key}: {value}")
# C-style, translated badly
for i in range(len(students)):
print(students[i].name)

# Idiomatic
for student in students:
print(student.name)

Iterating by index is the clearest sign of C# or Java habits. Use it only when you genuinely need the index — and then use enumerate.

range

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

range excludes the stop value. range(1, 6) gives 1 to 5, so a loop over class numbers 1 to 12 is range(1, 13).

range produces values lazily — range(1_000_000) allocates nothing until iterated.

enumerate

for index, student in enumerate(students):
print(f"{index + 1}. {student.name}")

for index, student in enumerate(students, start=1):
print(f"{index}. {student.name}")

start=1 removes the + 1 from the body — clearer for a numbered list.

zip

names = ["Ravi Kumar", "Priya Sharma", "Arjun Reddy"]
marks = [87, 91, 65]

for name, mark in zip(names, marks):
print(f"{name}: {mark}")

zip stops at the shortest sequence, silently. A names list longer than a marks list drops the extras with no warning:

for name, mark in zip(names, marks, strict=True): # Python 3.10+ — raises on mismatch
...

Use strict=True whenever the lengths are supposed to match.

Modifying while iterating

# Wrong — skips elements
for student in students:
if student.status == "Inactive":
students.remove(student)

# Right — build a new list
students = [s for s in students if s.status != "Inactive"]

# Or iterate a copy
for student in students[:]:
if student.status == "Inactive":
students.remove(student)

Removing during iteration shifts the remaining items, so the loop skips one each time. The result is a list that still contains inactive students — and it looks like the filter is broken.

while

attempts = 0
max_attempts = 3

while attempts < max_attempts:
password = input("Password: ")

if verify(password):
break

attempts += 1
else:
print("Too many failed attempts.")

Use while when the number of iterations is not known in advance — retries, reading until a sentinel, a menu loop.

Ensure the condition can become false. A while whose body never changes the condition hangs the program, and Python gives no warning.

while True:
choice = input("Choice: ").strip()

if choice == "5":
break

handle(choice)

while True with a break is idiomatic for a menu — Python has no do-while.

break, continue and else

for student in students:
if student.status != "Active":
continue # skip to the next iteration

if student.roll_number == target:
found = student
break # leave the loop

The loop else

for student in students:
if student.roll_number == target:
print(f"Found {student.name}")
break
else:
print("No student with that roll number.")

else on a loop runs when the loop finished without break. It is unique to Python and frequently misread as "runs otherwise".

It replaces the found = False flag pattern, and it is worth knowing because you will meet it — but a comment helps, since most readers pause at it.

Comprehensions

The most idiomatic construct in the language.

# Loop
active_names = []

for student in students:
if student.status == "Active":
active_names.append(student.name)

# Comprehension
active_names = [s.name for s in students if s.status == "Active"]
[expression for item in iterable]
[expression for item in iterable if condition]
[a if condition else b for item in iterable]

Note the position: a filtering if goes at the end; a conditional expression goes at the front.

percentages = [r.marks_obtained / r.max_marks * 100 for r in results]

passed = [r for r in results if not r.is_absent and r.marks_obtained >= 35]

labels = [f"{s.name} ({s.roll_number})" for s in students]

grades = ["Pass" if r.marks_obtained >= 35 else "Fail" for r in results if not r.is_absent]

Dict and set comprehensions:

by_roll = {s.roll_number: s for s in students}

class_names = {s.class_name for s in students} # unique, unordered

marks_by_name = {s.name: s.marks for s in students if s.marks is not None}

{s.roll_number: s for s in students} is the standard way to build a lookup — and it replaces the nested-loop join that turns O(n) into O(n × m).

A generator expression uses parentheses and produces values lazily:

total = sum(r.marks_obtained for r in results if not r.is_absent)

any(s.status == "Inactive" for s in students)
all(r.marks_obtained >= 35 for r in results)

Use a generator inside sum, any, all and max. It builds no intermediate list, and for any/all it stops at the first decisive item.

When not to use one

# Unreadable — use a loop
result = [transform(x) for sublist in matrix for x in sublist
if condition(x) and other(x) or fallback(x)]

A comprehension should fit on one or two lines and do one thing. Two for clauses plus a compound condition is a loop written badly.

Never use one purely for a side effect:

# Wrong — builds a list of None just to print
[print(s.name) for s in students]

# Right
for student in students:
print(student.name)

Unpacking

first, second, third = students

name, roll = "Ravi Kumar", "NCA-2024-0012"

a, b = b, a # swap, no temporary

first, *rest = students
*most, last = students
school, year, sequence = roll_number.split("-")
for index, (name, marks) in enumerate(zip(names, marks_list), start=1):
print(f"{index}. {name}: {marks}")

Unpacking makes loops over pairs read naturally, and the *rest form avoids slicing.

Errors you will hit

MessageCauseFix
IndentationError: expected an indented blockBody missing after if/for/defIndent the body
if marks: skips a genuine 00 is falsyTest if marks is not None:
TypeError: '<' not supported between 'NoneType' and 'int'Compared None with a numberCheck for None first
Loop variable leaks after the loopPython scoping — expectedDo not rely on it
RuntimeError: dictionary changed size during iterationModified while loopingIterate a copy

if marks: is false for 0. For anything where zero is a real value, test is not None — the same absent-versus-zero rule as everywhere else in this curriculum.

Common mistakes

  • else if instead of elif
  • The absent check placed after the grade checks
  • range(1, 12) when 12 should be included
  • Iterating by index instead of by item
  • zip silently truncating without strict=True
  • Removing from a list while iterating it
  • A while whose condition never changes
  • Misreading loop else as "runs otherwise"
  • No case _ in a match
  • A comprehension used for side effects
  • A comprehension too complex to read
  • or for a default where 0 or "" are valid
  • Building a list where a generator would do

Practice

The course exercise is solve logic problems.

  1. Write a grading function with the absent check first. Reverse the order and confirm an absent student is marked Fail.
  2. Rewrite a four-level nested validation as guard clauses.
  3. Write a menu loop with while True and break.
  4. Loop over class numbers 1 to 12. Confirm range(1, 12) misses one.
  5. Rewrite an index loop as a direct for. Then use enumerate where the index is genuinely needed.
  6. zip two lists of different lengths. Confirm the silent truncation, then add strict=True.
  7. Remove inactive students while iterating. Confirm some are skipped, then fix it two ways.
  8. Use loop else to report "not found" after a search.
  9. Convert three loops into comprehensions.
  10. Build a {roll_number: student} lookup with a dict comprehension.
  11. Sum marks with a generator expression, then with a list comprehension. Compare memory with sys.getsizeof.
  12. Write a match on exam type with case _. Remove the default and pass an unknown value.
  13. Use match to destructure a dictionary with a guard condition.
  14. Unpack a roll number into three variables with split.
  15. Write a comprehension with two for clauses and a compound condition, then rewrite it as a loop and compare readability.

Exercises 1, 6 and 7 correspond to three real defects.

You can now

  • Write correct conditions and loops
  • Say why if x: is wrong when 0 is valid
  • Use comprehensions where they read better than loops
  • Avoid modifying a collection while iterating it
  • Handle None before comparing

Review questions

  1. Why must the absent check come first in a grading chain?
  2. What does else on a for loop actually mean?
  3. Why does removing items while iterating skip elements?
  4. When is a generator expression better than a list comprehension?

Next: Collections