Teaching Python to Package Logic

python dev.to

I open Session 4 the same way every cohort: "You're an accountant. You calculate VAT on every invoice. What happens when the rate changes from 16% to 14% and you've written that calculation 50 times?"

Someone always says it out loud before I finish the question: "you'd have to fix it 50 times." Exactly. One missed spot, and 49 invoices are right while one is quietly wrong. That's the problem functions solve - write the logic once, give it a name, and every place that uses it updates the moment you change it in one location.

The Problem, Without Functions

Here's the VAT calculation done the way most people start out - copy, paste, adjust the numbers, repeat:

price1 = 1000
vat1 = price1 * 0.16
total1 = price1 + vat1
print(f"Item 1: Ksh {price1} + VAT {vat1:.0f} = Ksh {total1:.0f}")

price2 = 2500
vat2 = price2 * 0.16
total2 = price2 + vat2
print(f"Item 2: Ksh {price2} + VAT {vat2:.0f} = Ksh {total2:.0f}")
Enter fullscreen mode Exit fullscreen mode
Item 1: Ksh 1000 + VAT 160 = Ksh 1160
Item 2: Ksh 2500 + VAT 400 = Ksh 2900
Enter fullscreen mode Exit fullscreen mode

Works fine for two items. At 50 items, it's 50 near-identical blocks and 50 chances to make a typo. Here's the same thing solved properly:

def add_vat(price, rate=0.16):
    vat = price * rate
    total = price + vat
    return total

print(f"Item 1: Ksh {add_vat(1000):.0f}")
print(f"Item 2: Ksh {add_vat(2500):.0f}")
print(f"Export price: Ksh {add_vat(1000, rate=0.14):.0f}")
Enter fullscreen mode Exit fullscreen mode
Item 1: Ksh 1160
Item 2: Ksh 2900
Export price: Ksh 1140
Enter fullscreen mode Exit fullscreen mode

The logic exists in exactly one place. Fifty invoices, fifty calls, one function. Change the rate on that one line and every call picks it up instantly - that's what "write once, use anywhere" actually buys you.

def and Calling - Writing the Recipe vs Cooking the Meal

def tells Python you're defining a function - nothing runs yet. It's a recipe sitting in a book. The function only actually does something when you call it, by writing its name followed by parentheses.

def print_divider():
    print("=" * 40)

print_divider()
print(" STUDENT REPORT")
print_divider()
print(" Amina: 87")
print_divider()
Enter fullscreen mode Exit fullscreen mode
========================================
 STUDENT REPORT
========================================
 Amina: 87
========================================
Enter fullscreen mode Exit fullscreen mode

Defining print_divider() does nothing by itself - the four lines of output only appear because I called it three separate times. Writing the recipe and cooking the meal are two different steps, and beginners often expect the function to run the moment it's defined. It doesn't.

print vs return - The Distinction That Changes Everything

This is the single idea that separates people who can write functions from people who can write useful functions. print() shows something on screen and hands nothing back. return hands a value back to whoever called the function, so you can actually use it.

def add_print(a, b):
    print(a + b)   # shows on screen, gives nothing back

result = add_print(5, 3)
print("Result is:", result)   # Result is: None
Enter fullscreen mode Exit fullscreen mode
8
Result is: None
Enter fullscreen mode Exit fullscreen mode

add_print(5, 3) printed 8, sure - but result is None, because the function never handed anything back. Try to do maths with result and Python throws an error. Compare that to return:

def add_return(a, b):
    return a + b   # hands the value back to the caller

result = add_return(5, 3)
print("Result is:", result)          # Result is: 8

total = add_return(10, 20) + add_return(5, 5)
print("Total:", total)                # Total: 40
Enter fullscreen mode Exit fullscreen mode
Result is: 8
Total: 40
Enter fullscreen mode Exit fullscreen mode

Now result is an actual usable number - you can add it, multiply it, pass it into another function. The rule is simple: if you need to use the result somewhere else in your program, use return, not print.

This gets powerful fast once you combine it with conditionals:

def get_grade(score):
    if score >= 80: return "A"
    elif score >= 70: return "B"
    elif score >= 60: return "C"
    elif score >= 50: return "D"
    else: return "F"

scores = [87, 74, 55, 91, 43]
for score in scores:
    grade = get_grade(score)
    print(f"Score: {score}  Grade: {grade}")
Enter fullscreen mode Exit fullscreen mode
Score: 87  Grade: A
Score: 74  Grade: B
Score: 55  Grade: D
Score: 91  Grade: A
Score: 43  Grade: F
Enter fullscreen mode Exit fullscreen mode

A function stops the instant it hits a return - nothing after it in that function runs. And a function isn't limited to returning one value:

def analyse_scores(scores):
    total = sum(scores)
    average = round(total / len(scores), 1)
    return total, average, max(scores), min(scores)

marks = [78, 85, 91, 65, 72, 88]
tot, avg, hi, lo = analyse_scores(marks)
print(f"Total: {tot}  Average: {avg}  Highest: {hi}  Lowest: {lo}")
Enter fullscreen mode Exit fullscreen mode
Total: 479  Average: 79.8  Highest: 91  Lowest: 65
Enter fullscreen mode Exit fullscreen mode

Return four values with a comma, unpack all four into separate variables on the other side. Clean and readable.

Scope - Where Does a Variable Actually Live?

Scope is just: where does a variable exist, and who can see it? Think of a whiteboard inside a closed meeting room versus a notice board out in the corridor. The whiteboard gets wiped clean the moment the meeting ends, and no one outside the room ever saw what was on it - that's a local variable, created inside a function, gone the moment the function finishes. The corridor notice board is visible to everyone, all the time - that's a global variable.

def calculate_fee():
    fee = 5000   # LOCAL - only exists inside this function
    print("Fee inside function:", fee)

