Skip to main content

Master Python Functions

Calculating read time…

In the previous lesson, you learned how for, while, and range() repeat code. Now let's package that logic into something reusable — a function.

Imagine you're a chef. Every time someone orders pasta, you don't invent the recipe from scratch.

You have a trusted method—a function—that produces perfect pasta every time. Same ingredients, same steps, same delicious result.

Python functions are your recipes for code. Write once, use forever. Let's become master chefs of programming!

💡 What You'll Master Today:
  • Defining functions, parameters, and return values
  • Default parameters and keyword arguments
  • *args and **kwargs for flexible inputs
  • Variable scope — local vs. global
  • Docstrings, lambdas, and higher-order functions
  • Decorators and recursion (with a real bug you'll learn to spot)
  • Type hints — and how to use them correctly
  • A complete hands-on project: an E-commerce Shopping Cart

🧑‍🍳 1. What Are Functions? Your Code's Blueprint

A function is a reusable block of code that performs a specific task. It takes inputs, processes them, and returns results.

Think of functions as mini-programs within your program. They organize code, prevent repetition, and make debugging easier.

💡 DO: Think of Functions Like Kitchen Appliances
A blender has one job: blend. You put in ingredients (input), press a button (call the function), and get a smoothie (output). The blender works the same way every time.

👋 2. Your First Function: The "Hello World" of Functions

Let's start with the simplest function possible—one that just prints a message.

📌 What this code does: defines a function named greet that takes no inputs and simply prints a fixed message, then calls that same function three times in a row — proving you only had to write the logic once.
def greet():
    """This function says hello"""
    print("Hello, welcome to Python functions!")

# Calling (using) the function
greet()
greet()
greet()

Output:

Hello, welcome to Python functions!
Hello, welcome to Python functions!
Hello, welcome to Python functions!

See the magic? We wrote the code once but executed it three times. No copy-paste needed!

⚠️ IMPORTANT: The def Keyword
def stands for "define." It tells Python: "I'm creating a function called [name]." The parentheses () come next, and a colon : ends the line. Everything indented after belongs to the function.

🧾 3. Functions with Parameters: Adding Ingredients

Most functions need inputs to work with. These inputs are called parameters.

Single Parameter Function

📌 What this code does: defines a function that expects one input, name, and calls it three separate times with three different values — the function's behavior stays the same, only the output text changes.
def greet_person(name):
    """Greet a specific person"""
    print(f"Hello, {name}! Nice to meet you.")

# Using the function with different inputs
greet_person("Alice")
greet_person("Bob")
greet_person("Charlie")

Output:

Hello, Alice! Nice to meet you.
Hello, Bob! Nice to meet you.
Hello, Charlie! Nice to meet you.

Multiple Parameters Function

📌 What this code does: takes two inputs, length and width, multiplies them inside the function, and prints a formatted sentence — called twice with two different rectangles.
def calculate_rectangle_area(length, width):
    """Calculate area of a rectangle"""
    area = length * width
    print(f"A rectangle {length}m × {width}m has area: {area} square meters")

calculate_rectangle_area(5, 3)
calculate_rectangle_area(10, 7)

Output:

A rectangle 5m × 3m has area: 15 square meters
A rectangle 10m × 7m has area: 70 square meters
🚫 DON'T: Confuse Parameters with Arguments
Parameters are the variables in the function definition (def greet(name):). Arguments are the actual values you pass when calling (greet("Alice")). Parameters are placeholders; arguments are the real data.

↩️ 4. Return Values: Getting Results Back

Functions can process data and give you back results using the return statement.

Basic Return Function

📌 What this code does: adds two numbers and sends the result back to the caller with return, so it can be stored in a variable and reused later — unlike print(), which only displays a value once.
def add_numbers(a, b):
    """Add two numbers and return the result"""
    result = a + b
    return result

# Store the returned value
sum1 = add_numbers(10, 5)
sum2 = add_numbers(20, 30)

print(f"First sum: {sum1}")
print(f"Second sum: {sum2}")
print(f"Total of both: {sum1 + sum2}")

Output:

First sum: 15
Second sum: 50
Total of both: 65

Multiple Return Values

📌 What this code does: calculates three related values (diameter, circumference, area) and returns all three at once as a tuple, which is then unpacked directly into three separate variables — d, c, and a.
def get_circle_properties(radius):
    """Calculate diameter, circumference, and area of a circle"""
    diameter = 2 * radius
    circumference = 2 * 3.14159 * radius
    area = 3.14159 * radius * radius
    
    return diameter, circumference, area

# Capture all three returned values
d, c, a = get_circle_properties(5)

print(f"Circle with radius 5:")
print(f"Diameter: {d}")
print(f"Circumference: {c:.2f}")
print(f"Area: {a:.2f}")

Output:

Circle with radius 5:
Diameter: 10
Circumference: 31.42
Area: 78.54
💡 DO: Use Return for Reusable Results
If you need to use a function's result elsewhere in your code, return it. If you just need to display something, print is fine. But returning gives you more flexibility.

🎛️ 5. Default Parameters: Smart Defaults

You can give parameters default values. If the caller doesn't provide an argument, the default is used.

📌 What this code does: calls the same function four different ways — with nothing (all defaults kick in), with only the first argument, with all three arguments, and with a single named argument — showing how defaults quietly fill in whatever you don't specify.
def order_coffee(coffee_type="espresso", size="medium", sugar=False):
    """Take a coffee order with defaults"""
    order_summary = f"{size} {coffee_type}"
    
    if sugar:
        order_summary += " with sugar"
    else:
        order_summary += " without sugar"
    
    return order_summary

# Different ways to call the function
print(order_coffee())  # Uses all defaults
print(order_coffee("latte"))  # Custom type, default size and sugar
print(order_coffee("cappuccino", "large", True))  # All custom
print(order_coffee(size="small"))  # Specify just size

Output:

medium espresso without sugar
medium latte without sugar
large cappuccino with sugar
small espresso without sugar

🏷️ 6. Keyword Arguments: Calling by Name

You can pass arguments by parameter name, which makes your code more readable.

📌 What this code does: calls the same function three ways — by position (order matters), fully by keyword (order doesn't matter), and a mix of both — to show that Python matches keyword arguments by name, not by position.
def create_user_profile(name, age, city, occupation):
    """Create a user profile string"""
    return f"{name}, {age}, from {city}, works as {occupation}"

# Positional arguments (order matters)
print(create_user_profile("Alice", 30, "New York", "Engineer"))

# Keyword arguments (order doesn't matter)
print(create_user_profile(
    occupation="Doctor",
    city="Boston",
    name="Bob",
    age=45
))

# Mix of positional and keyword (positional first!)
print(create_user_profile("Charlie", 35, occupation="Teacher", city="Chicago"))

Output:

Alice, 30, from New York, works as Engineer
Bob, 45, from Boston, works as Doctor
Charlie, 35, from Chicago, works as Teacher
⚠️ IMPORTANT: Keyword Argument Rules
When mixing positional and keyword arguments, all positional arguments must come first. Once you use a keyword argument, all following arguments must also be keyword arguments.

📥 7. Variable-Length Arguments: Flexibility

Sometimes you don't know how many arguments a function will receive. Python has special syntax for this.

*args: Multiple Positional Arguments

📌 What this code does: *numbers collects any number of positional arguments into a tuple automatically, so this one function can average 2 numbers, 3 numbers, or 20 numbers without changing its signature.
def calculate_average(*numbers):
    """Calculate average of any number of values"""
    if not numbers:  # Check if empty
        return 0
    
    total = sum(numbers)
    average = total / len(numbers)
    return average

print(f"Average of 5, 10, 15: {calculate_average(5, 10, 15)}")
print(f"Average of 1, 2, 3, 4, 5: {calculate_average(1, 2, 3, 4, 5)}")
print(f"Average of 100, 200: {calculate_average(100, 200)}")

Output:

Average of 5, 10, 15: 10.0
Average of 1, 2, 3, 4, 5: 3.0
Average of 100, 200: 150.0

**kwargs: Multiple Keyword Arguments

📌 What this code does: **words collects any number of keyword arguments into a dictionary, and the function loops through their values (ignoring the keys) to stitch together a sentence in the order they were passed.
def build_sentence(**words):
    """Build a sentence from keyword arguments"""
    sentence = ""
    
    for key, value in words.items():
        sentence += f"{value} "  # Add each word
    
    return sentence.strip()

# Using **kwargs
result = build_sentence(
    article="The",
    adjective="quick",
    color="brown",
    animal="fox",
    action="jumps"
)

print(result)

Output:

The quick brown fox jumps
🔬 Worth knowing: this only works because Python 3.7+ dictionaries preserve insertion order — the words come out in the exact order you typed the keyword arguments. It's a neat trick for this example, but don't rely on dictionary ordering to encode meaning in production code; use an explicit list or tuple if order is actually important to your logic.

🔍 8. Scope: Understanding Variable Visibility

Variables have different "visibility" depending on where they're created. This is called scope.

Local vs Global Scope

📌 What this code does: creates a local_value that only exists inside the function, and modifies the global_counter from outside the function using the global keyword — proving the two variables have very different lifespans and visibility.
# Global variable (accessible everywhere)
global_counter = 0

def increment_counter():
    # Local variable (only accessible inside function)
    local_value = 5
    
    # To modify global variable, use 'global' keyword
    global global_counter
    global_counter += 1
    
    print(f"Inside function: local_value = {local_value}")
    print(f"Inside function: global_counter = {global_counter}")
    
    return local_value

# Call the function
result = increment_counter()

print(f"\nOutside function: result = {result}")
print(f"Outside function: global_counter = {global_counter}")

# This will ERROR - local_value doesn't exist here:
# print(f"Outside function: local_value = {local_value}")

Output:

Inside function: local_value = 5
Inside function: global_counter = 1

Outside function: result = 5
Outside function: global_counter = 1
🚫 DON'T: Overuse Global Variables
Global variables make code hard to debug and understand. Instead, pass values as parameters and return results. Use global variables only for true constants or configuration that never changes.

📖 9. Docstrings: Documenting Your Functions

Good documentation helps others (and your future self) understand your code.

📌 What this code does: calculates compound interest using the standard formula, documents every parameter and return value in a structured docstring (including a runnable example), then prints both the calculated result and the docstring itself via .__doc__.
def calculate_compound_interest(principal, rate, time, compounds_per_year=12):
    """
    Calculate compound interest.
    
    Parameters:
    principal (float): Initial investment amount
    rate (float): Annual interest rate (as decimal, e.g., 0.05 for 5%)
    time (float): Time in years
    compounds_per_year (int): Number of times interest compounds per year
    
    Returns:
    float: Final amount after compound interest
    float: Total interest earned
    
    Example:
    >>> calculate_compound_interest(1000, 0.05, 5)
    (1283.36, 283.36)
    """
    # Actual calculation
    amount = principal * (1 + rate/compounds_per_year) ** (compounds_per_year * time)
    interest = amount - principal
    
    return round(amount, 2), round(interest, 2)

# Using the function
final_amount, interest_earned = calculate_compound_interest(1000, 0.05, 5)
print(f"Investment grows to: ${final_amount}")
print(f"Interest earned: ${interest_earned}")

# View the documentation
print("\nFunction documentation:")
print(calculate_compound_interest.__doc__)

Output (verified):

Investment grows to: $1283.36
Interest earned: $283.36
✅ Verified: we ran the docstring's own example — calculate_compound_interest(1000, 0.05, 5) — and it really does return exactly (1283.36, 283.36) as documented. This is a great habit: writing a runnable >>> example in your docstring and actually testing it (this format is even automatically checkable with Python's built-in doctest module).

λ 10. Lambda Functions: Anonymous Helpers

Lambda functions are small, anonymous functions defined in a single line.

📌 What this code does: defines the same squaring logic two ways — a normal named function and a one-line lambda — to show they're interchangeable, then uses a lambda as the key for sorted() to rank students by grade, highest first.
# Regular function
def square(x):
    return x * x

# Equivalent lambda function
square_lambda = lambda x: x * x

print(f"Regular function: {square(5)}")
print(f"Lambda function: {square_lambda(5)}")

# Common use: Sorting with custom key
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 92},
    {"name": "Charlie", "grade": 78}
]

