8 min read

> "Don't repeat yourself. Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."

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:

  1. 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.

  2. Testability. A named function can be tested with specific inputs. A block of inline code buried in a script cannot.

  3. Readability. calculate_gross_margin(revenue, cogs) communicates intent instantly. The math that implements it can be a black box that readers trust.

  4. 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 →