Skip to content

Functions — Writing Reusable Code

If you find yourself writing the same logic twice, that is a function waiting to be born. Functions let you name a piece of logic, test it in isolation, and reuse it across your analysis. Every mature analytics codebase is built from functions. A script full of copy-pasted logic is a script full of bugs you will fix in one place but forget to fix everywhere else.

Learning Objectives

  • Define and call functions with def
  • Use positional parameters, default values, and keyword arguments
  • Return single and multiple values
  • Understand scope — local vs global variables
  • Write docstrings that make functions self-documenting
  • Use lambda functions for short, inline operations
  • Apply Python's most useful built-in functions for analytics
  • Handle errors gracefully with try/except

Defining a Function

def calculate_margin(revenue, cost):
    """Calculate gross margin as a percentage."""
    margin = (revenue - cost) / revenue * 100
    return round(margin, 2)

# Call it
margin = calculate_margin(50_000, 32_000)
print(f"Margin: {margin}%")   # Margin: 36.0%

# Call it again with different data — that's the point
margin_q2 = calculate_margin(47_500, 30_000)
print(f"Q2 Margin: {margin_q2}%")   # Q2 Margin: 36.84%

Anatomy of a function definition: - def — keyword that starts the definition - calculate_margin — name (snake_case, verb + noun) - (revenue, cost) — parameters (placeholders for values passed in) - """...""" — docstring (show up in help() and IDE tooltips) - return — sends a value back to the caller; without it the function returns None


Parameters and Arguments

Positional arguments

Arguments must be passed in the order parameters are defined.

def classify_order(total, threshold=100):
    """Classify an order as Large or Small relative to a threshold."""
    return "Large" if total >= threshold else "Small"

classify_order(250)              # "Large"  — threshold defaults to 100
classify_order(80)               # "Small"
classify_order(80, 50)           # "Large"  — threshold overridden to 50
classify_order(80, threshold=50) # "Large"  — keyword argument, same result

Multiple return values

Python functions can return multiple values as a tuple. Unpack them immediately.

def revenue_summary(amounts):
    """Return total, mean, min, and max of a list of amounts."""
    if not amounts:
        return 0, 0, 0, 0
    total = sum(amounts)
    mean  = total / len(amounts)
    return total, mean, min(amounts), max(amounts)

monthly = [42_000, 38_500, 51_200, 49_800, 55_100, 48_250]
total, avg, low, high = revenue_summary(monthly)
print(f"Total: £{total:,}  Avg: £{avg:,.0f}  Range: £{low:,}–£{high:,}")
# Total: £284,850  Avg: £47,475  Range: £38,500–£55,100

*args — variable number of positional arguments

def multi_sum(*args):
    """Sum any number of arguments."""
    return sum(args)

print(multi_sum(10, 20, 30))   # 60
print(multi_sum(5, 15))        # 20
print(multi_sum(*[100, 200]))  # 300 — unpack a list

**kwargs — variable number of keyword arguments

def log_event(event_type, **kwargs):
    """Log an event with arbitrary metadata."""
    print(f"Event: {event_type}")
    for key, val in kwargs.items():
        print(f"  {key}: {val}")

log_event("order_placed", customer="C001", total=149.99, status="pending")
# Event: order_placed
#   customer: C001
#   total: 149.99
#   status: pending

For mid-level analysts

**kwargs is useful when building flexible reporting functions. Instead of defining 10 optional parameters for different filter combinations, accept **filters and apply them dynamically. This makes the function forward-compatible — you can add new filter types without changing the function signature.


Scope — Local vs Global Variables

Variables created inside a function are local — they do not exist outside.

total_processed = 0    # global variable

def process_order(order_id, amount):
    """Process an order, return the net amount after fee."""
    global total_processed           # explicit declaration to modify global
    total_processed += 1
    fee = amount * 0.02              # local variable — not accessible outside
    net = amount - fee
    return net

net1 = process_order("O001", 100)
net2 = process_order("O002", 250)

print(total_processed)   # 2
print(net1)              # 98.0
print(net2)              # 245.0
# print(fee)             # NameError: fee is not defined

For mid-level analysts

Avoid global in production code. It makes functions hard to test and creates invisible dependencies. Instead, pass values as arguments and return results. Functions that do not read or write any external state (pure functions) are the most reliable and easiest to debug.

