Python · Beginner
Python if, for and while loops explained
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.
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") 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, soif row["is_active"]:is true for"False". Compare explicitly:row["is_active"] == "True".if not score:cannot tell "score is 0" from "no score". When0is a valid value, writeif score is None:.Use
is None/is not None, never== None.ischecks identity (there is only oneNoneobject) and cannot be fooled by a class that redefines==.
if, elif, else, chained comparisons and the conditional expression
Python checks
if/elifbranches top to bottom and runs only the first that is true. Put the most specific condition first (FizzBuzz's% 15must come before% 3).Comparisons chain:
0 <= marks <= 100means0 <= marks and marks <= 100, evaluatingmarksonce.The conditional expression
a if condition else bpicks 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 breturnsaifais truthy, otherwiseb. Hencename or "N/A"as a default. But0 or 5is5: a real zero is replaced. Usex if x is not None else defaultwhen zero or empty is valid.a and breturnsaifais falsy, otherwiseb.[] 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
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()[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 5forworks on any iterable: lists, strings, dict keys, files (line by line),range.range(stop),range(start, stop),range(start, stop, step). Thestopis excluded:range(1, 11)is 1 to 10. A negative step counts down.enumerate(items, start=1)gives a counter and the item. Writingi = 0...i += 1by hand is a code-review comment waiting to happen, andfor i in range(len(items))usually means you wantedenumerate.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 addedzip(..., strict=True)to raise instead. Use it whenever the lengths must be equal.
break, continue and for...else
continueskips to the next iteration;breakleaves the loop.A
for(orwhile) loop can have anelseblock that runs only when the loop finished withoutbreak. Read it as "no break". It replaces thefound = Falseflag pattern in searches: break when you find it, handle "not found" inelse.
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:
# 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)15241 bytes in 4 chunksf.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:
| pattern | matches |
|---|---|
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.
"""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):
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
byeHow the control flow does its job:
Two
while Trueloops, each with one exit.mainexits onbreakwhen the user quits;read_amountexits byreturning a valid amount orNone. Bad inputcontinues back to the prompt instead of crashing.Guard clauses.
add_expensereturns early when the user cancels, andlist_expenseswhen there is nothing to list, so the main logic is not nested insideifs.orfor a default note, safe here because an empty note really should become"-".enumerate(..., start=1)numbers the list for humans.if grand else 0avoids 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:
"""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()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
byeNotice 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. Useif x is None.==can be overridden by a class and is slower; linters (Ruff rule E711) flag it.if not valuewhen 0 or "" is valid. It treats them as missing. Checkis 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 includen, writerange(1, n + 1).Manual counters and
range(len(x)). Useenumerate, andzipfor parallel lists.zipsilently truncating. Addstrict=Truewhen lengths must match.Misreading
for...else. Theelseruns when the loop did notbreak, not "when the loop did not run".whileloops with no reachable exit. Make sure every path either changes the condition orbreaks, and handleEOFErrorif 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
elifbranches. A general condition before a specific one swallows it (the FizzBuzz bug).
Interview angle
Write FizzBuzz for 1 to 15.
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()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).
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.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%.
Make
mainsurviveCtrl+D/ end of input: catchEOFErrorand exit with "bye" instead of a traceback.Add the same
delete <n>command toexpenses_match.pywith acase ["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
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]}")
returnThen 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
More control flow tools: the official tutorial on
if,for,range,break,elseon loops andmatch.PEP 636: structural pattern matching tutorial: match/case explained with a command parser, much like this lesson.
Truth value testing: the exact list of falsy values.
Built-in functions: enumerate and zip: including
strict=True.