Skip to content

Control flow

docs.scrimba.com

Every program you have written so far runs the same way every time: top to bottom, one line at a time. That works for simple scripts, but real programs need to make decisions and repeat work. A quiz needs to check whether the answer is right. A game needs to keep running until the player wins or loses. This chapter covers how to make your program branch and repeat.

Control flow shapes the path your program takes. Conditions (if/elif/else) pick between branches; loops (while, for) repeat a block. Python's for walks through items one at a time rather than counting an index, which is why it loops over so many things cleanly. This chapter covers when to reach for each tool and the mistakes each one invites.

Python gives you three control flow tools: if/elif/else to branch, and while and for to repeat. A for loop walks an iterable (anything you can step through one item at a time, like a list or a file) and stops on its own when the items run out, so you rarely manage an index by hand. A while loop keeps going as long as its condition stays truthy. break and continue only touch the nearest loop around them, and the loop else clause (covered later) runs only when a loop finishes without a break. The rest of this chapter is about choosing the right one and avoiding the failure modes each brings.

Comparisons

Before you can make a decision, you need to compare things. Comparison operators return True or False. The most important one to get right early: = assigns a value, == checks whether two values are equal. Mixing them up is one of the most common beginner mistakes.

Comparison operators return a bool, and Python lets you chain them: 0 < x < 10 means 0 < x and x < 10, which most other languages read left-to-right and get wrong. Strings compare character by character in dictionary order, so uppercase sorts before lowercase. Use == to compare values; you almost never want is, which asks a different question (covered in the Deep Dive).

Comparisons return a bool, and two details matter in real code. First, Python supports chained comparisons: a < b < c reads as a < b and b < c, and the middle value b is evaluated only once, which is worth knowing if it is an expensive call. Second, == checks whether two values are equal, while is checks identity (whether two names point at the exact same object in memory). They are not interchangeable: compare values with ==, and keep is for None, True, and False, where you actually want the one-and-only object. Reaching for is on strings or numbers leads to bugs that pass on small inputs and fail on large ones.

python
5 > 3     # True
5 < 3     # False
5 == 5    # True   (note: double equals; = is assignment, == is comparison)
5 != 3    # True   ("not equal to")
5 >= 5    # True   ("greater than or equal to")
5 <= 4    # False  ("less than or equal to")

The = vs == distinction trips up almost everyone early on. Assignment (=) stores a value; comparison (==) checks whether two values are the same.

You can compare strings too. Python compares them alphabetically, character by character, which the Strings chapter covers in more detail:

python
"apple" == "apple"   # True
"apple" < "banana"   # True  (a comes before b)
"apple" == "Apple"   # False (case-sensitive)
JunoComparisons= stores a value, == checks whether two values are equal. I still slow down and double-check that one when I'm tired, so you're in good company if it trips you up. Comparisons hand back True or False, and they work on strings too, alphabetically.
JunoComparisons Comparisons return a bool. Chained forms like 0 < x < 10 read as 0 < x and x < 10, which most other languages get wrong. String comparison is by Unicode order, so it sorts case-sensitively.
JunoComparisons Use == for value, is for identity, and keep is to None, True, and False. The trap is is on strings or ints: it passes on small inputs and fails on large ones. Chained comparisons evaluate the middle term once, handy when it's expensive.

Combining conditions

and, or, and not combine comparisons. and requires both sides to be true. or requires at least one side. not flips the result. These let you express real-world conditions like "score is passing AND user is active".

and and or short-circuit: and stops at the first falsy operand, or stops at the first truthy one. They return that actual operand, not always a bare True or False, which is why name or "guest" is a clean way to supply a fallback. not always gives you back a plain bool.