# Better pattern — pass state as an argument, return new state
def process_order_pure(order_id, amount, processed_count):
    """Pure function — no global state."""
    fee = amount * 0.02
    return amount - fee, processed_count + 1

net, count = process_order_pure("O001", 100, 0)
net, count = process_order_pure("O002", 250, count)
print(count)   # 2

Lambda Functions

A lambda is a short, anonymous function for one-off operations.

# Named function
def double(x):
    return x * 2

# Equivalent lambda
double = lambda x: x * 2

# Common use 1: sorting key
orders = [
    {"id": "O001", "total": 149.99},
    {"id": "O002", "total": 389.00},
    {"id": "O003", "total": 35.99},
]
orders.sort(key=lambda o: o["total"], reverse=True)
print([o["id"] for o in orders])   # ['O002', 'O001', 'O003']

# Common use 2: sorted() with a key
customers_by_spend = sorted(
    [{"name": "Alice", "spend": 1200}, {"name": "Bob", "spend": 800}],
    key=lambda c: c["spend"],
    reverse=True
)

# Common use 3: map and filter (though list comprehensions are often clearer)
revenues = [42_000, 38_500, 51_200, 49_800, 55_100]
scaled   = list(map(lambda x: x * 1.1, revenues))
above_50 = list(filter(lambda x: x >= 50_000, revenues))

For mid-level analysts

In Pandas, lambdas shine inside .apply():

df["order_size"] = df["total"].apply(lambda x: "Large" if x > 200 else "Small")
However, when the logic gets complex, replace the lambda with a named function — it is easier to read, test, and reuse.


Error Handling — try/except

Real data is messy. Functions that work on clean data will fail on real data unless you handle errors.

def safe_divide(numerator, denominator, default=0):
    """Divide two numbers, returning default instead of raising ZeroDivisionError."""
    try:
        return numerator / denominator
    except ZeroDivisionError:
        return default
    except TypeError as e:
        print(f"TypeError: {e}. Returning default.")
        return default

print(safe_divide(100, 4))       # 25.0
print(safe_divide(100, 0))       # 0    — ZeroDivisionError caught
print(safe_divide(100, "five"))  # 0    — TypeError caught, message printed

Handling multiple exception types

def load_and_parse(filepath):
    """Load a CSV and return a list of order dicts."""
    import csv
    from pathlib import Path

    try:
        path = Path(filepath)
        if not path.exists():
            raise FileNotFoundError(f"File not found: {filepath}")

        with open(path, "r", newline="", encoding="utf-8") as f:
            return list(csv.DictReader(f))

    except FileNotFoundError as e:
        print(f"[ERROR] {e}")
        return []
    except UnicodeDecodeError:
        # Re-try with a Windows-friendly encoding
        with open(filepath, "r", newline="", encoding="cp1252") as f:
            return list(csv.DictReader(f))
    except csv.Error as e:
        print(f"[ERROR] CSV parsing failed: {e}")
        return []
    except Exception as e:
        # Catch-all: log and re-raise so the caller knows something went wrong
        print(f"[UNEXPECTED ERROR] {type(e).__name__}: {e}")
        raise

Do not use bare except:

except: catches everything, including KeyboardInterrupt and SystemExit, making your script impossible to stop with Ctrl+C. Always specify at least except Exception:.


Useful Built-in Functions for Analytics

Python's standard library has functions that analysts use constantly.

data = [42_000, 38_500, 51_200, 49_800, 55_100, 48_250]

# Aggregation
print(sum(data))              # 284850
print(min(data))              # 38500
print(max(data))              # 55100
print(len(data))              # 6
print(round(sum(data) / len(data), 2))   # 47475.0

# Sorting
ascending  = sorted(data)                    # new list, ascending
descending = sorted(data, reverse=True)      # new list, descending
data.sort()                                  # modifies in place

# Enumeration and zipping
months = ["Jan", "Feb", "Mar"]
for i, month in enumerate(months, start=1):
    print(f"  {i}. {month}")

for month, rev in zip(months, data[:3]):
    print(f"  {month}: £{rev:,}")

# all() and any() — useful for validation
amounts = [100, 200, 150, 300]
print(all(a > 0 for a in amounts))       # True — all positive?
print(any(a > 250 for a in amounts))     # True — any above 250?