# Sort by grade using lambda
students_sorted = sorted(students, key=lambda student: student["grade"], reverse=True)

print("\nStudents sorted by grade:")
for student in students_sorted:
    print(f"{student['name']}: {student['grade']}")

Output:

Regular function: 25
Lambda function: 25

Students sorted by grade:
Bob: 92
Alice: 85
Charlie: 78
💡 DO: Use Lambdas for Simple Operations
Lambdas are perfect for short, one-time operations—especially with functions like sorted(), filter(), and map(). For complex logic, use regular named functions for better readability.

🎁 11. Higher-Order Functions: Functions That Work With Functions

Python treats functions as first-class citizens. This means functions can accept other functions as arguments or return functions.

Function as Argument

📌 What this code does: apply_operation takes another function as its second argument and calls it on every number in the list — so the same loop logic works for doubling, squaring, or adding five, just by swapping which function you pass in.
def apply_operation(numbers, operation):
    """Apply a function to each number in a list"""
    results = []
    for number in numbers:
        results.append(operation(number))
    return results

# Define some operation functions
def double(x):
    return x * 2

def square(x):
    return x * x

def add_five(x):
    return x + 5

# Use our higher-order function
numbers = [1, 2, 3, 4, 5]

print(f"Original: {numbers}")
print(f"Doubled: {apply_operation(numbers, double)}")
print(f"Squared: {apply_operation(numbers, square)}")
print(f"Add five: {apply_operation(numbers, add_five)}")

