Python · Beginner

Python if, for and while loops explained

13 min read

Why it matters

The Python for loop, if and while are where your program makes decisions, and where most beginner bugs hide: a 0 treated as "missing", a loop that runs one time too many, a while that never stops. Screening tests for data, automation and backend roles almost always include a FizzBuzz variant or a "what does this loop print?" question, and code reviewers flag manual counters and == None on sight.

You will learn every control-flow tool Python has, then build an interactive expense tracker that validates input and loops until the user quits, and refactor its menu with match/case.

The concept

Truthiness

if x: does not check x == True. It asks "is x truthy?". These are falsy: False, None, 0, 0.0, empty strings, and empty containers ([], {}, set(), ()). Almost everything else is truthy.

truthiness.py
for value in [0, 0.0, "", "0", " ", [], [0], {}, None, False, "False"]:
    print(f"{value!r:>8} -> {bool(value)}")

score = 0
if not score:
    print("not score: treats a real 0 as missing")
if score is None:
    print("never printed: 0 is not None")

marks = 72
print(0 <= marks <= 100, "pass" if marks >= 40 else "fail")
print(None or "default", 0 or 5, "" or "N/A", "Asha" and "present", [] and "never")
text
       0 -> False
     0.0 -> False
      '' -> False
     '0' -> True
     ' ' -> True
      [] -> False
     [0] -> True
      {} -> False
    None -> False
   False -> False
 'False' -> True
not score: treats a real 0 as missing
True pass
default 5 N/A present []

The traps to remember:

  • The strings "0", " " and "False" are truthy. Values read from files and forms are strings, so if row["is_active"]: is true for "False". Compare explicitly: row["is_active"] == "True".

  • if not score: cannot tell "score is 0" from "no score". When 0 is a valid value, write if score is None:.

  • Use is None / is not None, never == None. is checks identity (there is only one None object) and cannot be fooled by a class that redefines ==.

