New: Try Voli The Bear, Fast package manager (and not only) for Windows
Updated Jun 19, 2026 Edit on GitHub

Control Flow & Functions

So far your programs run straight down, every line once. Real programs make choices ("if logged in, show the dashboard") and repeat work ("for each order, send an email"), and once you've written useful logic, you want to name it and reuse it instead of copy-pasting. Deciding, repeating, and packaging - control flow and functions - are where code starts to feel powerful.

Remember the indentation rule from Phase 2: : opens a block and the indented lines beneath belong to it. Every structure here uses it.

if / elif / else - making a decision

What it actually is. An if runs a block only when a condition is true. Add elif ("else if") for more conditions, and else for "none of the above." Python checks top to bottom, runs the first matching block, then skips the rest.

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

What just happened: Python checked score >= 90 (false), then >= 80 (false), then >= 70 (true), ran that block, and stopped without reaching else:

C

As a picture - Python falls through the checks until one matches:

📝 Terminology. A condition is any expression evaluating to True or False - usually a comparison like >=, ==, != (not equal), <, >. The block under if runs when it's True.

Truthiness - what counts as "true"

What it actually is. Python lets you use any value as a condition, not only True/False - it asks "is this truthy or falsy?" The falsy values are "empty or nothing"; almost everything else is truthy.

print(bool(0), bool(""), bool([]), bool(None))
print(bool(42), bool("hi"), bool([1, 2]))

What just happened: bool() shows how Python judges each value as a condition: zero, the empty string, the empty list, and None are all falsy; a nonzero number, non-empty string, and non-empty list are truthy:

False False False False
True True True

This enables natural checks - instead of if len(items) > 0:, write:

items = []
if items:
    print("There are items")
else:
    print("The list is empty")

What just happened: The empty list is falsy, so the else ran:

The list is empty

💡 Key point. "Empty or zero or nothing" is falsy; everything else truthy. if my_list: reads as "if the list has anything in it" - clean and Pythonic.

for - do something for each item

What it actually is. A for loop walks a collection, running its block once per item, each handed to a name you choose.

for fruit in ["apple", "banana", "cherry"]:
    print(fruit)

What just happened: The loop took each item in turn, pointed fruit at it, and ran the block - three items, three runs:

apple
banana
cherry

To repeat a fixed number of times, loop over range(n), which produces 0 up to (but not including) n:

for i in range(3):
    print(i)

What just happened: range(3) yielded 0, 1, 2 - stopping before 3, the same "stop is exclusive" rule as slicing:

0
1
2

while - repeat until a condition turns false

What it actually is. A while loop repeats its block as long as a condition stays true. Reach for it when you don't know in advance how many times you'll loop - you loop until something changes.

n = 3
while n > 0:
    print(n)
    n = n - 1

What just happened: The loop ran while n > 0, printing n then shrinking it by 1 each pass, until it hit 0 and the condition went false:

3
2
1

⚠️ The infinite loop. A while only stops when its condition becomes false, so something inside the loop must move it toward false. Drop the n = n - 1 line above and n stays 3 forever, printing without end. If a program ever "hangs," suspect an infinite loop; press Ctrl-C to stop it.

Functions - name a piece of logic and reuse it

What it actually is. A function is a named, reusable block of instructions: define it once with def, then call it whenever needed, with different inputs each time - how you avoid copy-pasting logic.

def greet(name):
    return f"Hello, {name}!"

print(greet("Ada"))
print(greet("Linus"))

What just happened: def greet(name): defined a function taking one parameter, name; return hands a value back. Each call supplied a different name, giving two different results:

Hello, Ada!
Hello, Linus!

📝 Terminology. A parameter is the name in the definition (name); an argument is the actual value passed when calling ("Ada"). return sends a value back out of the function.

Defaults let a parameter be optional by giving it a fallback value:

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Ada"))
print(greet("Ada", "Hi"))

What just happened: Without a greeting argument, it falls back to "Hello"; supply one and yours wins:

Hello, Ada!
Hi, Ada!

return vs printing - a crucial difference. A function that prints shows text but hands back nothing usable; one that returns gives a value you can store and work with. No return means it hands back None:

def show(x):
    print(x)

result = show(5)
print(result)

What just happened: show(5) printed 5, but with no return, the call evaluated to None - stored in result and printed on the second line:

5
None

To use a function's output later, it must return it, not just print it - printing is for humans, returning is for feeding the rest of your program.

The classic trap: mutable default arguments

This one bites experienced developers too, so it's worth meeting head-on.

What goes wrong. Give a parameter a mutable default (like a list), and it's created once, when the function is defined - then shared across every call, never fresh, which is almost never what you want.

def add_item(item, basket=[]):
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("banana"))

What just happened: You'd expect each call to start with an empty basket. Instead both share the same default list, so the second call sees the first call's leftovers:

['apple']
['apple', 'banana']

That ['apple', 'banana'] on the second line is the bug - "apple" shouldn't be there. The shared default silently accumulates across calls.

The fix - a pattern to adopt every time: default to None, then create a fresh value inside the function.

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

print(add_item("apple"))
print(add_item("banana"))

What just happened: Now each call with no basket gets a brand-new list, so they don't bleed together:

['apple']
['banana']

⚠️ Never use a mutable default ([], {}, set()) directly. Default to None and build the real value inside the function. Memorize this - it's a genuine sharp edge, coming straight from the aliasing idea in Phase 3: the default list is one shared object.

Recap

  1. if / elif / else runs the first matching block. Conditions are expressions evaluating to True/False.
  2. Truthiness: empty/zero/None are falsy, everything else truthy - so if my_list: means "if it has items."
  3. for loops once per item (use range(n) for a count); while loops until its condition goes false - make sure something moves it there.
  4. def defines a function; parameters name its inputs, return hands a value back. Defaults make parameters optional.
  5. return gives a usable value; a function with no return yields None.
  6. Never use a mutable default argument. Default to None and build the list/dict inside the function.

Next: importing code, the standard library, writing your own modules, and laying out a real project.


← Phase 3: Collections · Guide overview · Phase 5: Modules & Project Layout →

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. In Python, which values are falsy?

2. What is the difference between `return` and printing in a function?

3. Why should you never use a mutable default argument like `def f(x, items=[]):`?