and and or do not return True or False, they return one of the operands. and returns the first falsy operand, or the last one if all are truthy; or returns the first truthy operand, or the last if all are falsy. The right side is not evaluated once the left settles the result, and that short-circuit guarantee (Python stops as soon as the answer is known) is something you build on: user and user.name reads user.name only when user exists, so it never errors on a missing value. The flip side is a real failure mode: if the right side has a side effect you were counting on, like a function that logs or writes, it silently never runs when the left side short-circuits.

python
age = 25
score = 88

age >= 18 and score >= 80    # True  (both must be true)
age < 18 or score >= 80      # True  (at least one must be true)
not age >= 18                # False (flips the result)

and requires both sides. or requires at least one side. not inverts.

JunoCombining conditionsand wants both sides true, or wants at least one, not flips the answer. That covers most real conditions you'll write, like "score is passing and the user is active". Python stops checking the moment it knows the result.
JunoCombining conditionsand stops at the first falsy value, or at the first truthy one, and both return that actual operand, not always a bare True/False. That short-circuiting is why x and x.method() stays safe when x is empty.
JunoCombining conditionsand/or return an operand, not a bool, and the right side is skipped once the left decides the outcome. Lean on it for guards like user and user.name. Watch the other edge: a side effect on the right side quietly never fires when it short-circuits.

Truthy and falsy

Every value in Python has a boolean interpretation, even if it is not True or False. Empty strings, zero, empty lists, and None all behave like False in a condition. Everything else behaves like True. This means if results: checks whether a list is non-empty without writing if len(results) > 0:.

Python's falsy values are False, 0, 0.0, "", [], (), {}, set(), and None. Everything else is truthy, so an empty container is falsy and a non-empty one is truthy. That is what makes if results: the idiomatic emptiness check: write it instead of if len(results) > 0:.

When you put an object in a condition, Python asks it for a yes-or-no answer by calling its __bool__ method (a dunder, one of the double-underscore methods Python calls behind the scenes). If a type does not define __bool__, Python falls back to __len__, so anything with length zero counts as false. The standard falsy values are False, 0, 0.0, "", empty containers ([], (), {}, set()), and None; everything else is truthy. The trap to watch for: if results: is false for an empty list and also for None, so if you specifically need to tell "empty" apart from "not set yet", test if results is None: rather than relying on truthiness.

python
# These all behave like False in a condition:
False, 0, 0.0, "", [], {}, (), None

# Everything else behaves like True

This means if results: is a natural way to say "if the list is not empty", and if name: checks whether a string has any content.

JunoTruthy and falsy Empty things and zero count as False in a condition: 0, "", [], and None. Everything else counts as True. That's why if results: reads so cleanly as "if there's anything in the list", no len() needed.
JunoTruthy and falsy Falsy is False, zero, empty strings, empty containers, and None; everything else is truthy. So an empty list is falsy and a full one is truthy. That's the whole reason if my_list: works as an emptiness check, no len() needed.
JunoTruthy and falsy Conditions call __bool__, then __len__ if that's missing, so your own classes can pick their truthiness. The catch: if results: can't tell an empty list from None. When that distinction matters, reach for is None instead.

if / elif / else

The if statement runs a block of code only when its condition is True. elif adds more conditions to check if the first one was false. else catches everything that did not match any condition. Python uses indentation, not braces, to define what belongs inside each block.

if/elif/else evaluates conditions top to bottom and runs the first matching block. Python uses indentation (4 spaces by convention) to define block scope; inconsistent indentation is a SyntaxError. Only one branch runs: once a condition matches, all subsequent elif and else are skipped.

Python checks the conditions top to bottom and runs exactly one branch: the first whose condition is truthy, or the else if none match. Each condition is evaluated lazily, meaning Python only tests it if every condition above it was false, so order is part of the logic. Put the cheapest or most likely test first, and order overlapping ranges so the tighter bound comes before the looser one, or a later branch becomes unreachable. Indentation is the block structure here, not a style choice, so mixing tabs and spaces is a TabError rather than a silent shift in meaning.

python
score = 87

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

