Skip to content

Control Flow

Python conditionals use indentation instead of braces. Any expression that evaluates to a truthy or falsy value can be used as the condition.

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

Since Python 3.10, match/case matches a value’s shape, not just its equality — a value, a type check, a sequence pattern, or a mapping pattern, tried top to bottom, with _ as the catch-all:

def describe(value):
match value:
case 0:
return "zero"
case int() if value < 0:
return "negative int"
case [x, y]:
return f"pair: {x}, {y}"
case {"kind": kind}:
return f"dict with kind={kind}"
case _:
return "something else"
print(describe(0)) # zero
print(describe(-5)) # negative int
print(describe([1, 2])) # pair: 1, 2
print(describe({"kind": "widget"})) # dict with kind=widget

case [x, y] also binds x and y if the pattern matches — this is destructuring, not just comparison, which is why match can replace a chain of isinstance/index checks far more concisely than a plain if/elif chain.

Python’s for loop iterates over any iterable — a list, string, range, file, dictionary, or custom object that implements __iter__.

fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
print(fruit)
# Iterate over characters in a string
for ch in "hello":
print(ch)

Use enumerate() when you need both the index and the value:

for i, fruit in enumerate(fruits, start=1):
print(f"{i}. {fruit}")

for item in iterable: is syntactic sugar. Underneath, Python calls iter(iterable) once to get an iterator, then calls next() on it repeatedly until it raises StopIteration, which the loop catches silently to end:

nums = [10, 20, 30]
it = iter(nums)
print(next(it)) # 10
print(next(it)) # 20
print(next(it)) # 30
next(it) # StopIteration -- iterator exhausted

Knowing this explains behavior that otherwise looks magical: an iterator remembers its position between next() calls (it is stateful), and once exhausted it stays exhausted — calling iter() again on the same iterator object just returns itself, still empty. A fresh for loop over the same iterable (like a list) works again only because iter(a_list) builds a brand-new iterator each time; the same is not true of a generator, which is its own iterator.

A for loop tracks a numeric position into the sequence, not “the items I haven’t visited yet.” Removing an item from a list you are iterating over shifts every later item one slot earlier — which the loop’s position counter does not know about, so it silently skips the item that slid into the position it already passed:

values = [1, 2, 2, 3, 4, 5]
for n in values:
if n % 2 == 0:
values.remove(n)
print(values) # [1, 2, 3, 5] -- the second 2 survived; 4 was removed instead

The loop visits index 0 (1), index 1 (2, removed — the list shrinks and everything shifts left), then index 2 — but the element now sitting at index 2 is the second 2, which used to be at index 3. The loop has already “passed” that position and never revisits it, so that 2 survives while 4 (never actually even, in the pre-mutation sense the code intended) gets removed by coincidence of position. The fix is to iterate over a copy — for n in values.copy(): or for n in list(values): — or build a new list with a comprehension instead of mutating in place: values = [n for n in values if n % 2 != 0].

range(stop), range(start, stop), and range(start, stop, step) generate integer sequences without building a list in memory.

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

range is lazy — a range object stores only its start, stop, and step, and computes each value on demand instead of materializing every integer up front:

import sys
r = range(1_000_000)
print(type(r).__name__) # range
print(sys.getsizeof(r)) # 48 -- three integers, regardless of length
print(sys.getsizeof(list(r))) # 8000056 -- one million actual int objects

A range of a billion numbers costs the same 48 bytes as a range of three. This is why for i in range(len(items)): is memory-cheap even for huge items, and why you should reach for range (or a generator) instead of building a list purely to loop over it once.

while repeats as long as its condition is truthy.

count = 0
while count < 3:
print(count)
count += 1
  • break exits the innermost loop immediately.
  • continue skips the rest of the current iteration and moves to the next.
  • The else clause on a loop runs only if the loop completed without hitting a break — this applies identically to for...else and while...else.
for n in [1, 3, 5, 7]:
if n % 2 == 0:
print("found even")
break
else:
print("no even number found") # this runs

break only exits the innermost loop — Python has no labeled break like some languages do. Breaking out of two nested loops needs a sentinel checked after the inner loop, or moving the whole thing into a function and using return:

found = None
for i in range(3):
for j in range(3):
if i * j == 4:
found = (i, j)
break # only exits the inner `for j` loop
if found:
break # this second break exits the outer loop
print(found) # (2, 2)