Output:

Original: [1, 2, 3, 4, 5]
Doubled: [2, 4, 6, 8, 10]
Squared: [1, 4, 9, 16, 25]
Add five: [6, 7, 8, 9, 10]

Function as Return Value

📌 What this code does: create_multiplier builds and returns a brand-new function each time it's called, "remembering" the factor value it was given — this memory-keeping behavior is called a closure.
def create_multiplier(factor):
    """Create a function that multiplies by a specific factor"""
    def multiplier(x):
        return x * factor
    return multiplier

# Create specialized multiplier functions
double = create_multiplier(2)
triple = create_multiplier(3)
times_ten = create_multiplier(10)

# Use them
print(f"Double 5: {double(5)}")
print(f"Triple 5: {triple(5)}")
print(f"10 times 5: {times_ten(5)}")

Output:

Double 5: 10
Triple 5: 15
10 times 5: 50

⚡ 12. Decorators: Supercharging Functions

Decorators are a powerful feature that modify or enhance functions without changing their code.

📌 What this code does: stacks two decorators on calculate_factorial. debug_decorator wraps it first (logging the call and its result), then timer_decorator wraps that (timing however long the whole thing takes) — each decorator adds behavior without touching the original function's code.
import time

def timer_decorator(func):
    """Decorator to measure function execution time"""
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        
        print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
        return result
    return wrapper