The rules:

  • if is required and always comes first
  • elif (short for "else if") is optional and you can have as many as you need
  • else is optional, handles everything that did not match, and comes last
  • Python uses indentation (4 spaces) to mark what belongs inside each block; there are no braces

The indentation is not optional or cosmetic. Python uses it to define structure. Inconsistent indentation is a syntax error.

Junoif / elif / elseif runs its block when the condition is true, elif checks more cases, else catches the rest. Only the first match runs. The thing that caught me out early: indentation is what tells Python where each block starts and ends, so keep it consistent.
Junoif / elif / else Conditions are checked top to bottom and only the first match runs, so order matters: put narrower ranges before wider ones. Indentation (4 spaces) marks the block, and mixing it up is a SyntaxError, not a quiet bug.
Junoif / elif / else Conditions evaluate lazily, top down, so put the cheapest or most likely test first and order overlapping bounds tight-before-loose or a branch goes dead. Indentation is structure, not style, and tabs-vs-spaces is a TabError rather than a silent shift.

One-line conditions

For simple yes/no assignments, Python has a compact one-line form called a ternary expression: value_if_true if condition else value_if_false. Use it only when the logic is small and reads like a sentence.

The conditional expression (ternary operator) evaluates to one of two values based on a condition. It is an expression, not a statement, so it can appear anywhere a value is expected: inside an f-string, as a function argument, in an assignment. Use it for simple yes/no cases; for anything involving elif, write the full version.

The conditional expression x if condition else y evaluates the condition and returns one side without running the other, so it is safe to use where one branch would otherwise error. Because it is an expression, it slots in anywhere a value goes: an f-string, a function argument, a default. The discipline is knowing when to stop. It cannot hold an elif and chaining it (a if p else b if q else c) reads worse than the block it replaces, so once you have more than one decision, reach for the full if/elif/else. The reader who maintains this later will thank you.

python
label = "pass" if score >= 50 else "fail"

This is a ternary expression; it reads like a sentence. Use it when the logic is small. For anything involving elif, write the full version.

JunoOne-line conditionsvalue_if_true if condition else value_if_false picks one of two values on a single line. It's lovely for tidy little choices like "pass" if score >= 50 else "fail". The moment the logic grows past that, go back to a normal if block, it'll read better.
JunoOne-line conditions The ternary is an expression, so it fits anywhere a value does: an f-string, an argument, an assignment. Use it for simple two-way choices. Anything with an elif in it belongs in a full block.
JunoOne-line conditionsx if cond else y runs only the side it picks, so it's safe where one branch would raise, and it drops into any expression slot. Don't chain them: stacked ternaries read worse than the block they replace, so past one decision, write it out.

while loops

A while loop repeats its block as long as its condition is True. Use it when you do not know in advance how many times the loop should run, for example waiting for valid input or retrying until a job succeeds.

while evaluates its condition before each iteration and runs the block only when the condition is truthy. Use it for loops where the exit condition depends on something that changes inside the loop. When the number of iterations is known or you are iterating a collection, for is usually cleaner.

while re-tests its condition before every pass, and the body is what changes that condition, so the failure mode is structural: forget to move toward the exit (decrement the counter, consume the queue) and you get a loop that never ends and a process that hangs. The idiomatic shape when the exit has to be decided in the middle or end of the body is while True with an interior break, which keeps the condition where it actually happens instead of forcing it into the header. For anything walking a collection or running a known number of times, a for loop is cleaner and removes the chance of an off-by-one in the counter.

python
lives = 3

while lives > 0:
    print(f"Lives remaining: {lives}")
    lives -= 1

print("Game over")

while is best when you do not know in advance how many times the loop will run. When you do know, or when you are iterating over a collection, for is cleaner.

