Control Flow
Conditionals: if / elif / else
Section titled “Conditionals: if / elif / else”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) # BStructural pattern matching: match
Section titled “Structural pattern matching: match”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)) # zeroprint(describe(-5)) # negative intprint(describe([1, 2])) # pair: 1, 2print(describe({"kind": "widget"})) # dict with kind=widgetcase [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.
for loops over iterables
Section titled “for loops over iterables”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 stringfor 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}")What for actually does: iter() and next()
Section titled “What for actually does: iter() and next()”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)) # 10print(next(it)) # 20print(next(it)) # 30next(it) # StopIteration -- iterator exhaustedKnowing 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.
Mutating a list while iterating over it
Section titled “Mutating a list while iterating over it”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 insteadThe 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()
Section titled “range()”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__) # rangeprint(sys.getsizeof(r)) # 48 -- three integers, regardless of lengthprint(sys.getsizeof(list(r))) # 8000056 -- one million actual int objectsA 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 loops
Section titled “while loops”while repeats as long as its condition is truthy.
count = 0while count < 3: print(count) count += 1break, continue, and loop else
Section titled “break, continue, and loop else”breakexits the innermost loop immediately.continueskips the rest of the current iteration and moves to the next.- The
elseclause on a loop runs only if the loop completed without hitting abreak— this applies identically tofor...elseandwhile...else.
for n in [1, 3, 5, 7]: if n % 2 == 0: print("found even") breakelse: print("no even number found") # this runsbreak 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 = Nonefor 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 loopprint(found) # (2, 2)Chained comparisons
Section titled “Chained comparisons”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, 5lst = [True, False]
print(x < y in lst) # (x < y) and (y in lst) -> False and False -> Falseprint((x < y) in lst) # bool(x < y) tested for membership -> False in lst -> Truea < 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.
// and % with negative numbers
Section titled “// and % with negative numbers”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 1print(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.
The walrus operator :=
Section titled “The walrus operator :=”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")Late-binding closures in loops
Section titled “Late-binding closures in loops”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 iAll 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 timeFull runnable demo
Section titled “Full runnable demo”score = 85if score >= 90: grade = "A"elif score >= 80: grade = "B"elif score >= 70: grade = "C"else: grade = "F"print(f"Score {score} -> Grade {grade}")
total = 0for i in range(1, 6): total += iprint(f"Sum 1..5 = {total}")
numbers = [1, 3, 5, 7]for n in numbers: if n % 2 == 0: print(f"Found even: {n}") breakelse: print("No even numbers found")
count = 0while 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")Loading Python runtime (first run only)…
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, 5lst = [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 = 0while count < 3: print("while count:", count) count += 1else: print("while/else: loop finished without break")
# 8. break only exits the innermost loop.found = Nonefor i in range(3): for j in range(3): if i * j == 4: found = (i, j) break if found: breakprint("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])Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”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"] Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
for built on iter()/next() | Works uniformly on lists, files, generators, custom objects | Mutating the container mid-iteration silently skips or repeats elements |
Lazy range | O(1) memory no matter how large the range | Not a list — no .append(), and slicing/indexing recompute rather than look up storage |
| Chained comparisons | Concise range checks (0 <= x < 10) | Any comparison operator can chain, including in, which reads differently than expected |
Loop else | Replaces a manual “found” flag variable | Easy to misread as “runs when the loop ran zero times” — it does not mean that |
match structural patterns | Destructures and matches shape in one place, replacing nested isinstance/index checks | Newer syntax (3.10+); a case variable name always binds, it never compares against an existing variable’s value by accident |
Common Misconceptions
Section titled “Common Misconceptions”- “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 — arangeobject stores only start/stop/step and computes values lazily; onlylist(range(...))allocates them all. - “
a < b in cmeans(a < b) in c.” It meansa < b and b in c—inparticipates in Python’s comparison chaining just like<does. - “A
lambdain 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...elseruns theelseblock if the loop ran zero times.” It runs theelseblock whenever the loop finishes without hittingbreak— including when it ran zero times, but also every other time nothing broke it.
Reading AI-generated code
Section titled “Reading AI-generated code”- 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
lambdaor 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
breakactually reach the outer loop, or does the code assumebreakexits everything at once?
🐛 Spot the bug
def remove_duplicates(items): for item in items: if items.count(item) > 1: items.remove(item) return itemsShow 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.