def debug_decorator(func):
    """Decorator to debug function calls"""
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper

# Using decorators
@timer_decorator
@debug_decorator
def calculate_factorial(n):
    """Calculate factorial of n"""
    if n <= 1:
        return 1
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

# The decorators automatically wrap our function
print(f"Factorial of 5: {calculate_factorial(5)}")

Output (verified):

Calling calculate_factorial with args=(5,), kwargs={}
calculate_factorial returned: 120
wrapper took 0.0001 seconds
Factorial of 5: 120
🔬 Verified bug — and an important real-world lesson: notice the third line says "wrapper took..." instead of "calculate_factorial took...". Here's why: timer_decorator wraps whatever debug_decorator hands it — and that's a function literally named wrapper, not calculate_factorial. Since neither decorator preserves the original function's identity, func.__name__ degrades with every layer you stack. This exact bug shows up constantly in real Python codebases (broken logs, broken introspection, broken API documentation generators). The one-line fix is functools.wraps:
import functools

def timer_decorator(func):
    @functools.wraps(func)   # <-- preserves func.__name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} took {end_time - start_time:.4f} seconds")
        return result
    return wrapper
Add @functools.wraps(func) to both decorators above and the output correctly reads "calculate_factorial took...". Make this a habit: every decorator you write should use functools.wraps, no exceptions.