Junowhile loopswhile keeps running its block as long as the condition stays true. Reach for it when you don't know how many rounds you need, like waiting for valid input. Make sure something inside the loop moves toward the exit, or it'll run forever, a mistake I've made more than once.
Junowhile loopswhile checks its condition before each pass and the body is what changes it. Use it when the exit depends on something happening inside the loop. If you're counting or walking a collection, for is the cleaner pick.
Junowhile loops The body has to move toward the exit or you get a hang, so that's the first thing to check. while True with an interior break is the clean shape when the exit lands mid-body. For a known count or a collection, prefer for and skip the off-by-one risk.

break and continue

break exits the loop immediately, no matter how many iterations remain. continue skips the rest of the current iteration and jumps back to the condition check. Both only affect the innermost loop they are inside.

break terminates the nearest enclosing loop, transferring control to the first statement after it. continue skips the remainder of the current loop body and restarts from the condition check (or the next iteration in a for loop). Both only affect the innermost enclosing loop.

break leaves the loop at once and skips its else clause if it has one; continue jumps straight back to the loop header for the next pass. Both bind to the nearest enclosing loop, and Python has no labelled break, so they cannot reach an outer loop from inside an inner one. When you need to escape several levels at once, the clean move is to lift the nested loops into a function and return, which reads better than threading a flag variable through both. A flag works, but every extra if found: you add to dodge the missing label is a place a future edit can get it wrong.

break exits the loop immediately:

python
target = 5
num = 0

while True:
    num += 1
    if num == target:
        print(f"Found {target}")
        break   # stop the loop

while True: with a break is a valid and common pattern when the exit condition is complex or needs to happen at the end of the loop body.

continue skips the rest of the current iteration and goes back to the condition check:

python
num = 0

while num < 10:
    num += 1
    if num % 2 == 0:
        continue    # skip even numbers
    print(num)      # only odd numbers print: 1, 3, 5, 7, 9
Junobreak and continuebreak jumps out of the loop right away, continue skips the rest of this round and goes back to check the condition. Both only touch the loop they're directly inside. while True: with a break reads odd at first, but it's a normal way to loop until something happens.
Junobreak and continuebreak leaves the nearest loop, continue restarts it from the condition. Neither reaches an outer loop. To bail out of nested loops, set a flag or pull the inner loop into a function and return.
Junobreak and continue Both bind to the nearest loop, and there's no labelled break, so escaping several levels means a flag or, cleaner, a function with return. break also skips the loop's else, which is exactly what the search pattern relies on.

for loops

A for loop goes through a sequence one item at a time: a list, a string, a range of numbers. The variable you name after for receives each item in turn. You do not manage a counter or check the length yourself.

for asks the thing you give it for one item at a time and stops when the items run out, rather than counting through index positions. That is why it works on far more than lists: strings, dictionaries, ranges, and file objects all loop the same way, with no length check or counter on your side. Reach for for over while whenever you are walking a collection.

for does not count indexes, it asks the thing you give it for an iterator (an object that hands out one item at a time and remembers where it stopped) and pulls items until they run out. The practical payoff is that for works on far more than lists: strings, dictionaries, ranges, file objects, and lazy sources like generators (sequences computed on demand, never all held in memory) all loop the same way. That last group is where the one gotcha lives: a lazy iterator is single-use, so once a for loop drains a generator or a file, looping it again gives you nothing. If you need the items twice, materialise them into a list first or open the source again.

python
players = ["Alice", "Bob", "Charlie"]

for player in players:
    print(f"Hello, {player}!")

for loops also work on strings (iterating character by character) and on any other sequence type.

Junofor loopsfor item in sequence walks each item in turn, and you never touch a counter or a length yourself. It works on lists, strings, ranges, lots of things. Coming from other languages, not managing the index felt strange to me at first, then it felt like a gift.
Junofor loopsfor pulls items one at a time until they run out, so it isn't tied to indexed sequences. Lists, strings, dicts, ranges, files: same loop for all of them. No index bookkeeping, no length checks.
Junofor loopsfor drives any iterator, so lists, files, and generators all loop alike. The catch is the lazy ones: a generator or file iterator is single-use, drained after one pass. Need the items twice? Pull them into a list first or reopen the source.