orders = [
    {"status": "completed", "paid": True},
    {"status": "completed", "paid": False},
]
all_paid = all(o["paid"] for o in orders if o["status"] == "completed")
print(f"All completed orders paid: {all_paid}")   # False

Writing Good Analytics Functions

Good analytics functions follow these principles:

  1. Single responsibility — one function does one thing
  2. Clear name — verb + noun: calculate_margin, filter_active_orders, load_csv
  3. Documented with a docstring — inputs, outputs, units, edge cases
  4. Return a value — don't print inside a function (print in the caller)
  5. Handle edge cases — empty input, zero denominators, None values
def calculate_cac(marketing_spend, new_customers):
    """
    Calculate Customer Acquisition Cost.

    Args:
        marketing_spend: Total marketing budget spent (float, in £).
        new_customers:   Number of new customers acquired (int).

    Returns:
        CAC in £ per customer (float), or None if new_customers is 0.

    Example:
        >>> calculate_cac(15_000, 450)
        33.33
    """
    if new_customers is None or new_customers == 0:
        return None
    if marketing_spend < 0:
        raise ValueError("marketing_spend cannot be negative")
    return round(marketing_spend / new_customers, 2)


def calculate_churn_rate(customers_start, customers_end, new_customers):
    """
    Calculate monthly churn rate.

    Formula: (customers_start - customers_end + new_customers) / customers_start

    Returns:
        Churn rate as a float between 0 and 1 (e.g. 0.05 = 5% churn).
        Returns None if customers_start is 0.
    """
    if customers_start == 0:
        return None
    churned = customers_start - customers_end + new_customers
    return round(churned / customers_start, 4)


# Test both
cac = calculate_cac(15_000, 450)
print(f"CAC: £{cac}")                     # CAC: £33.33

churn = calculate_churn_rate(1200, 1150, 80)
print(f"Monthly churn: {churn:.1%}")      # Monthly churn: 10.8%

A Reusable Analytics Toolkit Pattern

As you build analyses, collect your most-used functions in a module.

# analytics_utils.py — your personal analytics toolkit

import csv
import statistics
from pathlib import Path
from collections import defaultdict