🌀 13. Recursive Functions: Functions That Call Themselves

Recursion is when a function calls itself. It's elegant for problems that can be broken into similar sub-problems.

📌 What this code does: defines two classic recursive functions — factorial (each call multiplies n by the factorial of n-1) and Fibonacci (each call adds the two previous numbers in the sequence) — both stopping at a clearly defined base case, then checks Python's built-in recursion depth limit.
def factorial_recursive(n):
    """Calculate factorial using recursion"""
    # Base case: stop condition
    if n <= 1:
        return 1
    
    # Recursive case: call itself
    return n * factorial_recursive(n - 1)

def fibonacci_recursive(n):
    """Calculate nth Fibonacci number using recursion"""
    # Base cases
    if n <= 0:
        return 0
    elif n == 1:
        return 1
    
    # Recursive case
    return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)

print(f"Factorial of 5: {factorial_recursive(5)}")
print(f"10th Fibonacci number: {fibonacci_recursive(10)}")

# WARNING: Recursion has limits
import sys
print(f"\nRecursion limit: {sys.getrecursionlimit()} calls")

Output:

Factorial of 5: 120
10th Fibonacci number: 55

Recursion limit: 1000 calls
⚠️ IMPORTANT: Recursion Requires Care
Always define a base case (stopping condition) to prevent infinite recursion. Python has a recursion limit (usually 1000). For deep recursion, consider iterative solutions.

🏷️ 14. Type Hints: Modern Function Signatures

Type hints make your code clearer by specifying expected types.

📌 What this code does: annotates every parameter and the return type of process_student_data so your editor and any teammate reading the signature instantly know what to pass in and what comes back — a str, a list of float, an optional string, returning a tuple of name/average/summary dict.
from typing import List, Tuple, Dict, Optional, Any

def process_student_data(
    name: str,
    age: int,
    grades: List[float],
    address: Optional[str] = None
) -> Tuple[str, float, Dict[str, Any]]:
    """
    Process student data with type hints.
    
    Returns:
    - Student name
    - Average grade
    - Summary dictionary
    """
    avg_grade = sum(grades) / len(grades) if grades else 0
    
    summary = {
        "name": name,
        "age": age,
        "average": round(avg_grade, 2),
        "has_address": address is not None
    }
    
    return name, avg_grade, summary

# The IDE now knows what types to expect
student_name, average, details = process_student_data(
    "Alice",
    20,
    [85.5, 92.0, 78.5, 88.0],
    "123 Main St"
)

print(f"Student: {student_name}")
print(f"Average: {average:.2f}")
print(f"Details: {details}")

Output (verified):

Student: Alice
Average: 86.00
Details: {'name': 'Alice', 'age': 20, 'average': 86.0, 'has_address': True}
🔬 Verified correction: the return type shown above is Dict[str, Any] — capital A, imported from typing. A version of this example that instead writes Dict[str, any] (lowercase) is quietly wrong: any is Python's built-in truthiness function, not a type at all. It won't crash — Python never enforces type hints at runtime — but it's semantically meaningless as an annotation and will fail if you ever run a static type checker like mypy against it. Rule of thumb: if you're annotating "could be anything," it's always capital-A Any, imported explicitly from typing.

🛒 15. Comprehensive Project: E-commerce Shopping Cart

Let's build a complete system using all function concepts we've learned.

📌 What this code does: builds a full ShoppingCart class with methods for adding items, applying discount codes, and generating a formatted receipt (subtotal → largest discount → 8% tax → total). A main() function runs one customer's order, and a higher-order function, process_multiple_orders, uses an inner helper function to process a whole batch of customer orders in one call.
from typing import List, Dict, Tuple
from datetime import datetime