range()

range() generates a sequence of numbers for you to loop over. range(5) gives you 0, 1, 2, 3, 4. You can control the start, end, and step size. Use it when you need a loop to run a specific number of times.

range(start, stop, step) produces integers from start up to (but not including) stop, stepping by step. It is a lazy sequence: it does not create a list, it generates numbers on demand. This makes range(10_000_000) memory-efficient. All three forms accept negative arguments for reverse counting.

A range never builds the list of numbers, it stores only the start, stop, and step and works out each value as it goes. That makes range(10_000_000) cost the same memory as range(10), so when you only need to iterate, loop the range directly instead of wrapping it in list() and paying for ten million stored integers. It still behaves like a sequence where it counts: you can index it, slice it, ask len(), and test membership with in, all cheaply. Calling list(range(n)) is only worth it when you actually need a real, reusable list to hold onto.

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

range() has three forms:

CallWhat it produces
range(5)0, 1, 2, 3, 4
range(2, 6)2, 3, 4, 5
range(0, 10, 2)0, 2, 4, 6, 8 (step of 2)
range(5, 0, -1)5, 4, 3, 2, 1 (counting down)

range() does not create a list. It produces numbers one at a time, which is efficient even for very large ranges.

Junorange()range(5) gives you 0, 1, 2, 3, 4, and you can set a start, stop, and step. Reach for it when you want a loop to run a set number of times. It doesn't build a list, it hands you one number per round.
Junorange()range(start, stop, step) counts from start up to but not including stop. It's a lazy sequence, so it makes numbers on demand rather than building a list, which is why range(10_000_000) costs almost nothing. Negative steps count down.
Junorange() A range stores only start, stop, and step, so it's flat memory no matter how big. Loop it directly instead of list(range(n)) unless you really need a reusable list. It still indexes, slices, and supports in and len() cheaply.

enumerate()

enumerate() gives you both the index and the value while you loop, so you do not need to track a counter separately. The i, player part automatically receives a pair of values on each iteration.

enumerate(iterable, start=0) wraps any iterator and yields (index, value) tuples. The start parameter offsets the counter but does not change the underlying index. Prefer enumerate() over managing a counter variable; it is cleaner and less error-prone.

enumerate wraps any iterable and hands back (count, value) pairs, which the for header splits into two names through unpacking (Python assigning each part of a tuple to its own name in one step). It is lazy and adds nothing beyond the counter, so it costs the same as the bare loop. The reason to always reach for it over a hand-managed index = 0; index += 1 is reliability: the manual version invites the bug where you forget the increment or bump it in the wrong place, and enumerate removes that whole class of mistake. Pass start=1 when you are numbering output for people, who count from one, not zero.

python
players = ["Alice", "Bob", "Charlie"]

for i, player in enumerate(players):
    print(f"{i + 1}. {player}")
# 1. Alice
# 2. Bob
# 3. Charlie

The i, player syntax is called unpacking. Python splits the (index, value) pair into two names automatically.

By default enumerate() starts at 0. Pass a start value to change that:

python
for i, player in enumerate(players, start=1):
    print(f"{i}. {player}")    # starts at 1
Junoenumerate()enumerate() hands you the position and the value together, so no separate counter to keep in sync. The i, player part catches both at once. Pass start=1 when you're numbering a list for someone to read.
Junoenumerate()enumerate(iterable, start=0) yields (index, value) pairs that unpack right in the for header. Prefer it to a counter variable, it's cleaner and you can't forget to increment it. start only shifts the displayed number, not the real index.
Junoenumerate() Lazy, free beyond the counter, and it deletes the whole "forgot to increment" bug a manual index invites, so reach for it by default. Each item is a tuple, which is why the for i, v unpacking works. start=1 for human-facing numbering.