calculate_fee()
# print(fee)  ← NameError! fee doesn't exist out here
Enter fullscreen mode Exit fullscreen mode
Fee inside function: 5000
Enter fullscreen mode Exit fullscreen mode

You can read a global variable from inside a function with zero extra effort:

school = "Nairobi Tech Institute"   # GLOBAL

def show_school():
    print(f"School: {school}")   # reading a global - no special keyword needed

show_school()
Enter fullscreen mode Exit fullscreen mode
School: Nairobi Tech Institute
Enter fullscreen mode Exit fullscreen mode

But here's the trap: creating a variable inside a function with the same name as a global one does not touch the global - it silently creates a brand new, separate local variable that happens to share a name.

name = "Nairobi"   # GLOBAL

def show_local():
    name = "Mombasa"   # LOCAL - a completely different variable
    print("Inside function:", name)

show_local()
print("Outside function:", name)   # still "Nairobi"
Enter fullscreen mode Exit fullscreen mode
Inside function: Mombasa
Outside function: Nairobi
Enter fullscreen mode Exit fullscreen mode

If you genuinely need to modify a global from inside a function, you have to say so explicitly with the global keyword:

login_count = 0

def login(username):
    global login_count   # tells Python: use the GLOBAL one, not a new local
    login_count += 1
    print(f"{username} logged in. Total logins: {login_count}")

login("Amina")
login("Brian")
print("Final count:", login_count)
Enter fullscreen mode Exit fullscreen mode
Amina logged in. Total logins: 1
Brian logged in. Total logins: 2
Final count: 2
Enter fullscreen mode Exit fullscreen mode

global is only ever needed to change a global variable - never to read one. And honestly, the cleaner habit long-term is to avoid global altogether: pass the value in as a parameter and return the updated result instead.

Default Arguments - Optional Inputs

A default argument is a parameter that already has a value baked in, so the caller can skip it entirely. It's the restaurant default drink - order nothing specific and water shows up automatically; ask for a soda and that arrives instead.

def send_message(recipient, message, channel="SMS"):
    print(f"Sending to {recipient} via {channel}: {message}")

send_message("Amina", "Your package has arrived")                  # uses default
send_message("Brian", "Meeting at 3pm", "WhatsApp")                # overrides it
send_message("Njeri", "Invoice attached", channel="Email")         # overrides it, named
Enter fullscreen mode Exit fullscreen mode
Sending to Amina via SMS: Your package has arrived
Sending to Brian via WhatsApp: Meeting at 3pm
Sending to Njeri via Email: Invoice attached
Enter fullscreen mode Exit fullscreen mode

One rule to keep in mind: default parameters must come after the non-default ones - def send(recipient, message, channel="SMS") is fine, def send(channel="SMS", recipient, message) is a SyntaxError. Python needs to know which required values are coming before it lets you make anything optional.

*args - Any Number of Arguments

Sometimes you genuinely don't know in advance how many values a function will get handed. A waiter doesn't know if two people or ten people are about to sit at the table - he just takes orders from whoever shows up. *args gives a function that same flexibility.

def total_cost(*prices):
    print(f"Prices received: {prices}")   # it's a tuple
    return sum(prices)

print(total_cost(150))
print(total_cost(150, 300, 800))
print(total_cost(50, 75, 120, 200, 350))
Enter fullscreen mode Exit fullscreen mode
Prices received: (150,)
150
Prices received: (150, 300, 800)
1250
Prices received: (50, 75, 120, 200, 350)
795
Enter fullscreen mode Exit fullscreen mode

Inside the function, prices is just a tuple - you can loop through it, sum it, measure its length, whatever you'd do with any other sequence. Regular parameters always come before *args:

def class_summary(teacher, *scores):
    print(f"Teacher: {teacher}")
    print(f"Students: {len(scores)}")
    if scores:
        print(f"Average: {sum(scores)/len(scores):.1f}")

class_summary("Mr. Navas", 78, 85, 91, 65, 72)
Enter fullscreen mode Exit fullscreen mode
Teacher: Mr. Navas
Students: 5
Average: 78.2
Enter fullscreen mode Exit fullscreen mode

teacher is required and always comes first; everything after it - however many values - gets swept into the scores tuple.

Putting It Together - Payslip Generator

By the end of the session, everything from today lands in one program: a function with a default argument, one that returns multiple values, and clean separation between calculation and display.

def calculate_paye(gross, relief=2400):
    tax = gross * 0.25
    net_tax = max(tax - relief, 0)
    return round(net_tax, 2)

def generate_payslip(name, gross):
    paye = calculate_paye(gross)
    net_pay = gross - paye
    print("=" * 40)
    print(f" PAYSLIP: {name}")
    print("=" * 40)
    print(f"Gross Pay: Ksh {gross:,.2f}")
    print(f"PAYE: Ksh {paye:,.2f}")
    print("-" * 40)
    print(f"NET PAY: Ksh {net_pay:,.2f}")
    print("=" * 40)

generate_payslip("Wanjiku Kamau", 85000)
Enter fullscreen mode Exit fullscreen mode
========================================
 PAYSLIP: Wanjiku Kamau
========================================
Gross Pay: Ksh 85,000.00
PAYE: Ksh 9,760.00
----------------------------------------
NET PAY: Ksh 75,240.00
========================================
Enter fullscreen mode Exit fullscreen mode

calculate_paye() does one job - number crunching - and returns a clean result. generate_payslip() does a different job - formatting and display. That separation, calculation in one function, presentation in another, is the exact shape almost every real Python program takes once it grows past a few lines.

I'm a data trainer in Nairobi running a full data programme -
Python foundations → Data Science or Data Engineering specialisations.

Source: dev.to

arrow_back Back to Tutorials