Python lets you chain comparisons: a < b < c means a < b and b < c, evaluating b only once. This reads naturally for range checks — but the same chaining rule applies to any comparison operator mixed together, including in, which is where it stops looking like ordinary math:

x, y = 10, 5
lst = [True, False]
print(x < y in lst) # (x < y) and (y in lst) -> False and False -> False
print((x < y) in lst) # bool(x < y) tested for membership -> False in lst -> True

a < b in c is not (a < b) in c — it is a < b and b in c, because Python treats in as just another comparison operator that can participate in chaining. If you actually meant “is the boolean result of a < b a member of c”, you must parenthesize (a < b) in c explicitly.

Floor division (//) always rounds toward negative infinity, not toward zero — and % is defined so that a == (a // b) * b + (a % b) always holds:

print(-7 // 2, -7 % 2) # -4 1
print(7 // -2, 7 % -2) # -4 -1

-7 // 2 is -4, not -3 — most languages with C-style truncating division give -3 here, which is a common source of off-by-one bugs when porting code between languages. The sign of %’s result always matches the sign of the divisor, not the dividend.

Introduced in Python 3.8, := assigns a value and evaluates to that value in the same expression. It is most useful in while and if conditions to avoid repeating an expression.

data = [4, 7, 2, 9, 1]
if (largest := max(data)) > 5:
print(f"Largest value {largest} exceeds 5")

A lambda (or nested def) created inside a loop does not capture the loop variable’s value at creation time — it captures the name, looked up fresh every time the function actually runs. By the time any of the closures are called, the loop has finished and the name holds its final value:

funcs = [lambda: i for i in range(3)]
print([f() for f in funcs]) # [2, 2, 2] -- every closure reads the SAME i

All three lambdas share one enclosing scope and one variable i; none of them stored 0, 1, or 2 — they all look up i when called, by which point the loop has ended and i is 2. The idiomatic fix binds the value at each iteration as a default argument, which is evaluated once, immediately, at function-definition time:

funcs_fixed = [lambda i=i: i for i in range(3)]
print([f() for f in funcs_fixed]) # [0, 1, 2] -- each default was bound at creation time
score = 85
if score >= 90:
grade = "A"
elif score >= 80:
grade = "B"
elif score >= 70:
grade = "C"
else:
grade = "F"
print(f"Score {score} -> Grade {grade}")
total = 0
for i in range(1, 6):
total += i
print(f"Sum 1..5 = {total}")
numbers = [1, 3, 5, 7]
for n in numbers:
if n % 2 == 0:
print(f"Found even: {n}")
break
else:
print("No even numbers found")
count = 0
while True:
count += 1
if count % 2 == 0:
continue
if count > 7:
break
print(f"odd: {count}")
data = [4, 7, 2, 9, 1]
if (largest := max(data)) > 5:
print(f"Largest value {largest} exceeds 5")

The second playground is a tour of the pitfalls above: the iterator protocol, mutating while iterating, range laziness, the chained-comparison trap, negative floor division, nested-loop break, and late-binding closures.

import sys
# 1. match (structural pattern matching)
def describe(value):
match value:
case 0:
return "zero"
case int() if value < 0:
return "negative int"
case [x, y]:
return f"pair: {x}, {y}"
case {"kind": kind}:
return f"dict with kind={kind}"
case _:
return "something else"
print(describe(0))
print(describe(-5))
print(describe([1, 2]))
print(describe({"kind": "widget"}))
print(describe("hello"))
# 2. for runs on iter()/next().
nums = [10, 20, 30]
it = iter(nums)
print(next(it))
print(next(it))
print(next(it))
try:
next(it)
except StopIteration:
print("StopIteration -- iterator exhausted")
# 3. Mutating a list while iterating over it.
values = [1, 2, 2, 3, 4, 5]
for n in values:
if n % 2 == 0:
values.remove(n)
print("after removing evens while iterating:", values)
# 4. range() is lazy.
r = range(1_000_000)
print("type:", type(r).__name__)
print("range size:", sys.getsizeof(r))
print("list size:", sys.getsizeof(list(r)))
# 5. Chained comparison and the "a < b in c" trap.
x, y = 10, 5
lst = [True, False]
print("chained x < y in lst:", x < y in lst) # (x < y) and (y in lst)
print("(x < y) in lst:", (x < y) in lst) # boolean tested for membership
# 6. // and % with negative numbers.
print(-7 // 2, -7 % 2)
print(7 // -2, 7 % -2)
# 7. while / else.
count = 0
while count < 3:
print("while count:", count)
count += 1
else:
print("while/else: loop finished without break")
# 8. break only exits the innermost loop.
found = None
for i in range(3):
for j in range(3):
if i * j == 4:
found = (i, j)
break
if found:
break
print("found:", found)
# 9. Late-binding closures in a loop.
funcs = [lambda: i for i in range(3)]
print("late binding:", [f() for f in funcs])
funcs_fixed = [lambda i=i: i for i in range(3)]
print("fixed with default arg:", [f() for f in funcs_fixed])

for item in iterable: is a small state machine, not a magic sweep over “the items.” Python asks the iterable for one iterator (iter()), then repeatedly asks that iterator for the next value (next()) until it signals StopIteration. The iterator alone tracks position — the iterable itself is never asked “what’s after the thing you gave me last time.”

flowchart LR
  A["iter(iterable)"] --> B["iterator object"]
  B --> C{"next(iterator)"}
  C -->|"value"| D["run loop body"]
  D --> C
  C -->|"StopIteration"| E["loop ends"]
for desugars to: get an iterator once, then call next() until StopIteration
What you getBenefitCost
for built on iter()/next()Works uniformly on lists, files, generators, custom objectsMutating the container mid-iteration silently skips or repeats elements
Lazy rangeO(1) memory no matter how large the rangeNot a list — no .append(), and slicing/indexing recompute rather than look up storage
Chained comparisonsConcise range checks (0 <= x < 10)Any comparison operator can chain, including in, which reads differently than expected
Loop elseReplaces a manual “found” flag variableEasy to misread as “runs when the loop ran zero times” — it does not mean that
match structural patternsDestructures and matches shape in one place, replacing nested isinstance/index checksNewer syntax (3.10+); a case variable name always binds, it never compares against an existing variable’s value by accident
  • “You can safely remove items from a list while looping over it with for.” You cannot — removal shifts later elements into already-visited positions, silently skipping some of them. Iterate over a copy, or build a new list instead.
  • “range(1_000_000) allocates a million integers immediately.” It does not — a range object stores only start/stop/step and computes values lazily; only list(range(...)) allocates them all.
  • “a < b in c means (a < b) in c.” It means a < b and b in c — in participates in Python’s comparison chaining just like < does.
  • “A lambda in a loop captures the loop variable’s current value.” It captures the name, resolved when the lambda is called, not when it is created — by which time the loop has usually finished.
  • “for...else runs the else block if the loop ran zero times.” It runs the else block whenever the loop finishes without hitting break — including when it ran zero times, but also every other time nothing broke it.
  • Does a loop call .remove(), .pop(), del, or .append() on the exact same list it is iterating over? That is almost always a bug — check whether it should iterate over a copy or build a new list instead.
  • Does a lambda or nested function defined inside a loop reference the loop variable directly, instead of capturing it as a default argument? That is late-binding waiting to surprise every caller.
  • Does a chained comparison include in, is, or mixed operators in a way that might not mean what it looks like it means?
  • Does integer division or modulo on a value that could be negative assume “toward zero” truncation instead of Python’s “toward negative infinity” floor behavior?
  • Does a nested loop’s break actually reach the outer loop, or does the code assume break exits everything at once?

🐛 Spot the bug

def remove_duplicates(items):
for item in items:
if items.count(item) > 1:
items.remove(item)
return items
Show the answer

This loop mutates items — the exact list it is iterating over — every time it removes a duplicate. Removing an element shifts every later element one position earlier, but the loop’s position counter has no idea that happened, so it can skip the very next element or revisit one it already handled. The result depends on the input in ways that are hard to predict, and duplicates can survive. Fix it by iterating over a copy while still mutating (or building) the real list: for item in items.copy(): ..., or better, build a fresh de-duplicated list without mutating the input at all, e.g. list(dict.fromkeys(items)) to remove duplicates while preserving order.

What does this print?
values = [1, 2, 2, 3, 4, 5]
for n in values:
    if n % 2 == 0:
        values.remove(n)
print(values)
What does this print?
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])
What does x < y in lst actually evaluate as, for values x, y, and a list lst?
What does -7 // 2 evaluate to in Python?
Why does sys.getsizeof(range(1_000_000)) return a small, constant-looking number?
When does the else clause of a for or while loop execute?