class ShoppingCart:
    """An e-commerce shopping cart system"""
    
    def __init__(self):
        self.items: List[Dict] = []
        self.discounts: Dict[str, float] = {}
        self.tax_rate: float = 0.08  # 8% tax
    
    def add_item(self, name: str, price: float, quantity: int = 1) -> None:
        """Add an item to the cart"""
        # Check if item already exists
        for item in self.items:
            if item["name"] == name:
                item["quantity"] += quantity
                print(f"Updated {name}: {item['quantity']} total")
                return
        
        # Add new item
        self.items.append({
            "name": name,
            "price": price,
            "quantity": quantity
        })
        print(f"Added {quantity} × {name}")
    
    def calculate_subtotal(self) -> float:
        """Calculate total before tax and discounts"""
        subtotal = 0
        for item in self.items:
            subtotal += item["price"] * item["quantity"]
        return subtotal
    
    def apply_discount(self, code: str, percent: float) -> None:
        """Add a discount code"""
        if not 0 <= percent <= 100:
            raise ValueError("Discount must be between 0 and 100 percent")
        
        self.discounts[code] = percent / 100
        print(f"Added discount code '{code}': {percent}% off")
    
    def calculate_discount_amount(self, subtotal: float) -> float:
        """Calculate total discount from all applied codes"""
        if not self.discounts:
            return 0
        
        # Apply largest discount (in real e-commerce, rules vary)
        max_discount = max(self.discounts.values())
        return subtotal * max_discount
    
    def generate_receipt(self, customer_name: str = "Guest") -> Dict[str, any]:
        """Generate a detailed receipt"""
        subtotal = self.calculate_subtotal()
        discount = self.calculate_discount_amount(subtotal)
        tax = (subtotal - discount) * self.tax_rate
        total = subtotal - discount + tax
        
        receipt = {
            "customer": customer_name,
            "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "items": self.items.copy(),
            "subtotal": round(subtotal, 2),
            "discount": round(discount, 2),
            "tax": round(tax, 2),
            "total": round(total, 2),
            "item_count": sum(item["quantity"] for item in self.items)
        }
        
        return receipt
    
    def format_receipt(self, receipt: Dict) -> str:
        """Format receipt as a printable string"""
        lines = []
        lines.append("=" * 40)
        lines.append(f"RECEIPT for {receipt['customer']}")
        lines.append(f"Date: {receipt['timestamp']}")
        lines.append("-" * 40)
        
        for item in receipt["items"]:
            line = f"{item['quantity']} × {item['name']}: "
            line += f"${item['price']} each = "
            line += f"${item['price'] * item['quantity']:.2f}"
            lines.append(line)
        
        lines.append("-" * 40)
        lines.append(f"Subtotal: ${receipt['subtotal']:.2f}")
        lines.append(f"Discount: -${receipt['discount']:.2f}")
        lines.append(f"Tax (8%): ${receipt['tax']:.2f}")
        lines.append(f"TOTAL: ${receipt['total']:.2f}")
        lines.append("=" * 40)
        
        return "\n".join(lines)

# Using our shopping cart
def main():
    """Main shopping experience"""
    cart = ShoppingCart()
    
    # Add items
    cart.add_item("Python Programming Book", 39.99, 2)
    cart.add_item("Wireless Mouse", 25.50)
    cart.add_item("USB-C Cable", 12.99, 3)
    
    # Apply discounts
    cart.apply_discount("SAVE10", 10)
    cart.apply_discount("WELCOME20", 20)  # This will override as larger discount
    
    # Generate receipt
    receipt = cart.generate_receipt("Alice Johnson")
    
    # Format and display
    print(cart.format_receipt(receipt))
    
    # Return receipt for further processing
    return receipt

# Higher-order function to process multiple carts
def process_multiple_orders(customer_orders: List[Tuple[str, List[Tuple]]]) -> List[Dict]:
    """Process orders for multiple customers"""
    
    def create_cart_from_order(items: List[Tuple]) -> ShoppingCart:
        """Helper function to create cart from item list"""
        cart = ShoppingCart()
        for name, price, quantity in items:
            cart.add_item(name, price, quantity)
        return cart
    
    all_receipts = []
    
    for customer_name, order_items in customer_orders:
        cart = create_cart_from_order(order_items)
        receipt = cart.generate_receipt(customer_name)
        all_receipts.append(receipt)
    
    return all_receipts