if, elif, else, chained comparisons and the conditional expression

  • Python checks if/elif branches top to bottom and runs only the first that is true. Put the most specific condition first (FizzBuzz's % 15 must come before % 3).

  • Comparisons chain: 0 <= marks <= 100 means 0 <= marks and marks <= 100, evaluating marks once.

  • The conditional expression a if condition else b picks a value inline. Use it for simple choices; nested ones are hard to read.

and / or return operands, not booleans

and and or short-circuit and return one of their operands:

  • a or b returns a if a is truthy, otherwise b. Hence name or "N/A" as a default. But 0 or 5 is 5: a real zero is replaced. Use x if x is not None else default when zero or empty is valid.

  • a and b returns a if a is falsy, otherwise b. [] and "never" returns [] without evaluating the right side.

  • Short-circuiting lets you guard: if row and row[0] == "#": never indexes an empty row.

for loops, range, enumerate and zip

loops_demo.py
print(list(range(5)), list(range(1, 11, 3)), list(range(10, 0, -4)))

subjects = ["Maths", "Physics", "Chemistry"]
marks = [91, 78, 85]
for rank, subject in enumerate(subjects, start=1):
    print(rank, subject)
for subject, mark in zip(subjects, marks):
    print(f"{subject:<10}{mark:>4}")

print(list(zip(subjects, [91, 78])))          # zip stops at the shortest, silently
try:
    list(zip(subjects, [91, 78], strict=True))
except ValueError as e:
    print("ValueError:", e)

# for...else: else runs only if the loop did NOT break
for subject, mark in zip(subjects, marks):
    if mark < 40:
        print("failed", subject)
        break
else:
    print("passed everything")

for n in range(1, 8):
    if n % 2 == 0:
        continue          # skip even numbers
    if n > 5:
        break             # stop entirely
    print(n, end=" ")
print()
text
[0, 1, 2, 3, 4] [1, 4, 7, 10] [10, 6, 2]
1 Maths
2 Physics
3 Chemistry
Maths       91
Physics     78
Chemistry   85
[('Maths', 91), ('Physics', 78)]
ValueError: zip() argument 2 is shorter than argument 1
passed everything
1 3 5
  • for works on any iterable: lists, strings, dict keys, files (line by line), range.

  • range(stop), range(start, stop), range(start, stop, step). The stop is excluded: range(1, 11) is 1 to 10. A negative step counts down.

  • enumerate(items, start=1) gives a counter and the item. Writing i = 0 ... i += 1 by hand is a code-review comment waiting to happen, and for i in range(len(items)) usually means you wanted enumerate.

  • zip(a, b) walks two sequences together and stops at the shortest without complaint, which silently drops data when two lists that should match do not. Python 3.10 added zip(..., strict=True) to raise instead. Use it whenever the lengths must be equal.

break, continue and for...else

  • continue skips to the next iteration; break leaves the loop.

  • A for (or while) loop can have an else block that runs only when the loop finished without break. Read it as "no break". It replaces the found = False flag pattern in searches: break when you find it, handle "not found" in else.

while loops and sentinels

while condition: repeats as long as the condition is truthy. Use it when you do not know the number of iterations in advance: reading until the user types q, retrying until success, reading a file in chunks.

Every while needs a way to end. Either the condition becomes false, or the body breaks. while True: with a clear break is idiomatic for menus and input validation. A sentinel is the value that means "stop" (an empty line, q, an empty chunk from read()).

If your program hangs, press Ctrl+C to raise KeyboardInterrupt and see the traceback of where it was looping.

The walrus operator :=

Python 3.8 added :=, which assigns and returns a value inside an expression. Its best use is in while loops where you would otherwise call the same function twice:

chunks.py
# Read a file 4 KB at a time: the walrus := assigns and tests in one expression.
total = 0
chunks = 0
with open("penguins.csv", "rb") as f:
    while chunk := f.read(4096):
        total += len(chunk)
        chunks += 1
print(f"{total} bytes in {chunks} chunks")

# The same loop without the walrus needs the read in two places:
with open("penguins.csv", "rb") as f:
    chunk = f.read(4096)
    while chunk:
        chunk = f.read(4096)
text
15241 bytes in 4 chunks

f.read(4096) returns empty bytes b"" at the end of the file, which is falsy, so the loop stops. Keep the walrus for this kind of "read, test, use" loop; using it to squeeze more into one line just makes code harder to read.

match / case (Python 3.10+)

Structural pattern matching compares a value against shapes, not just equal values. It shines when input is a sequence or a dict with a few known forms, like a command typed by a user:

patternmatches
case "quit":the exact value
case "quit" | "q":either value
case ["add", category, amount]:a 3-item sequence starting with "add", binding the other two
case ["add", category, *rest]:2 or more items, the rest captured as a list
case ["undo"] if expenses:the shape and a guard condition
case {"type": "refund", "amount": a}:a dict with those keys (extra keys allowed)
case _:anything (the default)

Cases are tried top to bottom and the first match wins. Two traps: a bare name like case quit: captures anything into a variable called quit (it does not compare against a variable), and match only works on Python 3.10+, so check the version your server runs. For simple "if x equals 1, 2 or 3" choices, if/elif or a dictionary of functions is just as good.

Worked example: a command-line expense tracker

A student wants to track spending without a spreadsheet. The program shows a menu, loops until the user quits, rejects bad amounts without crashing, and summarises spending by category.

expenses.py
"""A tiny expense tracker: add expenses, list them, summarise by category, quit."""
from decimal import Decimal, InvalidOperation

CATEGORIES = ("food", "travel", "books", "rent", "other")
expenses = []          # list of (category, amount, note) tuples

MENU = """
1) add expense   2) list   3) summary   q) quit"""


def read_amount(prompt):
    """Ask until the user types a positive amount, or blank to cancel. Returns Decimal or None."""
    while True:
        text = input(prompt).strip().replace(",", "")
        if not text:
            return None
        try:
            amount = Decimal(text)
        except InvalidOperation:
            print(f"  '{text}' is not a number, try again")
            continue
        if amount <= 0:
            print("  amount must be more than 0")
            continue
        return amount.quantize(Decimal("0.01"))


def add_expense():
    category = input(f"category {CATEGORIES}: ").strip().lower()
    if category not in CATEGORIES:
        print(f"  unknown category '{category}', saved as 'other'")
        category = "other"
    amount = read_amount("amount in rupees (blank to cancel): ")
    if amount is None:
        print("  cancelled")
        return
    note = input("note (optional): ").strip() or "-"
    expenses.append((category, amount, note))
    print(f"  added ₹{amount} for {category}")


def list_expenses():
    if not expenses:
        print("  no expenses yet")
        return
    for number, (category, amount, note) in enumerate(expenses, start=1):
        print(f"  {number:>2}. {category:<7} ₹{amount:>9,.2f}  {note}")


def summary():
    totals = {}
    for category, amount, _ in expenses:
        totals[category] = totals.get(category, Decimal(0)) + amount
    grand = sum(totals.values(), Decimal(0))
    for category, total in sorted(totals.items(), key=lambda kv: kv[1], reverse=True):
        share = total / grand if grand else 0
        print(f"  {category:<7} ₹{total:>9,.2f}  {share:>6.1%}")
    print(f"  {'total':<7} ₹{grand:>9,.2f}")


def main():
    while True:                      # loop until the user quits
        print(MENU)
        choice = input("> ").strip().lower()
        if choice == "1":
            add_expense()
        elif choice == "2":
            list_expenses()
        elif choice == "3":
            summary()
        elif choice in ("q", "quit", "exit"):
            print("bye")
            break
        else:
            print(f"  unknown option '{choice}'")


if __name__ == "__main__":
    main()

A real session (what the user typed appears after each prompt):

text
 
1) add expense   2) list   3) summary   q) quit
> 1
category ('food', 'travel', 'books', 'rent', 'other'): food
amount in rupees (blank to cancel): 180
note (optional): thali at canteen
  added ₹180.00 for food
 
