> "Don't repeat yourself. Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."
In This Chapter
- Opening Scenario: The Copy-Paste Trap
- 6.1 What Functions Are and Why They Matter
- 6.2 Defining and Calling Functions
- 6.3 Parameters and Arguments
- 6.4 Docstrings: Self-Documenting Code
- 6.5 Scope: Local and Global Variables
- 6.6 Lambda Functions
- 6.7 Building a Business Function Library
- 6.8 The DRY Principle in Practice
- 6.9 Function Design Principles
- 6.10 A Complete Business Function Library Example
- 6.11 Error Handling in Functions
- Summary
Chapter 6: Functions — Building Reusable Business Logic
"Don't repeat yourself. Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." — Andrew Hunt & David Thomas, The Pragmatic Programmer
Opening Scenario: The Copy-Paste Trap
Priya's scripts are getting longer. Over the past three weeks, she's built the daily scorecard, the weekly regional report, and the beginning of a customer tier calculator. Each one works. But she's noticed a problem.
The gross margin calculation appears in four different scripts. They're almost identical. Almost. In one script she forgot to divide by revenue before multiplying by 100. In another, she divided by total_revenue instead of region_revenue. The calculations look the same at a glance — same variable names, same structure — but they produce different results.
She's been copy-pasting business logic. And copy-pasted logic diverges. Someone changes one copy, forgets the other three, and suddenly you have four versions of the same rule, and no confidence about which one is right.
Functions are the solution. You write a calculation once, give it a name, and call it wherever you need it. When the formula changes, you change it in one place. Every caller gets the update automatically.
This chapter is about writing functions — Python's mechanism for packaging logic into reusable, named pieces.
6.1 What Functions Are and Why They Matter
A function is a named block of code that you can define once and call as many times as you need.
You've been using functions all along:
- print("hello") — calling Python's built-in print function
- len("Acme Corp") — calling the built-in len function
- "midwest".upper() — calling the upper method on a string
Now you'll learn to define your own.
The business case for functions is exactly the business case for any well-organized process:
-
DRY: Don't Repeat Yourself. If you write the same logic in four places, you have four maintenance points. Change the rule, change all four — or forget one and introduce inconsistency.
-
Testability. A named function can be tested with specific inputs. A block of inline code buried in a script cannot.
-
Readability.
calculate_gross_margin(revenue, cogs)communicates intent instantly. The math that implements it can be a black box that readers trust. -
Composability. Functions call other functions. Complex business logic is built from simple, trusted building blocks.
6.2 Defining and Calling Functions
The basic syntax:
def function_name(parameter1, parameter2):
"""Docstring: what this function does."""
# function body
result = ...
return result
Let's make this concrete:
def calculate_gross_margin(revenue, cogs):
"""
Calculate gross margin as a decimal (e.g., 0.35 for 35%).
Args:
revenue: Total sales revenue
cogs: Cost of Goods Sold
Returns:
Gross margin as a float (0.0 to 1.0)
"""
gross_profit = revenue - cogs
gross_margin = gross_profit / revenue
return gross_margin
This defines the function. To use it:
# Call the function with specific values
march_margin = calculate_gross_margin(874_400, 550_000)
print(f"March gross margin: {march_margin:.1%}") # 37.1%
# Call it again with different values
q1_margin = calculate_gross_margin(2_187_400, 1_356_188)
print(f"Q1 gross margin: {q1_margin:.1%}") # 38.0%
# The formula only exists in one place — consistent every time
The def Keyword
def tells Python: "I am defining a function." What follows is:
- The function name (same naming rules as variables)
- Parentheses containing zero or more parameters (the inputs)
- A colon
- The indented function body
The return Statement
return specifies what the function sends back to the caller. After return executes, the function ends.
def add_tax(amount, tax_rate):
"""Return amount plus tax."""
return amount * (1 + tax_rate)
subtotal = 1000
total = add_tax(subtotal, 0.0875)
print(f"Total with tax: ${total:.2f}") # $1,087.50
Functions without a return statement return None implicitly. This is fine for functions whose purpose is a side effect (like printing), not a calculation.
def print_separator():
"""Print a visual separator line."""
print("=" * 50)
# No return needed — the purpose is the print side effect
print_separator()
# Output: ==================================================
6.3 Parameters and Arguments
The terms are often used interchangeably, but technically:
- Parameters are the names in the function definition: def f(revenue, cogs):
- Arguments are the values passed in when calling: f(874_400, 550_000)
Positional Arguments
Arguments are matched to parameters by position:
def calculate_discount(price, discount_rate):
"""Apply a discount rate to a price."""
return price * (1 - discount_rate)
# The first argument matches 'price', the second matches 'discount_rate'
discounted = calculate_discount(99.99, 0.15)
print(f"${discounted:.2f}") # $84.99
# If you swap them, you get a different (wrong) result
wrong = calculate_discount(0.15, 99.99) # Discounting a rate by a price!
Keyword Arguments
You can also pass arguments by name, which makes calls more readable and order-independent:
# Keyword arguments — order doesn't matter
discounted = calculate_discount(price=99.99, discount_rate=0.15)
# Even in different order:
discounted = calculate_discount(discount_rate=0.15, price=99.99)
Keyword arguments are especially valuable when calling functions with many parameters, where positional argument order is hard to remember.
Default Parameters
You can assign default values to parameters. If the caller doesn't provide a value for that parameter, the default is used:
def calculate_discount(price, discount_rate=0.10, apply_tax=True, tax_rate=0.0875):
"""
Apply discount and optionally add tax.
Default: 10% discount, with tax applied.
"""
discounted_price = price * (1 - discount_rate)
if apply_tax:
return discounted_price * (1 + tax_rate)
return discounted_price
# Use all defaults
print(f"${calculate_discount(100.00):.2f}") # $97.88 (10% off + 8.75% tax)
# Override discount rate, keep other defaults
print(f"${calculate_discount(100.00, 0.20):.2f}") # $87.00
# Override using keywords
print(f"${calculate_discount(100.00, discount_rate=0.20, apply_tax=False):.2f}") # $80.00
Rule: Parameters with defaults must come after parameters without defaults in the function definition.
# VALID: non-default first, then default
def f(price, discount_rate=0.10):
...
# INVALID: default before non-default
def f(discount_rate=0.10, price): # SyntaxError!
...
Multiple Return Values
Python functions can return multiple values using tuples:
def analyze_sales(revenue, cogs, expenses):
"""Return multiple financial metrics."""
gross_profit = revenue - cogs
gross_margin = gross_profit / revenue
operating_profit = gross_profit - expenses
operating_margin = operating_profit / revenue
return gross_profit, gross_margin, operating_profit, operating_margin
# Unpack all four return values
gp, gm, op, om = analyze_sales(500_000, 310_000, 125_000)
print(f"Gross profit: ${gp:,}")
print(f"Gross margin: {gm:.1%}")
print(f"Operating profit: ${op:,}")
print(f"Operating margin: {om:.1%}")
6.4 Docstrings: Self-Documenting Code
A docstring is the first string in a function body. Python stores it in the function's __doc__ attribute and development tools display it when you call help() or hover over the function name.
def calculate_clv(monthly_revenue_per_customer, gross_margin, churn_rate):
"""
Calculate Customer Lifetime Value (CLV) using a simple model.
This uses the formula: CLV = (MRPC × GM) / Churn Rate
where MRPC is Monthly Revenue Per Customer.
Args:
monthly_revenue_per_customer (float): Average monthly revenue from one customer
gross_margin (float): Gross margin as a decimal (e.g., 0.35 for 35%)
churn_rate (float): Monthly churn rate as a decimal (e.g., 0.05 for 5%)
Returns:
float: Customer Lifetime Value in dollars
Example:
>>> calculate_clv(monthly_revenue_per_customer=85, gross_margin=0.40, churn_rate=0.05)
680.0
"""
if churn_rate <= 0:
raise ValueError("Churn rate must be positive")
monthly_margin = monthly_revenue_per_customer * gross_margin
clv = monthly_margin / churn_rate
return clv
Docstring conventions: - First line: one-sentence summary of what the function does - Args section: each parameter, its type, what it represents - Returns section: what the function returns and its type - Example (optional but very helpful for calculation functions) - Raises section if the function intentionally raises exceptions
In VS Code, when you type a function name and open the parenthesis, the docstring appears as a tooltip. This is what makes code self-documenting.
6.5 Scope: Local and Global Variables
Scope determines where a variable can be accessed. Python has two main scopes:
Local scope: Variables defined inside a function. They exist only within that function and are invisible outside it.
Global scope: Variables defined at the top level of a module. Visible everywhere.
# Global variable
company_name = "Acme Corp"
discount_tiers = {"Gold": 0.15, "Silver": 0.10, "Bronze": 0.05}
def apply_customer_discount(price, customer_tier):
"""Apply the discount for a given customer tier."""
# 'customer_tier' and 'price' are local to this function
# 'discount_tiers' is a global — readable from inside the function
discount_rate = discount_tiers.get(customer_tier, 0)
discounted = price * (1 - discount_rate)
return discounted
result = apply_customer_discount(100, "Gold")
print(f"${result:.2f}") # $85.00
# 'discount_rate' doesn't exist outside the function
print(discount_rate) # NameError: name 'discount_rate' is not defined
Best practice: Don't modify global variables inside functions. Pass data in through parameters and out through return values. This makes functions predictable and testable.
# BAD: function modifies a global variable (side effect, hard to test)
total = 0
def add_to_total(amount):
global total
total += amount # Modifying global — avoid this
# GOOD: function takes input and returns output
def add_amounts(current_total, amount):
return current_total + amount
total = 0
total = add_amounts(total, 100)
total = add_amounts(total, 250)
print(total) # 350
6.6 Lambda Functions
A lambda is a small, anonymous function defined in a single expression. They're useful when you need a simple function without giving it a name — most commonly as an argument to other functions.
# Regular function
def get_revenue(record):
return record["revenue"]
# Equivalent lambda
get_revenue = lambda record: record["revenue"]
# Most common use: as sorting key
sales_records = [
{"region": "Chicago", "revenue": 312450},
{"region": "Nashville", "revenue": 205340},
{"region": "Cincinnati", "revenue": 187890},
{"region": "St. Louis", "revenue": 168720},
]
# Sort by revenue (descending) using lambda as the key
sorted_sales = sorted(sales_records, key=lambda r: r["revenue"], reverse=True)
for record in sorted_sales:
print(f" {record['region']:<15} ${record['revenue']:>10,.0f}")
Lambda functions are a single expression — they can't contain statements like if/else blocks, for loops, or return (they implicitly return the expression's value). For anything more complex than a simple expression, use a regular def.
6.7 Building a Business Function Library
As you write more Python, you'll accumulate functions that you use across multiple scripts. The natural next step is organizing them into a module — a .py file that you import.
Creating a Module
Create a file called business_math.py:
"""
business_math.py
Common financial calculations for Acme Corp analytics.
Usage:
from business_math import calculate_gross_margin, apply_discount
"""
# Standard discount tiers (company policy)
DISCOUNT_TIERS = {
"Gold": 0.15,
"Silver": 0.10,
"Bronze": 0.05,
"Standard": 0.00,
}
def calculate_gross_margin(revenue, cogs):
"""
Calculate gross margin as a decimal.
Returns 0.0 if revenue is zero (avoids division by zero).
"""
if revenue == 0:
return 0.0
return (revenue - cogs) / revenue
def calculate_operating_margin(revenue, cogs, operating_expenses):
"""Calculate operating margin as a decimal."""
if revenue == 0:
return 0.0
gross_profit = revenue - cogs
operating_profit = gross_profit - operating_expenses
return operating_profit / revenue
def apply_customer_discount(price, customer_tier):
"""
Apply the standard customer discount for the given tier.
Returns the discounted price. Uses the DISCOUNT_TIERS table.
Unknown tiers receive no discount.
"""
discount_rate = DISCOUNT_TIERS.get(customer_tier, 0.0)
return price * (1 - discount_rate), discount_rate
def compound_growth(initial_value, annual_rate, years):
"""
Project a value forward using compound growth.
Args:
initial_value: Starting value
annual_rate: Annual growth rate (e.g., 0.08 for 8%)
years: Number of years to project
Returns:
Projected value after the given number of years
"""
return initial_value * (1 + annual_rate) ** years
def calculate_payback_months(investment, monthly_savings):
"""
Calculate how many months until an investment pays back.
Returns None if monthly_savings is zero or negative.
"""
if monthly_savings <= 0:
return None
return investment / monthly_savings
def format_currency(amount, include_cents=True):
"""
Format a number as a currency string.
Args:
amount: The dollar amount
include_cents: If True, show 2 decimal places; if False, show 0
Returns:
Formatted string like "$1,234.56" or "$1,235"
"""
if include_cents:
return f"${amount:,.2f}"
return f"${amount:,.0f}"
Using the Module
In another script in the same folder:
from business_math import (
calculate_gross_margin,
apply_customer_discount,
compound_growth,
format_currency,
)
# Now use the functions
margin = calculate_gross_margin(874_400, 550_000)
print(f"Gross margin: {margin:.1%}")
discounted_price, discount_rate = apply_customer_discount(500.00, "Gold")
print(f"Gold tier price: {format_currency(discounted_price)} ({discount_rate:.0%} off)")
projected = compound_growth(1_450_000, 0.08, 5)
print(f"Revenue in 5 years: {format_currency(projected, include_cents=False)}")
6.8 The DRY Principle in Practice
DRY — Don't Repeat Yourself — is the guiding principle of function design. Let's see it in practice.
Before (Priya's original scripts):
Script 1 (daily scorecard):
# Gross margin calculation — version in daily scorecard
gross_profit = revenue - (revenue * 0.63)
margin = gross_profit / revenue
Script 2 (weekly report):
# Gross margin calculation — version in weekly report
gp = total_rev - cogs
margin_rate = gp / total_rev
Script 3 (monthly summary):
# Gross margin calculation — version in monthly summary
gross_margin = (sales - cost_of_sales) / sales
Three slightly different implementations of the same business concept. One uses an assumed COGS rate (0.63 × revenue), another uses actual COGS. They'll produce different results.
After (with a shared function):
# In business_math.py — one authoritative implementation
def calculate_gross_margin(revenue, cogs):
"""Calculate gross margin as a decimal. Returns 0.0 if revenue is 0."""
if revenue == 0:
return 0.0
return (revenue - cogs) / revenue
# In every script, simply import and use:
from business_math import calculate_gross_margin
margin = calculate_gross_margin(revenue=874_400, cogs=550_000)
One implementation. Consistent everywhere. Change the formula once and every script gets the update.
6.9 Function Design Principles
Do One Thing
A function should do one thing. If you can't describe what a function does without using "and," it probably does too much.
# BAD: does multiple things
def process_order_and_email_customer_and_update_inventory(order):
# This function is trying to do three jobs
# GOOD: each function has one responsibility
def validate_order(order):
...
def calculate_order_total(order):
...
def send_order_confirmation(order, customer_email):
...
def update_inventory(order):
...
Keep It Short
As a rough guideline: if a function doesn't fit on one screen, consider splitting it. If you're scrolling to read a function, it probably has multiple responsibilities.
Naming: Verbs for Functions, Nouns for Variables
Function names should describe actions: calculate_, format_, validate_, apply_, generate_, check_, send_.
Variable names should describe things: revenue, margin_rate, customer_name, order_total.
Pure Functions vs. Functions with Side Effects
A pure function takes inputs and returns an output, with no other effects on the world:
# Pure function — predictable, testable, composable
def calculate_discount(price, rate):
return price * (1 - rate)
A function with side effects changes something in the world — writes to a file, sends an email, modifies a database, prints to screen:
# Function with side effect — sends an email
def send_overdue_notice(customer_email, invoice_number, amount_due):
# ...sends email...
print(f"Sent notice to {customer_email}")
Side effects are necessary — you can't write useful software without them. But:
- Keep pure calculation functions separate from functions with side effects
- Make side effects explicit (name them clearly: send_, write_, update_)
- Test pure functions easily; test side-effect functions carefully
6.10 A Complete Business Function Library Example
Let's build Priya's complete discount and pricing module:
"""
acme_pricing.py
Pricing and discount logic for Acme Corp.
All discount and pricing rules are defined here as authoritative functions.
Scripts that need pricing logic should import from this module.
Business rules sourced from: Acme Corp Pricing Policy v2.3 (2024-01-15)
"""
from typing import Optional
# ── CONSTANTS ─────────────────────────────────────────────────────────────────
# Customer tier discount rates
# Source: Sales Policy Document, Section 4.2
CUSTOMER_TIER_DISCOUNTS = {
"Platinum": 0.20,
"Gold": 0.15,
"Silver": 0.10,
"Bronze": 0.05,
"Standard": 0.00,
}
# Volume discount thresholds (applied on top of tier discount)
VOLUME_DISCOUNT_THRESHOLDS = [
(10_000, 0.05), # Orders $10,000+ get additional 5%
(5_000, 0.03), # Orders $5,000–$9,999 get additional 3%
(1_000, 0.01), # Orders $1,000–$4,999 get additional 1%
(0, 0.00), # Orders below $1,000 get no volume discount
]
# Standard tax rate (Illinois)
STANDARD_TAX_RATE = 0.1025
# Free shipping threshold
FREE_SHIPPING_MINIMUM = 500.00
STANDARD_SHIPPING_RATE = 0.035 # 3.5% of order value, minimum $12
def get_tier_discount(customer_tier: str) -> float:
"""
Return the discount rate for a customer tier.
Unknown tiers receive 0% discount (Standard tier treatment).
"""
return CUSTOMER_TIER_DISCOUNTS.get(customer_tier, 0.00)
def get_volume_discount(order_subtotal: float) -> float:
"""
Return the volume discount rate based on order size.
Discount tiers are applied to the pre-tier-discount subtotal.
"""
for threshold, rate in VOLUME_DISCOUNT_THRESHOLDS:
if order_subtotal >= threshold:
return rate
return 0.00
def calculate_order_total(
subtotal: float,
customer_tier: str = "Standard",
apply_volume_discount: bool = True,
apply_tax: bool = True,
include_shipping: bool = True,
) -> dict:
"""
Calculate the full order total with all applicable discounts and fees.
Args:
subtotal: Pre-discount order value
customer_tier: Customer tier for discount lookup
apply_volume_discount: Whether to apply volume discount on top of tier discount
apply_tax: Whether to apply sales tax
include_shipping: Whether to calculate and add shipping
Returns:
dict with keys: subtotal, tier_discount, volume_discount,
pre_tax_total, tax, shipping, final_total,
tier_discount_rate, volume_discount_rate
"""
# Tier discount
tier_rate = get_tier_discount(customer_tier)
tier_discount_amount = subtotal * tier_rate
after_tier = subtotal - tier_discount_amount
# Volume discount (applied after tier discount)
volume_rate = get_volume_discount(subtotal) if apply_volume_discount else 0.00
volume_discount_amount = after_tier * volume_rate
after_volume = after_tier - volume_discount_amount
# Tax
tax_amount = after_volume * STANDARD_TAX_RATE if apply_tax else 0.00
# Shipping
if not include_shipping:
shipping_amount = 0.00
elif after_volume >= FREE_SHIPPING_MINIMUM:
shipping_amount = 0.00
else:
shipping_amount = max(after_volume * STANDARD_SHIPPING_RATE, 12.00)
# Final total
final_total = after_volume + tax_amount + shipping_amount
return {
"subtotal": subtotal,
"tier_discount_rate": tier_rate,
"tier_discount": tier_discount_amount,
"after_tier_discount": after_tier,
"volume_discount_rate": volume_rate,
"volume_discount": volume_discount_amount,
"pre_tax_total": after_volume,
"tax": tax_amount,
"shipping": shipping_amount,
"final_total": final_total,
}
def format_order_summary(order_result: dict, customer_name: str = "") -> str:
"""
Format an order total result as a readable string.
Args:
order_result: Dict returned by calculate_order_total
customer_name: Optional customer name for the header
Returns:
Formatted multi-line string
"""
header = f"ORDER SUMMARY{' — ' + customer_name if customer_name else ''}"
lines = [
"=" * 45,
header,
"=" * 45,
f" Subtotal: ${order_result['subtotal']:>10,.2f}",
]
if order_result["tier_discount"] > 0:
lines.append(
f" Tier discount ({order_result['tier_discount_rate']:.0%}): "
f"-${order_result['tier_discount']:>9,.2f}"
)
if order_result["volume_discount"] > 0:
lines.append(
f" Volume discount ({order_result['volume_discount_rate']:.0%}): "
f"-${order_result['volume_discount']:>9,.2f}"
)
lines.extend([
f" Pre-tax total: ${order_result['pre_tax_total']:>10,.2f}",
f" Sales tax: ${order_result['tax']:>10,.2f}",
f" Shipping: ${order_result['shipping']:>10,.2f}",
"-" * 45,
f" TOTAL: ${order_result['final_total']:>10,.2f}",
"=" * 45,
])
return "\n".join(lines)
# ── EXAMPLE USAGE ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
# This block only runs when executing this file directly, not when imported
# Test case 1: Gold customer, large order
result = calculate_order_total(
subtotal=8_500.00,
customer_tier="Gold",
apply_volume_discount=True,
)
print(format_order_summary(result, "Hartmann Office Supply (Gold)"))
print()
# Test case 2: Standard customer, small order
result2 = calculate_order_total(
subtotal=350.00,
customer_tier="Standard",
)
print(format_order_summary(result2, "Walk-in Customer (Standard)"))
6.11 Error Handling in Functions
Functions should validate their inputs and communicate errors clearly. We'll cover error handling in depth in Chapter 8, but the pattern to introduce now:
def calculate_gross_margin(revenue, cogs):
"""
Calculate gross margin as a decimal.
Raises:
ValueError: If revenue is zero or negative
ValueError: If cogs is negative
"""
if revenue <= 0:
raise ValueError(f"Revenue must be positive, got {revenue}")
if cogs < 0:
raise ValueError(f"COGS cannot be negative, got {cogs}")
if cogs > revenue:
# This is valid but unusual — negative gross margin
# Warn but don't error
pass
return (revenue - cogs) / revenue
The raise ValueError(...) pattern stops the function and signals that something is wrong. The caller can handle it or let it propagate.
Summary
- A function is a named, reusable block of code:
def name(parameters): ... return value - Functions implement the DRY principle: write logic once, use it everywhere
- Parameters define inputs; return specifies output
- Default parameters make arguments optional:
def f(price, discount=0.10) - Keyword arguments improve readability:
f(price=100, discount=0.20) - Multiple return values use implicit tuple packing:
return a, b, c - Docstrings make functions self-documenting (and VS Code will display them)
- Scope: Local variables exist only inside functions; global variables are accessible everywhere but shouldn't be modified inside functions
- Lambda functions are one-line anonymous functions, useful as arguments to other functions
- Modules collect related functions into importable files
- Function design principles: Do one thing, keep it short, name with verbs, separate pure from side-effect functions
Chapter 7: Data Structures: Lists, Tuples, Dictionaries, and Sets →