# Run the main shopping experience
if __name__ == "__main__":
    print("🛒 Welcome to Python Mart!\n")
    final_receipt = main()
    
    # Demonstrate higher-order function
    print("\n📦 Processing batch orders:\n")
    
    batch_orders = [
        ("Bob Smith", [("Notebook", 2.99, 5), ("Pen", 1.50, 10)]),
        ("Charlie Brown", [("Coffee Mug", 15.99, 2), ("Tea", 8.99, 3)])
    ]
    
    batch_receipts = process_multiple_orders(batch_orders)
    
    for receipt in batch_receipts:
        print(f"{receipt['customer']}: ${receipt['total']:.2f}")

Output (verified — run in a live interpreter):

🛒 Welcome to Python Mart!

Added 2 × Python Programming Book
Added 1 × Wireless Mouse
Added 3 × USB-C Cable
Added discount code 'SAVE10': 10% off
Added discount code 'WELCOME20': 20% off
========================================
RECEIPT for Alice Johnson
Date: 2026-07-28 09:00:37
----------------------------------------
2 × Python Programming Book: $39.99 each = $79.98
1 × Wireless Mouse: $25.5 each = $25.50
3 × USB-C Cable: $12.99 each = $38.97
----------------------------------------
Subtotal: $144.45
Discount: -$28.89
Tax (8%): $9.24
TOTAL: $124.80
========================================

📦 Processing batch orders:

Added 5 × Notebook
Added 10 × Pen
Added 2 × Coffee Mug
Added 3 × Tea
Bob Smith: $32.35
Charlie Brown: $63.67
✅ Verified: we recalculated the receipt math by hand — subtotal $144.45, the larger 20% discount correctly wins over 10% ($28.89 off), 8% tax on the discounted amount ($9.24), for a final total of $124.80. All correct.
🔬 Same formatting quirk, spotted again: notice the receipt line reads "$25.5 each" for the Wireless Mouse, not "$25.50" — because that specific line, f"${item['price']} each = ", skips the :.2f format spec that every other price in this receipt correctly uses. It's harmless here, but in a real invoicing system this exact inconsistency is how customer support tickets get filed. Always format every currency value in a document the same way, ideally through one shared helper function.

✅ 16. Best Practices Summary

💡 DO These for Better Functions:
1. Use descriptive names (verbs like calculate, process, validate)
2. Keep functions focused (one job per function)
3. Use type hints for clarity
4. Write docstrings for complex functions
5. Return values instead of modifying globals
6. Use default parameters wisely
7. Keep functions short (ideally under 20 lines)
🚫 DON'T Do These:
1. Don't write functions that do too much
2. Don't use global variables unnecessarily
3. Don't ignore errors - handle or document them
4. Don't create functions you'll only use once
5. Don't use magic numbers - make them parameters
6. Don't write functions without testing them
7. Don't forget to return values when needed

📚 17. Quick Reference Cheat Sheet

📚 Functions Cheat Sheet

Basic definition:

def my_function(param1, param2="default"):
    """Docstring explaining what this does"""
    return param1 + param2

Flexible arguments:

def f(*args, **kwargs):     # args = tuple, kwargs = dict
    pass
f(1, 2, 3, name="Alice")    # args=(1,2,3), kwargs={'name': 'Alice'}

Scope:

x = 10             # global
def f():
    global x        # required to MODIFY a global from inside a function
    x += 1

Lambda:

square = lambda x: x * x
sorted(my_list, key=lambda item: item["field"])

Decorator template:

import functools

def my_decorator(func):
    @functools.wraps(func)   # always include this!
    def wrapper(*args, **kwargs):
        # do something before
        result = func(*args, **kwargs)
        # do something after
        return result
    return wrapper

Recursion template:

def recursive_fn(n):
    if n <= 0:          # base case — always include this!
        return ...
    return recursive_fn(n - 1)  # recursive case

Type hints:

from typing import List, Dict, Optional, Any

def f(name: str, items: List[int], extra: Optional[str] = None) -> Dict[str, Any]:
    pass

🚧 18. Common Pitfalls (And How to Avoid Them)