1) add expense   2) list   3) summary   q) quit
> 1
category ('food', 'travel', 'books', 'rent', 'other'): travel
amount in rupees (blank to cancel): abc
  'abc' is not a number, try again
amount in rupees (blank to cancel): -5
  amount must be more than 0
amount in rupees (blank to cancel): 1,450
note (optional): bus pass
  added ₹1450.00 for travel
 
1) add expense   2) list   3) summary   q) quit
> 1
category ('food', 'travel', 'books', 'rent', 'other'): movies
  unknown category 'movies', saved as 'other'
amount in rupees (blank to cancel): 350
note (optional): 
  added ₹350.00 for other
 
1) add expense   2) list   3) summary   q) quit
> 2
   1. food    ₹   180.00  thali at canteen
   2. travel  ₹ 1,450.00  bus pass
   3. other   ₹   350.00  -
 
1) add expense   2) list   3) summary   q) quit
> 7
  unknown option '7'
 
1) add expense   2) list   3) summary   q) quit
> 3
  travel  ₹ 1,450.00   73.2%
  other   ₹   350.00   17.7%
  food    ₹   180.00    9.1%
  total   ₹ 1,980.00
 
1) add expense   2) list   3) summary   q) quit
> q
bye

How the control flow does its job:

  • Two while True loops, each with one exit. main exits on break when the user quits; read_amount exits by returning a valid amount or None. Bad input continues back to the prompt instead of crashing.

  • Guard clauses. add_expense returns early when the user cancels, and list_expenses when there is nothing to list, so the main logic is not nested inside ifs.

  • or for a default note, safe here because an empty note really should become "-".

  • enumerate(..., start=1) numbers the list for humans.

  • if grand else 0 avoids dividing by zero when there are no expenses.

Refactoring the menu with match/case

Numbered menus get tedious. Typed commands like add food 180 thali are faster, and match parses them by shape. The new version reuses the functions from expenses.py:

expenses_match.py
"""Same tracker, driven by typed commands and match/case instead of an if/elif menu."""
from decimal import Decimal, InvalidOperation

from expenses import CATEGORIES, expenses, list_expenses, summary

HELP = "commands: add <category> <amount> [note...] | list | summary | undo | quit"


def parse_amount(text):
    try:
        amount = Decimal(text.replace(",", ""))
    except InvalidOperation:
        return None
    return amount.quantize(Decimal("0.01")) if amount > 0 else None


def handle(words):
    """Run one command. Returns False when the user wants to quit."""
    match words:
        case ["add", category, amount, *note] if category in CATEGORIES:
            value = parse_amount(amount)
            if value is None:
                print(f"  '{amount}' is not a positive amount")
            else:
                expenses.append((category, value, " ".join(note) or "-"))
                print(f"  added ₹{value} for {category}")
        case ["add", category, *_]:
            print(f"  unknown category '{category}', use one of {CATEGORIES}")
        case ["list" | "ls"]:
            list_expenses()
        case ["summary"]:
            summary()
        case ["undo"] if expenses:
            print("  removed", expenses.pop())
        case ["undo"]:
            print("  nothing to undo")
        case ["quit" | "q" | "exit"]:
            return False
        case []:
            pass                                  # empty line: do nothing
        case _:
            print("  " + HELP)
    return True


def main():
    print(HELP)
    while handle(input("> ").split()):
        pass
    print("bye")


if __name__ == "__main__":
    main()
text
commands: add <category> <amount> [note...] | list | summary | undo | quit
> add food 180 thali at canteen
  added ₹180.00 for food