def load_csv(filepath, numeric_cols=None, date_cols=None):
    """Load a CSV and return a list of dicts with type conversion."""
    numeric_cols = numeric_cols or []
    path = Path(filepath)
    if not path.exists():
        raise FileNotFoundError(filepath)
    with open(path, "r", newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    for row in rows:
        for col in numeric_cols:
            if col in row:
                try:
                    row[col] = float(row[col].replace(",", ""))
                except (ValueError, AttributeError):
                    row[col] = None
    return rows


def describe(values, label=""):
    """Print summary statistics for a list of numbers."""
    values = [v for v in values if v is not None]
    if not values:
        print(f"{label}: no data")
        return
    n = len(values)
    if label:
        print(f"\n--- {label} ---")
    print(f"  Count:  {n:,}")
    print(f"  Mean:   £{statistics.mean(values):,.2f}")
    print(f"  Median: £{statistics.median(values):,.2f}")
    print(f"  Std:    £{statistics.stdev(values):,.2f}" if n > 1 else "  Std:   N/A")
    print(f"  Min:    £{min(values):,.2f}")
    print(f"  Max:    £{max(values):,.2f}")


def group_and_sum(records, group_col, value_col):
    """Group records by a column and sum values. Returns a sorted dict."""
    totals = defaultdict(float)
    for r in records:
        key = r.get(group_col, "Unknown")
        val = r.get(value_col, 0) or 0
        totals[key] += float(val)
    return dict(sorted(totals.items(), key=lambda x: -x[1]))


# Usage
if __name__ == "__main__":
    orders = load_csv("orders.csv", numeric_cols=["quantity", "unit_price"])
    for o in orders:
        o["total"] = (o["quantity"] or 0) * (o["unit_price"] or 0)

    completed = [o for o in orders if o["status"] == "completed"]
    describe([o["total"] for o in completed], "Completed Orders")

    by_category = group_and_sum(completed, "category", "total")
    print("\nRevenue by Category:")
    for cat, rev in by_category.items():
        print(f"  {cat:<20} £{rev:>10,.2f}")

Practice Exercises

Warm-up 1. Write a function fahrenheit_to_celsius(f) that converts temperature. Test it with 32, 100, and 212. 2. Write a function count_above(data, threshold) that returns the count of values strictly above the threshold. Include a docstring. 3. Write a function greet_customer(name, segment="Regular") that returns "Welcome, [name]! Segment: [segment]." Use a default parameter.

Main 4. Write a function month_summary(revenues) that takes a list of monthly revenues and returns a dictionary with total, average, max_revenue, min_revenue, and growth_pct (percentage change from first to last month). Handle the case where the list is empty. 5. Write a function classify_customer(total_spend, total_orders) that returns "VIP" (spend > £1000 and orders > 5), "Regular" (spend > £200 or orders > 2), or "New" otherwise. 6. Write a function clean_price(price_str) that accepts strings like "£1,249.99", "$89.95", "1249.99" and returns a float. Use try/except to handle unparseable inputs by returning None.

Stretch 7. Write a function top_n_by_field(records, field, n=5) that takes a list of dicts and returns the top N records sorted by the given field (descending). Include error handling if the field doesn't exist. 8. Write a decorator function timer(func) that wraps a function, times its execution, prints the elapsed time, and returns the result. Use import time. Test it on a function that sleeps for 0.1 seconds.


Interview Questions

[Beginner] What is the difference between a parameter and an argument?

Show answer

A parameter is the variable listed in the function definition. An argument is the actual value passed when you call the function.

def greet(name):           # 'name' is a parameter
    print(f"Hello, {name}!")

greet("Alice")             # "Alice" is the argument

[Beginner] What does return do in a function? What happens if a function has no return statement?

Show answer

return sends a value back to the caller and exits the function. A function without return implicitly returns None.

def print_total(data):
    total = sum(data)
    print(f"Total: {total}")    # prints, but does not return

result = print_total([100, 200])
print(result)    # None — the function returned nothing

# Fix: return the value, print in the caller
def calculate_total(data):
    return sum(data)

result = calculate_total([100, 200])
print(f"Total: {result}")    # 300

Analytics best practice: functions that calculate something should return the result. Functions that print or log are fine without return. Keep calculation and display separate.


[Mid-level] What is a lambda function and when would you use one?

Show answer

A lambda is a short, anonymous function defined in one line. It can take any number of arguments and returns a single expression.

# Equivalent forms
def square(x):
    return x ** 2

square = lambda x: x ** 2

# Best use: as a key function for sorting or filtering
orders = [{"id": "O1", "total": 150}, {"id": "O2", "total": 80}]
orders.sort(key=lambda o: o["total"])     # sort by total in place
top = sorted(orders, key=lambda o: -o["total"])  # descending

# In Pandas
df["tier"] = df["revenue"].apply(lambda r: "High" if r > 1000 else "Low")

Use lambdas for simple, one-off operations. Replace them with named functions when the logic is complex or needs a docstring.


[Mid-level] Explain the difference between *args and **kwargs.

Show answer
  • *args collects extra positional arguments into a tuple
  • **kwargs collects extra keyword arguments into a dict
def report(*metrics, title="Report", **filters):
    print(f"=== {title} ===")
    print(f"Metrics: {metrics}")         # tuple
    print(f"Filters: {filters}")         # dict

report("revenue", "orders", title="Q1 Summary", region="India", segment="VIP")
# === Q1 Summary ===
# Metrics: ('revenue', 'orders')
# Filters: {'region': 'India', 'segment': 'VIP'}

In analytics, **kwargs is useful for building flexible filter functions. Pass whatever filters make sense for the report, and apply them dynamically inside the function.


[Senior] What is a closure? Write an example where a closure is useful in data analytics.

Show answer

A closure is a function that "remembers" variables from the enclosing scope even after that scope has finished executing.

def make_threshold_filter(threshold):
    """Return a filter function for a given threshold."""
    def filter_above(values):
        return [v for v in values if v > threshold]
    return filter_above    # returns the inner function

# Create two specialised filters
filter_above_50k = make_threshold_filter(50_000)
filter_above_100k = make_threshold_filter(100_000)

revenues = [42_000, 38_500, 51_200, 49_800, 55_100, 120_000]
print(filter_above_50k(revenues))    # [51200, 55100, 120000]
print(filter_above_100k(revenues))   # [120000]

Closures are useful when you need to create families of functions that share a parameter (like a threshold, a column name, or a currency conversion rate) without repeating logic. They are also the mechanism behind decorators.


Previous: 01-python-basics | Next: 03-lists-and-dictionaries