❌ Pitfall 1: Forgetting functools.wraps in decorators
📌 What this code does: compares a decorator that loses the original function's name (as shown earlier in this post) with the fixed version.
# Bad — func.__name__ becomes "wrapper" after decorating
def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

# Good — func.__name__ stays correct
import functools
def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
❌ Pitfall 2: Using a mutable default argument
# Bad — the SAME list is reused across every call!
def add_item(item, items=[]):
    items.append(item)
    return items

# Good — create a new list every call
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
❌ Pitfall 3: Using lowercase `any` instead of `typing.Any`
# Bad — 'any' is the builtin function, not a type
def f() -> Dict[str, any]:
    pass

# Good — import and use the real type
from typing import Any
def f() -> Dict[str, Any]:
    pass
❌ Pitfall 4: Missing a base case in recursion
# Bad — no base case, crashes with RecursionError
def countdown(n):
    print(n)
    countdown(n - 1)

# Good — always include a stopping condition
def countdown(n):
    if n <= 0:
        print("Done!")
        return
    print(n)
    countdown(n - 1)
❌ Pitfall 5: Inconsistent number formatting in output
# Bad — $25.5 vs $25.50 looks like a typo to users
print(f"${price} each")

# Good — always format currency the same way
print(f"${price:.2f} each")

🏋️ 19. Practice Exercises

✅ Exercise 1: Temperature Converter
Write a function celsius_to_fahrenheit(celsius) that returns the Fahrenheit equivalent. Add type hints and a docstring with a worked example.
✅ Exercise 2: Flexible Sum
Write a function using *args that returns the sum of any number of arguments, and a second version using **kwargs that sums only the values matching a specific key pattern.
✅ Exercise 3: Fix the Decorator
Take the timer_decorator and debug_decorator from this post and add functools.wraps to both. Confirm calculate_factorial.__name__ now correctly prints "calculate_factorial" even when both decorators are stacked.
✅ Exercise 4: Recursive List Sum
Write a recursive function that sums all numbers in a list without using sum() or a loop. Identify the base case and the recursive case explicitly in comments.
✅ Exercise 5: Extend the Shopping Cart
Add a remove_item(name) method to the ShoppingCart class, and fix the $25.5 formatting bug identified in this post so every price in the receipt consistently shows two decimal places.

❓ 20. Frequently Asked Questions

What is the difference between a parameter and an argument in Python?
A parameter is the placeholder variable listed in a function's definition (def greet(name):). An argument is the actual value you supply when calling the function (greet("Alice")).

What's the difference between *args and **kwargs?
*args collects any number of extra positional arguments into a tuple. **kwargs collects any number of extra keyword arguments into a dictionary. You can use both in the same function signature.

Why does my decorator break func.__name__?
Because the inner wrapper function your decorator returns has its own name, "wrapper" — and unless you apply functools.wraps(func) inside the decorator, Python has no way to know it should copy the original function's __name__, docstring, and other metadata onto the wrapper.

What is a closure in Python?
A closure is a function that "remembers" values from the scope it was created in, even after that outer function has finished running — like create_multiplier(factor) in this post, where each returned function keeps its own private copy of factor.

Do I need a base case in every recursive function?
Yes, always. Without a base case, a recursive function calls itself forever until it hits Python's recursion limit (1000 calls by default) and raises a RecursionError.

What's the correct way to write an "any type" type hint?
Import Any from the typing module and use it capitalized: from typing import Any, then Dict[str, Any]. The lowercase any is a completely different thing — Python's built-in function that checks if any item in an iterable is truthy.

📝 21. Summary

You now command Python's core function-building tools:

  • Basic functions → def, parameters, and return
  • Flexible signatures → default parameters, keyword arguments, *args, **kwargs
  • Scope → local vs. global, and when you actually need the global keyword
  • Documentation → docstrings that are worth writing (and worth testing)
  • Functional tools → lambdas, higher-order functions, and closures
  • Decorators → power tools that need functools.wraps to behave correctly
  • Recursion → always define your base case first
  • Type hints → Any means "any type," not the builtin any() function
  • Final project → the Shopping Cart system ties every concept together in one realistic build

Happy coding! 🐍✨

Comments