Python Control Flow and Functions: if, for, while, def
ProgrammingLesson 2 of 45 min read
What you'll learn
- Branch with if/elif/else and loop with for, while, break and continue
- Define functions with parameters, defaults, keyword arguments and return values
- Avoid the mutable default argument bug and the UnboundLocalError scope trap
- Split a problem into small functions that you can test independently
On this page
Here is a bug that has bitten almost every Python developer once. A function takes history=[] as a default. The first call returns ['A-B']. The second call, with a different trip, returns ['A-B', 'C-D']. Nobody passed in the old data, yet it is there. The explanation involves when Python evaluates a default value, and we will get to it at the end of this lesson. First, the machinery that makes programs make decisions.
The running example is a fare engine for a small metro system. Fares depend on how many zones a trip crosses, the rider type and whether the trip is off-peak. Keeping the rules in functions means that when the transit authority changes a price you edit one place.
Choosing a branch: if, elif, else
An if statement runs its block when the condition is truthy. Add elif branches for further cases and an else for everything left over. Python checks them top to bottom and runs only the first match, so order your conditions from most specific to most general. Combine conditions with and, or and not, and chain comparisons the way mathematicians write them: 1 <= zones <= 3.
# fares.py - a metro fare engine in whole rupees
def zone_fare(zones_crossed):
if zones_crossed <= 0:
raise ValueError("a trip crosses at least one zone")
if zones_crossed == 1:
return 20
elif zones_crossed <= 3:
return 35
else:
return 50
def apply_discounts(fare, rider_type="adult", off_peak=False):
if rider_type == "student":
fare = fare * 0.5
elif rider_type == "senior":
fare = fare * 0.6
if off_peak:
fare = fare - 5
return max(round(fare), 10) # never below the minimum fare
trips = [(1, "adult", False), (2, "student", True), (5, "senior", False), (1, "student", True)]
total = 0
for zones, rider, off_peak in trips:
fare = apply_discounts(zone_fare(zones), rider, off_peak)
print(f"{zones} zone(s), {rider:<7} off-peak={off_peak!s:<5} -> {fare}")
total += fare
print("Day total:", total)Output
1 zone(s), adult off-peak=False -> 20 2 zone(s), student off-peak=True -> 12 5 zone(s), senior off-peak=False -> 30 1 zone(s), student off-peak=True -> 10 Day total: 72
Look at the second line of output. A two-zone student fare is 35 * 0.5 = 17.5, minus 5 for off-peak gives 12.5, and round(12.5) is 12, not 13. Python rounds exact halves to the nearest even number (banker's rounding). That is correct behaviour, but if your business rule says "round halves up" you must implement it yourself, for instance with Decimal and ROUND_HALF_UP. The last trip shows the max guard working: 10 - 5 would be 5, but the minimum fare is 10.
Repeating work: for, while, break and else
A for loop walks over any iterable: a list, a string, the keys of a dict, range(n). When you also need the position, use enumerate instead of managing a counter. A while loop repeats as long as a condition holds, so something inside it must eventually change that condition. break leaves the loop, continue skips to the next pass, and a loop's optional else block runs only if the loop finished without hitting break.
# add 50 to the card until it covers the fare, at most 3 times
def top_up_needed(balance, fare, step=50, limit=3):
taps = 0
while balance < fare:
if taps == limit:
break
balance += step
taps += 1
else:
return balance, taps # loop ended normally: card now covers the fare
return None, taps # we broke out: gave up
print(top_up_needed(10, 35))
print(top_up_needed(40, 35))
print(top_up_needed(0, 400))
for i, name in enumerate(["Central", "Harbour", "Airport"], start=1):
print(i, name)Output
(60, 1) (40, 0) (None, 3) 1 Central 2 Harbour 3 Airport
Functions: parameters, defaults and return values
A function is defined with def, takes parameters and hands back a value with return. A function with no return returns None, a classic source of confusion when you write x + 1 as the last line and forget to return it. You can call arguments by position or by name, give parameters defaults, collect extra positional arguments with *args (a tuple) and extra keyword arguments with **kwargs (a dict). To return several values, return a tuple and unpack it at the call site.
def summarise(*fares, currency="INR", **extras):
return len(fares), sum(fares), currency, extras
print(summarise(20, 35, 50))
print(summarise(20, currency="USD", station="Harbour"))
def min_max(values):
return min(values), max(values) # a tuple
low, high = min_max([35, 20, 50])
print(low, high)
def no_return(x):
x + 1
print(no_return(3))
rate = 2
def scaled(x):
rate = 10 # a new local name; the global is untouched
return x * rate
print(scaled(4), rate)
def broken():
print(counter)
counter = 1
try:
broken()
except UnboundLocalError as e:
print("UnboundLocalError:", e)Output
(3, 105, 'INR', {})
(1, 20, 'USD', {'station': 'Harbour'})
20 50
None
40 2
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a valueThe last example shows scope. Assigning to a name anywhere inside a function makes that name local for the whole function, so reading it before the assignment fails. If you ever think you need the global keyword, first ask whether the function could take the value as a parameter and return the result instead. It is almost always easier to test.
Common mistakes and the errors they produce
Back to the opening bug. Default values are evaluated once, when def runs, not on every call. A mutable default such as [] is therefore created once and shared by every call that does not pass its own.
def log_trip(trip, history=[]):
history.append(trip)
return history
print(log_trip("A-B"))
print(log_trip("C-D")) # surprise
def log_trip_fixed(trip, history=None):
if history is None:
history = []
history.append(trip)
return history
print(log_trip_fixed("A-B"))
print(log_trip_fixed("C-D"))Output
['A-B'] ['A-B', 'C-D'] ['A-B'] ['C-D']
- Mutable default arguments. The symptom is data leaking between calls. Default to
Noneand create the list inside the function. - Forgetting `return`. The caller receives
None, and a later line fails withTypeError: unsupported operand type(s) for +: 'NoneType' and 'int'. - Off-by-one with `range`.
range(1, 5)stops at 4. Print the loop variable once to check your bounds. - Infinite `while` loops. If the condition variable is never updated, the program hangs; stop it with Ctrl+C and add a counter or a
break. - `=` instead of `==` in a condition. Python rejects
if x = 3:withSyntaxError: invalid syntax. Maybe you meant '==' or ':=' instead of '='?.
How this shows up in real projects and interviews
Business rules such as fares, shipping costs and eligibility checks are written as chains of conditions, and most bugs in them are boundary errors: < where <= was meant. Reviewers also look at whether a function does one thing and returns a value instead of printing it. Interview screens often ask for a small function, FizzBuzz-style, and then probe the edge cases: empty input, a negative number, a default argument. Practice saying the edge cases out loud before you code, as described in how to think out loud in interviews. Next, the collection types that these loops will iterate over: lists, dicts, sets and comprehensions.
Try it yourself
Extend fares.py with a weekly_cap rule: a rider never pays more than 200 in one week. Write week_total(trips, cap=200) that takes a list of (zones, rider, off_peak) tuples, adds up each trip's fare, stops charging once the cap is reached, and returns both the amount charged and how many trips were free because of the cap.
Show a hint
Keep a running charged total. For each trip compute fare, then charge min(fare, cap - charged) and count a free trip when that amount is 0. Return a tuple.
Key takeaways
if/elif/elseruns the first matching branch, so order conditions from specific to general.- Use
forwithenumerateandrange; usewhileonly when the number of passes is unknown. - A function with no
returngives backNone; return values instead of printing them. - Never use a mutable default argument; default to
Noneand build the object inside. - Assigning to a name inside a function makes it local for that whole function.
Test yourself
A trick from this lesson's ride: One star gathers a pile (tuple); two stars gather a labelled pile (dict) Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between break and continue in Python?
break ends the whole loop immediately, while continue skips the rest of the current iteration and moves on to the next one. A loop's else block runs only when the loop finished without a break.
Why does a Python function remember a list between calls?
Default argument values are evaluated once when the function is defined, so a default like history=[] is one shared list. Use history=None as the default and create a fresh list inside the function when it is None.
What does *args and **kwargs mean in Python?
*args collects any extra positional arguments into a tuple, and **kwargs collects extra keyword arguments into a dict. They let a function accept a flexible number of inputs, and they are handy for passing arguments through to another function.