> add travel 1,450 bus pass
  added ₹1450.00 for travel
> add movies 350
  unknown category 'movies', use one of ('food', 'travel', 'books', 'rent', 'other')
> add books abc
  'abc' is not a positive amount
> add books 499
  added ₹499.00 for books
> undo
  removed ('books', Decimal('499.00'), '-')
> list
   1. food    ₹   180.00  thali at canteen
   2. travel  ₹ 1,450.00  bus pass
> summary
  travel  ₹ 1,450.00   89.0%
  food    ₹   180.00   11.0%
  total   ₹ 1,630.00
> help
  commands: add <category> <amount> [note...] | list | summary | undo | quit
> q
bye

Notice the order of cases: the specific add with a guard comes before the general add that reports a bad category, and the two undo cases differ only by their guard. *note collects every remaining word, so notes can contain spaces. while handle(...): pass is the loop: it keeps going while handle returns True.

Common mistakes

  • if x == None. Use if x is None. == can be overridden by a class and is slower; linters (Ruff rule E711) flag it.

  • if not value when 0 or "" is valid. It treats them as missing. Check is None.

  • Truthy strings from files. "0" and "False" are truthy. Convert or compare explicitly.

  • Off-by-one with range. range(1, 10) stops at 9. To include n, write range(1, n + 1).

  • Manual counters and range(len(x)). Use enumerate, and zip for parallel lists.

  • zip silently truncating. Add strict=True when lengths must match.

  • Misreading for...else. The else runs when the loop did not break, not "when the loop did not run".

  • while loops with no reachable exit. Make sure every path either changes the condition or breaks, and handle EOFError if input can end (piped input).

  • case some_name: in a match. It captures, it does not compare. Use dotted constants (case Colour.RED:) or literals.

  • Order of elif branches. A general condition before a specific one swallows it (the FizzBuzz bug).

Interview angle

Write FizzBuzz for 1 to 15.
python
for n in range(1, 16):
    if n % 15 == 0:
        print("FizzBuzz", end=" ")
    elif n % 3 == 0:
        print("Fizz", end=" ")
    elif n % 5 == 0:
        print("Buzz", end=" ")
    else:
        print(n, end=" ")
print()
text
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz 

Point out that the % 15 branch must come first, and that range(1, 16) is needed to include 15. Interviewers then often change the rules ("7 is Bazz"); a version that builds the word from a list of (divisor, word) pairs adapts in one line.

What does for...else do?

The else block runs when the loop ends normally, without hitting break. It is used for searches: break when you find the item, and handle "not found" in else, without a separate flag variable.

Why is if x == None wrong and if x is None right?

is checks identity: there is exactly one None object, so x is None is precise and fast. == calls the object's __eq__, which any class can override; a class whose __eq__ always returns True makes x == None true for a real object. PEP 8 says to compare singletons with is.

What do 0 or 5 and [] and 'x' return?

5 and []. or returns the first truthy operand (or the last one), and returns the first falsy operand (or the last one). They return operands, not True/False.

Practice task

Extend expenses.py (the menu version).

  1. Add option 4) delete, which lists expenses, asks for a number, and deletes that entry. Reject numbers out of range and non-numbers, and let a blank input cancel.

  2. Add a monthly budget: ask for it once at start-up (re-ask until it is a positive number), and after each new expense print a warning when total spending passes 80% and again when it passes 100%.

  3. Make main survive Ctrl+D / end of input: catch EOFError and exit with "bye" instead of a traceback.

  4. Add the same delete <n> command to expenses_match.py with a case ["delete", number] if number.isdigit(): pattern.

Done when: you can run a session that adds four expenses, deletes one, triggers the 80% warning, and quits with Ctrl+D, with no traceback at any point.

Show a solution for step 1
python
def delete_expense():
    list_expenses()
    if not expenses:
        return
    while True:
        text = input("number to delete (blank to cancel): ").strip()
        if not text:
            print("  cancelled")
            return
        if not text.isdigit() or not 1 <= int(text) <= len(expenses):
            print(f"  type a number from 1 to {len(expenses)}")
            continue
        removed = expenses.pop(int(text) - 1)
        print(f"  deleted {removed[0]} ₹{removed[1]}")
        return

Then add elif choice == "4": delete_expense() to the menu and update MENU. The chained comparison 1 <= int(text) <= len(expenses) checks the range in one expression, and the isdigit() test before it short-circuits so int() never sees bad text.

Further reading