Nested loops

You can put a loop inside another loop. The inner loop runs fully for each single iteration of the outer loop. This is how you process grids, combinations, or any data with two levels of structure.

Nested loops have an O(m × n) iteration count for outer length m and inner length n. break and continue inside a nested loop only affect the innermost loop. For breaking out of multiple levels, use a flag variable or restructure into a function.

Nested loops multiply: an outer loop of length m around an inner of length n runs the body m times n times, so the cost climbs fast and a pair of loops over large inputs is the usual culprit behind a slow script. Before reaching for a third level, ask whether the data structure is the real fix (a dictionary lookup can collapse an inner search loop). When you only need every combination of two sequences (a Cartesian product, every pairing of one with the other), itertools.product from the standard library reads better than the nesting. And remember break exits only the inner loop: to leave both, lift them into a function and return.

python
rows = [1, 2, 3]
cols = ["A", "B"]

for row in rows:
    for col in cols:
        print(f"{col}{row}", end=" ")
    print()   # newline after each row
# A1 B1
# A2 B2
# A3 B3

break and continue inside a nested loop only affect the innermost loop.

JunoNested loops A loop inside a loop runs the whole inner loop for every single round of the outer one. That's how you handle grids and combinations. break and continue only act on the loop they sit in, the inner one here.
JunoNested loops The inner loop runs in full per outer step, so the count is m times n, watch it on large inputs. break and continue only reach the innermost loop. To escape both, use a flag or pull them into a function and return.
JunoNested loops Costs multiply, so nested loops over big inputs are the usual slow spot; sometimes a dict lookup replaces the inner one entirely. For every pairing, itertools.product beats the nesting. break hits only the inner loop, so a function with return is the clean way out of both.

Loop-else

Python loops can have an else clause that runs only if the loop finished without hitting a break. It is not commonly used, but it is the cleanest way to write "search a list, and if nothing was found, do this".

The else on a for or while loop executes if the loop completes normally (exhausts the iterable or condition becomes false) without hitting a break. It is the idiomatic pattern for "search and report if not found" without needing a separate found-flag variable.

Loop else runs only when the loop finishes without a break, which is the opposite of what most people read it as (it is not "else, the loop did not run"). That naming is exactly why it confuses readers, so the rule in production is to use it for one thing and one thing only: the search-and-not-found pattern, where break marks a hit and the else reports the miss. It replaces the found = False flag you would otherwise carry through the loop. Anywhere else, the cleverness costs more in confused reviewers than it saves, so a comment on the else is worth the line.

python
target = "Dave"
names = ["Alice", "Bob", "Charlie"]

for name in names:
    if name == target:
        print(f"Found {target}")
        break
else:
    print(f"{target} not in list")   # runs because break never fired

If break runs, the else is skipped. If the loop exhausts the sequence, else runs. It is a niche pattern but cleaner than a flag variable.

JunoLoop-else A loop can have an else that runs only when the loop ends without a break. It's not used often, but it's the neatest way to say "search this list, and if nothing matched, do this". The name is a little misleading, so a short comment helps.
JunoLoop-else The loop else fires when the loop finishes normally, no break. It's the tidy "search and report if not found" pattern, no found-flag needed. Keep it to that one use, since the name throws people off.
JunoLoop-else It runs when the loop ends without a break, which is the reverse of how the word reads. Keep it to the search-and-not-found case, where it replaces a found = False flag, and comment it. Anywhere else it costs more in confused reviewers than it saves.

Sorting

sorted() returns a new sorted list and leaves the original unchanged. .sort() sorts the list in place and returns None. The key= argument lets you sort by something other than the raw value. For example, sorting names case-insensitively or sorting player tuples by their score.

sorted() is the safe default: it never modifies the original. .sort() modifies in place and returns None. Both accept reverse=True for descending order. The key= argument takes a function applied to each element before comparison. This separates the sorting criterion from the data.

Python's sort is stable, meaning items that compare equal keep their original order, which is what lets you sort in stages: sort by name first, then by score, and players with the same score stay in name order. The key= function runs once per element, not once per comparison, so an expensive key is computed n times, not n log n times, and you rarely need to cache it yourself. .sort() returns None on purpose, to stop you writing scores = scores.sort() and silently wiping your list, so the in-place call must stand on its own line. Reach for sorted() by default and keep .sort() for when you actually want to mutate the original.

python
scores = [87, 42, 96, 55, 71]

ranked = sorted(scores)           # [42, 55, 71, 87, 96] (new list)
scores.sort()                     # sorts the original list, returns None
scores.sort(reverse=True)         # [96, 87, 71, 55, 42]

Both accept a key= argument: a function applied to each item before comparison:

python
names = ["Charlie", "Alice", "Bob"]
sorted(names, key=str.lower)       # case-insensitive sort

players = [("Alice", 87), ("Bob", 96), ("Charlie", 55)]
sorted(players, key=lambda p: p[1])   # sort by score

lambda p: p[1] is a one-line function: it takes a player tuple and returns the score. Lambdas get full treatment in the Lambdas and comprehensions chapter.

For simple cases, use sorted(). For lists where you want to modify in place, use .sort().

JunoSortingsorted() hands back a new sorted list and leaves the original alone, .sort() rearranges the list itself and returns None. Pass key= to sort by something other than the raw value, like names case-insensitively. When in doubt, reach for sorted(), it's the safe one.
JunoSortingsorted() never touches the original, .sort() mutates in place and returns None. Both take reverse=True and a key= function applied to each element before comparing. Default to sorted(); use .sort() only when you mean to change the list.
JunoSorting Sorting is stable, so sort in stages and ties keep their order. key= runs once per element, not per comparison, so don't pre-cache it. And .sort() returns None on purpose: x = x.sort() wipes your list, so keep the in-place call on its own line.

In practice

Loop through scores, accumulate a total, count passing grades, and print a summary:

python
raw_scores = [87, 42, 96, 55, 71, 63]

total = 0
passing = 0

for score in raw_scores:
    total += score
    if score >= 60:
        passing += 1

average = total / len(raw_scores)
print(f"Average: {average:.1f}")
print(f"Passing: {passing}/{len(raw_scores)}")
print(f"Top score: {sorted(raw_scores, reverse=True)[0]}")

Process a list of files in sorted order, skip ones that are too large, and report how many were skipped:

python
files = [
    {"name": "report_jan.csv", "size_mb": 12},
    {"name": "report_feb.csv", "size_mb": 850},
    {"name": "report_mar.csv", "size_mb": 7},
]

MAX_SIZE = 100
skipped = 0

for f in sorted(files, key=lambda x: x["name"]):
    if f["size_mb"] > MAX_SIZE:
        print(f"Skipping {f['name']} ({f['size_mb']} MB, too large)")
        skipped += 1
    else:
        print(f"Processing {f['name']}...")

print(f"\nDone. {skipped} file(s) skipped.")

Scan a request log for errors, then use a retry loop that exits on success or when the attempt limit is hit:

python
requests = [
    {"method": "GET", "path": "/users", "status": 200},
    {"method": "POST", "path": "/users", "status": 201},
    {"method": "GET", "path": "/broken", "status": 500},
]

errors = []

for req in requests:
    if req["status"] >= 400:
        errors.append(req)

if errors:
    print(f"{len(errors)} error(s) in request log:")
    for err in errors:
        print(f"  {err['method']} {err['path']} -> {err['status']}")
else:
    print("All requests succeeded")

attempts = 0
max_retries = 3
success = False

while attempts < max_retries and not success:
    attempts += 1
    print(f"Attempt {attempts}...")
    success = attempts >= 2   # simulate success on second try

print("Connected" if success else "Failed after all retries")