Module 5.1

Defining Functions

Functions are reusable code recipes. You define them once, then use them anywhere. Instead of copying and pasting the same code blocks, wrap logic in a function and call it by name. This makes your programs shorter, cleaner, and easier to maintain.

40 min
Beginner
Hands-on
What You'll Learn
  • Function definition with def keyword
  • Parameters and arguments
  • Return values and statements
  • Default and keyword arguments
  • Docstrings and documentation
Contents
01

What Are Functions?

A function is a named block of reusable code that performs a specific task. Functions help you organize your code, avoid repetition, and make programs easier to understand and maintain.

Key Concept

The Recipe Analogy

A function is like a recipe. You write the recipe once with all the steps, then you can use it to cook that dish anytime. You do not rewrite the recipe each time. Just follow it (call the function) whenever you need that dish (result).

Why it matters: Without functions, you would copy-paste the same code everywhere. Functions let you write logic once and reuse it throughout your program.

Reusability

Write once, use many times. Call the same function from multiple places in your code.

Modularity

Break complex problems into smaller, manageable pieces. Each function handles one task.

Maintainability

Fix bugs in one place. Update function logic without changing every call site.

Your First Function

Creating a function in Python uses the def keyword followed by the function name, parentheses, and a colon. The function body is indented.

# Define a simple function
def greet():
    print("Hello, World!")

# Call the function
greet()  # Output: Hello, World!

# You can call it multiple times
greet()  # Output: Hello, World!
greet()  # Output: Hello, World!

How it works: The def keyword tells Python "I'm creating a new function." Everything indented below it is the function's "recipe" - the instructions to follow. When you write greet(), you're telling Python "run that recipe now." The parentheses () are required even when empty. You can call the same function as many times as you want, and Python will run the same code each time.

When to Use Functions

Not all code needs to be in a function. Here are guidelines for when to create functions versus when to keep code inline:

Create a Function When
  • Code repeats: Same logic appears 2+ times
  • Logical unit: Code performs one complete task
  • Testable: Can be verified independently
  • Reusable: Might be used in other projects
  • Complex logic: Makes main code hard to read
  • Configuration: Behavior controlled by parameters
Keep Inline When
  • One-time use: Used only once, simple logic
  • Context-specific: Needs many local variables
  • Trivial operation: Like x + 1
  • Already clear: Inline code is self-documenting
  • Sequential steps: Part of linear workflow

Function vs Inline Comparison

Let's compare solving the same problem with and without functions:

Without Functions (Repetitive)
# Calculate area for room 1
length1 = 12
width1 = 10
area1 = length1 * width1
print(f"Room 1: {area1} sq ft")

# Calculate area for room 2
length2 = 15
width2 = 8
area2 = length2 * width2
print(f"Room 2: {area2} sq ft")

# Calculate area for room 3
length3 = 10
width3 = 10
area3 = length3 * width3
print(f"Room 3: {area3} sq ft")

total = area1 + area2 + area3
print(f"Total: {total} sq ft")

Repetitive, error-prone, hard to change

With Functions (Clean)
def calculate_area(length, width):
    """Calculate rectangle area."""
    return length * width

def print_room_area(room_name, area):
    """Display formatted room area."""
    print(f"{room_name}: {area} sq ft")

# Calculate all areas
area1 = calculate_area(12, 10)
area2 = calculate_area(15, 8)
area3 = calculate_area(10, 10)

# Display results
print_room_area("Room 1", area1)
print_room_area("Room 2", area2)
print_room_area("Room 3", area3)

total = area1 + area2 + area3
print(f"Total: {total} sq ft")

Reusable, testable, easy to modify

Real-World Function Library

Here are practical functions you'll use in real projects, organized by category:

def is_valid_email(email):
    """Check if email format is valid."""
    return "@" in email and "." in email.split("@")[-1]

def is_strong_password(password):
    """Check if password meets basic criteria."""
    return (len(password) >= 8 and 
            any(c.isupper() for c in password) and
            any(c.isdigit() for c in password))

# Using validation functions
print(is_valid_email("user@example.com"))     # True
print(is_valid_email("invalid.email"))        # False
print(is_strong_password("Pass123"))          # True
print(is_strong_password("weak"))             # False

What's happening: These validation functions return True (valid) or False (invalid). For email: first we check if there's an @ symbol, then we split the email at @ and check if the part after it contains a dot (like "gmail.com"). For password: we use and to combine three checks - length must be at least 8 characters, any(c.isupper() for c in password) means "at least one character must be uppercase," and any(c.isdigit() for c in password) means "at least one character must be a number." All three conditions must be True for the password to be strong.

def format_currency(amount):
    """Format number as USD currency."""
    return f"${amount:,.2f}"

def format_phone(number):
    """Format 10-digit phone number."""
    digits = ''.join(c for c in number if c.isdigit())
    if len(digits) == 10:
        return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
    return number

# Using formatting functions
print(format_currency(1234.56))               # $1,234.56
print(format_currency(1000000))               # $1,000,000.00
print(format_phone("5551234567"))             # (555) 123-4567
print(format_phone("555-123-4567"))           # (555) 123-4567

Breaking it down: format_currency uses special formatting code :,.2f inside an f-string: the comma (,) adds thousand separators, .2 means "2 decimal places," and f means "floating point number." For format_phone, we first extract only the digits from whatever format the user typed (like "555-123-4567" or "(555) 123-4567") using ''.join(c for c in number if c.isdigit()) which means "keep only digits, throw away dashes and spaces." Then we use string slicing: digits[:3] gets first 3 digits (area code), digits[3:6] gets next 3 (prefix), and digits[6:] gets the rest (line number). We put them together with formatting like (555) 123-4567.

def get_file_extension(filename):
    """Extract file extension from filename."""
    if "." in filename:
        return filename.split(".")[-1].lower()
    return ""

def is_leap_year(year):
    """Determine if year is a leap year."""
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

def celsius_to_fahrenheit(celsius):
    """Convert Celsius to Fahrenheit."""
    return (celsius * 9/5) + 32

# Using utility functions
print(get_file_extension("document.pdf"))     # pdf
print(get_file_extension("photo.jpg"))        # jpg
print(is_leap_year(2024))                     # True
print(is_leap_year(2023))                     # False
print(celsius_to_fahrenheit(100))             # 212.0
print(celsius_to_fahrenheit(0))               # 32.0

Understanding the logic: get_file_extension splits the filename wherever it sees a dot, creating a list of parts, then [-1] takes the last part (the extension). This works for files like "document.pdf" or even "archive.tar.gz" (returns "gz"). The .lower() makes it lowercase for consistency. For is_leap_year, the rules are: divisible by 4 is a leap year (2024), EXCEPT century years (1900, 2100) are NOT leap years, UNLESS they're also divisible by 400 (2000 was a leap year). We check this with: year % 4 == 0 (divisible by 4) AND year % 100 != 0 (not a century) OR year % 400 == 0 (divisible by 400). Temperature conversion uses the scientific formula: multiply Celsius by 9/5 (or 1.8) then add 32.

Pro Tip: Build your own utility library of common functions. Save validation, formatting, and calculation functions in a separate module and import them into your projects.
02

Function Anatomy

Every function has specific parts that work together. Understanding function anatomy helps you write clean, well-structured code.

Function Anatomy Diagram
def calculate_area(length, width):
def
Keyword
calculate_area
Function Name
length, width
Parameters
:
Colon
"""Calculate rectangle area.""" area = length * width return area
Docstring (optional)
Function Body
Return Statement

Complete Function Example

Here is a complete function with all parts labeled. Docstrings document what the function does.

def calculate_area(length, width):
    """
    Calculate the area of a rectangle.
    
    Args:
        length: The length of the rectangle
        width: The width of the rectangle
    
    Returns:
        The area (length * width)
    """
    area = length * width
    return area

# Call the function with arguments
result = calculate_area(5, 3)
print(result)  # Output: 15

Docstrings are triple-quoted strings right after the function definition. They appear when you use help(function_name).

Naming Convention: Use lowercase letters with underscores for function names (snake_case). Names should describe what the function does: calculate_area, get_user_input, send_email.

Complete Real-World Examples

Here are full working examples that combine multiple concepts:

Temperature Converter

Building a temperature conversion library with multiple related functions:

def celsius_to_fahrenheit(celsius):
    """Convert Celsius to Fahrenheit."""
    return (celsius * 9/5) + 32

Step by step: To convert Celsius to Fahrenheit, we use the scientific formula. First multiply the Celsius value by 9/5 (which is the same as 1.8), then add 32. For example: 100°C × 9/5 = 180, then 180 + 32 = 212°F (boiling point of water). The function takes one input (celsius) and returns the calculated Fahrenheit value.

def fahrenheit_to_celsius(fahrenheit):
    """Convert Fahrenheit to Celsius."""
    return (fahrenheit - 32) * 5/9

Reverse process: To go from Fahrenheit back to Celsius, we reverse the formula. First subtract 32 (undo the addition), then multiply by 5/9 (undo the 9/5 multiplication). Order matters! If you multiply first, you'll get the wrong answer. For example: 32°F - 32 = 0, then 0 × 5/9 = 0°C (freezing point of water).

def kelvin_to_celsius(kelvin):
    """Convert Kelvin to Celsius."""
    return kelvin - 273.15

Simplest conversion: Kelvin to Celsius is the easiest - just subtract 273.15 (the absolute zero constant). That's because Kelvin and Celsius use the same degree size, they just start at different points. 0 Kelvin is absolute zero (-273.15°C), the coldest possible temperature. Each function does ONE thing clearly - this is the "single responsibility" principle in action.

# Usage examples
print(f"100°C = {celsius_to_fahrenheit(100)}°F")  # 212.0°F
print(f"32°F = {fahrenheit_to_celsius(32)}°C")    # 0.0°C
print(f"0K = {kelvin_to_celsius(0)}°C")           # -273.15°C

Using the functions: F-strings (the f before the quotes) let you put expressions inside curly braces {} and Python evaluates them. When we write {celsius_to_fahrenheit(100)}, Python calls the function with 100, gets back 212.0, and inserts it into the string. Each function returns a number that we can display, use in math, or pass to another function - that's the power of return values!

Input Validator

Validation functions check data format and return boolean values:

def is_valid_email(email):
    """Validate email format."""
    return "@" in email and "." in email.split("@")[-1]

Email checking explained: This function does basic email validation. First, "@" in email checks if there's an @ sign anywhere in the email. Then email.split("@") cuts the email into pieces at the @ sign (like cutting a string with scissors). [-1] takes the last piece (the domain, like "gmail.com"), and we check if THAT piece has a dot in it. We use and to require BOTH conditions - without an @ or without a dot after @, it's not a valid email. This is simple validation - real email validation is much more complex!

def is_strong_password(password):
    """Check password strength."""
    return (len(password) >= 8 and
            any(c.isupper() for c in password) and
            any(c.isdigit() for c in password))

Password rules breakdown: A strong password needs THREE things (connected with and, so ALL must be true). First: len(password) >= 8 means length must be 8 or more characters. Second: any(c.isupper() for c in password) is a fancy way of saying "loop through each character c in the password, check if it's uppercase, and return True if AT LEAST ONE is uppercase." Third: same idea but checking for digits with c.isdigit(). If even one condition fails, the whole thing returns False. Try it: "weak" fails all three checks, "Password" fails the digit check, "pass123" fails the uppercase check, but "Pass123" passes all three!

def validate_age(age):
    """Check if age is valid."""
    return 0 <= age <= 150

Range checking trick: Python lets you chain comparisons, which is super readable! 0 <= age <= 150 literally reads as "0 is less than or equal to age AND age is less than or equal to 150." This is the same as writing age >= 0 and age <= 150 but cleaner. We assume nobody is older than 150 (reasonable limit). If age is -5, the first part fails (0 is not <= -5). If age is 200, the second part fails (200 is not <= 150). Only ages from 0 to 150 make both parts true.

# Using validators
print(is_valid_email("user@example.com"))  # True
print(is_strong_password("Pass123"))       # True  
print(validate_age(25))                    # True

Why validators are useful: Functions that return True/False are called "predicates" or "validators." They're perfect for if statements: if is_valid_email(user_input): reads like English! You can use these when building login forms (check password strength), sign-up forms (validate email), registration forms (check age), and anywhere you need to verify data before saving it to a database. They prevent bad data from entering your system.

Price Calculator

Building complex calculations from simple function components:

def calculate_discount(price, percent):
    """Calculate discounted price."""
    discount = price * (percent / 100)
    return price - discount

Breaking down the math: To calculate a discount, we do it in two clear steps. First, figure out how much money the discount is worth: if something costs $100 and has a 20% discount, the discount amount is $100 × (20/100) = $100 × 0.2 = $20. We divide by 100 to convert percentage to decimal. Second step: subtract that discount from the original price: $100 - $20 = $80. Using a variable called discount makes the code self-documenting - anyone reading it instantly understands what's happening.

def add_tax(price, tax_rate=0.08):
    """Add sales tax to price."""
    return price * (1 + tax_rate)

Tax calculation shortcut: This uses a math trick! Instead of calculating tax separately ($100 × 0.08 = $8, then $100 + $8 = $108), we do it in one step: $100 × (1 + 0.08) = $100 × 1.08 = $108. Adding 1 to the tax rate (0.08 becomes 1.08) gives us "price plus tax" in one multiplication. The tax_rate=0.08 part is a default parameter - if someone calls add_tax(100) without specifying tax rate, it automatically uses 8%. But they can override it: add_tax(100, 0.10) for 10% tax.

def final_price(price, discount=0, tax_rate=0.08):
    """Calculate final price with discount and tax."""
    after_discount = calculate_discount(price, discount)
    with_tax = add_tax(after_discount, tax_rate)
    return round(with_tax, 2)

Building functions from functions: This is called "function composition" - using functions inside other functions like building blocks. final_price doesn't reinvent the wheel; it calls calculate_discount to handle discounts and add_tax to handle tax. This is powerful because: (1) each function can be tested independently, (2) if discount logic changes, we only update one place, (3) code is easier to understand. Default parameters discount=0 and tax_rate=0.08 mean both are optional - you can call final_price(100), final_price(100, 20), or final_price(100, 20, 0.10). The round(with_tax, 2) ensures we get exactly 2 decimal places for currency ($86.40 instead of $86.3999999).

# Usage
original = 100
print(f"Original: ${original}")                      # $100
print(f"20% off: ${calculate_discount(original, 20)}")  # $80.0
print(f"Final: ${final_price(original, 20)}")        # $86.4

Mix and match: Notice how each function works independently? You can use calculate_discount by itself to show "You saved $20!", use add_tax alone when there's no discount, or combine them with final_price for the complete calculation. This "modular" approach is like LEGO blocks - each piece works alone, but you can combine them in different ways. When testing, you can verify each function separately instead of testing one giant function that does everything.

Statistics Functions

Analyzing numeric data with specialized functions:

def calculate_average(numbers):
    """Calculate mean of numbers."""
    if not numbers:
        return 0
    return sum(numbers) / len(numbers)

Preventing crashes: The average (mean) is calculated by adding all numbers together and dividing by how many numbers there are. sum(numbers) adds them all up, len(numbers) counts how many there are. BUT what if the list is empty? We'd divide by zero and get an error! So we check if not numbers: (which means "if the list is empty") and return 0 as a safe default. This "defensive programming" prevents your program from crashing. Always think: "What could go wrong?" and handle those cases.

def find_min_max(numbers):
    """Return minimum and maximum."""
    if not numbers:
        return None, None
    return min(numbers), max(numbers)

Returning multiple values: Python functions can return more than one value! When you write return min(numbers), max(numbers), Python packages both values into a tuple (a container for multiple items). If the list is empty, we return None, None (None means "no value") for both. To use this function, you "unpack" the tuple: min_val, max_val = find_min_max(scores). Python puts the first returned value into min_val and the second into max_val. This is like a function returning two answers at once!

def calculate_range(numbers):
    """Calculate range (max - min)."""
    if not numbers:
        return 0
    return max(numbers) - min(numbers)

Understanding data spread: Range tells you how "spread out" your data is. If test scores range from 78 to 95, the range is 95 - 78 = 17 points. A small range (like 5) means scores are close together; a large range (like 50) means they're all over the place. Again, we check for empty lists first - we could crash trying to find max/min of an empty list, so we return 0 as a safe default. Edge case handling is a professional habit!

# Usage
scores = [85, 92, 78, 95, 88]
print(f"Average: {calculate_average(scores)}")  # 87.6
min_score, max_score = find_min_max(scores)
print(f"Range: {min_score} - {max_score}")      # 78 - 95

Working together: These statistics functions are designed to work as a set. You might use all three to analyze test scores, sales data, or any list of numbers. Notice the tuple unpacking in action: min_score, max_score = find_min_max(scores) - Python calls the function, gets back a tuple with two values, and splits them into two variables automatically. This pattern is super common in Python and makes code readable. Real-world use: a teacher analyzing class performance, a business analyzing monthly sales, or a scientist analyzing experiment results.

Error Handling in Functions

Functions should handle invalid inputs gracefully to prevent crashes and provide helpful feedback:

def safe_divide(a, b):
    """Safely divide two numbers."""
    if b == 0:
        return "Error: Cannot divide by zero"
    return a / b

Protecting against division by zero: In math, dividing by zero is undefined - it breaks the universe! In programming, it causes a crash (ZeroDivisionError). This function uses a "guard clause" - checking for the problem BEFORE it happens. The if b == 0: line asks "is the divisor zero?" If yes, immediately return an error message instead of attempting division. If no, proceed with the division a / b safely. This pattern - check for errors first, then do the operation - is called "defensive programming" and prevents your programs from crashing unexpectedly.

# Using safe_divide
print(safe_divide(10, 2))   # Output: 5.0
print(safe_divide(10, 0))   # Output: Error: Cannot divide by zero

Graceful error handling: Notice how the function handles BOTH scenarios smoothly? When we divide 10 by 2, we get the normal result (5.0). When we try to divide 10 by 0, instead of Python crashing with a scary error message, our function calmly returns "Error: Cannot divide by zero". This is called "graceful degradation" - the function fails gracefully instead of catastrophically. Your program keeps running, and the user gets a helpful message. In a real app, you might show this message to the user or log it for debugging.

def get_grade(score):
    """Convert numeric score to letter grade."""
    if not 0 <= score <= 100:
        return "Invalid score"
    
    if score >= 90:
        return "A"
    elif score >= 80:
        return "B"
    elif score >= 70:
        return "C"
    elif score >= 60:
        return "D"
    else:
        return "F"

Input validation first: The very first line checks if the score is valid: if not 0 <= score <= 100: means "if score is NOT between 0 and 100." Test scores can't be negative or over 100, so we reject those immediately with an early return. This prevents bad data from being processed. Then comes the grading logic using if/elif/else chain. We start with the highest grade (>= 90 is A) and work down. Why? Because if score is 95, it matches >= 90 first, returns "A", and never checks the other conditions. The order matters! If we checked >= 60 first, a 95 would incorrectly return "D" because 95 is indeed >= 60. Always check from most specific to least specific.

# Using get_grade
print(get_grade(95))   # Output: A
print(get_grade(150))  # Output: Invalid score
print(get_grade(-10))  # Output: Invalid score

Handling all cases: The function handles three types of inputs: valid scores (95 → "A"), scores too high (150 → "Invalid score"), and scores too low (-10 → "Invalid score"). This comprehensive error handling means you can confidently use this function with ANY number - even user input that might be completely wrong - and it won't crash. The function always returns something meaningful. In a real grading system, you'd use this to validate student scores before saving them to a database.

def safe_get_item(items, index):
    """Safely get item from list."""
    if not items:
        return "Empty list"
    if not 0 <= index < len(items):
        return "Index out of range"
    return items[index]

Layered validation: This function has TWO guard clauses to catch TWO different problems. First check: if not items: asks "is the list empty?" Empty lists have no items to get, so return an error message. Second check: if not 0 <= index < len(items): asks "is the index valid?" For a list with 3 items ["red", "green", "blue"], valid indexes are 0, 1, 2 (Python counts from 0). Index 10 is out of bounds. Index -1 is technically valid in Python (it gets the last item), but we reject it with index >= 0. Only if both checks pass do we return items[index]. This prevents IndexError exceptions that would crash your program.

# Using safe_get_item
colors = ["red", "green", "blue"]
print(safe_get_item(colors, 1))   # Output: green
print(safe_get_item(colors, 10))  # Output: Index out of range
print(safe_get_item([], 0))       # Output: Empty list

Three scenarios handled: This demonstrates the function's robustness. First call: normal case - colors[1] is "green". Second call: index too large - index 10 doesn't exist in a 3-item list, so we get a clear error message instead of a crash. Third call: empty list - can't get index 0 from an empty list [], so we get a different error message explaining the problem. The error messages are descriptive - "Index out of range" vs "Empty list" - so the caller knows exactly what went wrong. This is way better than a cryptic "IndexError" exception! In real applications, use this pattern when accessing user-selected items from lists to prevent crashes.

Practice: Function Basics

Task: Write a function called say_hello that prints "Hello, Python!". Call the function three times.

Show Solution
def say_hello():
    print("Hello, Python!")

# Call the function three times
say_hello()  # Output: Hello, Python!
say_hello()  # Output: Hello, Python!
say_hello()  # Output: Hello, Python!

Task: Create a function called print_info that prints your name and favorite programming language. Add a docstring explaining what the function does.

Show Solution
def print_info():
    """Print personal programming information."""
    print("Name: Alex")
    print("Favorite Language: Python")

print_info()
# Output:
# Name: Alex
# Favorite Language: Python

Task: Create a function called print_box that prints a text box using asterisks with "Hello" inside. The box should be 5 lines tall.

Show Solution
def print_box():
    """Print Hello inside an asterisk box."""
    print("*" * 10)
    print("*        *")
    print("* Hello  *")
    print("*        *")
    print("*" * 10)

print_box()

Docstring Styles and Formats

Python supports several docstring formats. Choose one and use it consistently across your project.

Google Style Docstring

Google style is popular for its readability and clean structure:

def calculate_bmi(weight, height):
    """Calculate Body Mass Index.
    
    Args:
        weight (float): Weight in kilograms
        height (float): Height in meters
    
    Returns:
        float: BMI value
    
    Examples:
        >>> calculate_bmi(70, 1.75)
        22.86
    """
    return weight / (height ** 2)

Google style breakdown: The docstring starts with a one-line summary "Calculate Body Mass Index." Then comes an Args: section listing each parameter with its type in parentheses and description. The Returns: section describes what comes back. Examples: shows actual usage with expected output using the >>> prompt (like Python's interactive shell). This style is easy to read and widely used in industry.

NumPy/SciPy Style

NumPy style uses underlines and is common in scientific computing:

def calculate_bmi(weight, height):
    """
    Calculate Body Mass Index.
    
    Parameters
    ----------
    weight : float
        Weight in kilograms
    height : float
        Height in meters
    
    Returns
    -------
    float
        BMI value
    """
    return weight / (height ** 2)

NumPy style explained: This style uses dashed underlines (----------) to separate sections, making them stand out visually. Instead of "Args:", it uses "Parameters" with underlines. The parameter format is name : type followed by indented description. "Returns" section also has dashes. This format is preferred in scientific/data science projects and is very detailed. Choose this if you're working with NumPy, SciPy, or pandas.

Type Hints and Modern Python

Python 3.5+ supports type hints that document expected types without affecting runtime behavior.

Basic Type Hints

Type hints show what types parameters should be and what the function returns:

def greet(name: str, times: int = 1) -> str:
    """
    Greet someone multiple times.
    
    Args:
        name: Person's name
        times: Number of times to repeat greeting
    
    Returns:
        The complete greeting message
    """
    greeting = f"Hello, {name}! " * times
    return greeting.strip()

Type hints in action: name: str means "name parameter should be a string," times: int = 1 means "times should be an integer with default value 1," and -> str means "this function returns a string." These are HINTS, not enforcement - Python won't stop you from passing a number where it expects a string. But your IDE can warn you, and tools like mypy can catch type errors before you run the code. It's like adding guardrails to your code.

# Using the function with type hints
result: str = greet("Alice", 3)
print(result)  # Hello, Alice! Hello, Alice! Hello, Alice!

Type-annotated variables: You can also add type hints to variables: result: str = greet("Alice", 3) tells Python (and your IDE) that result will be a string. This helps IDEs provide better autocomplete suggestions - when you type result. your IDE knows to show string methods like .upper(), .split(), etc.

from typing import List, Dict, Optional, Tuple

def process_data(items: List[str]) -> Dict[str, int]:
    """Count occurrences of each item."""
    counts: Dict[str, int] = {}
    for item in items:
        counts[item] = counts.get(item, 0) + 1
    return counts

Generic types explained: List[str] means "a list containing strings" (not just any list). Dict[str, int] means "a dictionary with string keys and integer values." The square brackets specify what's INSIDE the container. This is way more specific than just saying "dict" - now we know exactly what types of keys and values to expect. Import these from typing module to use them.

def find_user(user_id: int) -> Optional[Dict[str, str]]:
    """Find user by ID, return None if not found."""
    if user_id > 0:
        return {"name": "User", "email": "user@example.com"}
    return None

Optional types: Optional[Dict[str, str]] means "either a dictionary with string keys and string values, OR None." This is crucial for functions that might not find what they're looking for. It's the same as writing Dict[str, str] | None in Python 3.10+. This tells callers "hey, check if the result is None before using it!" preventing errors where you try to access a dictionary that doesn't exist.

def get_coordinates() -> Tuple[float, float]:
    """Return latitude and longitude as tuple."""
    return (40.7128, -74.0060)

Tuple types: Tuple[float, float] means "a tuple with exactly two floats." This is different from List[float] because tuples have a fixed size. When you see this type hint, you know you're getting back exactly two values that you can unpack: lat, lon = get_coordinates(). The order matters - first float is latitude, second is longitude.

Function Annotations and Metadata

You can access function metadata like name, docstring, and annotations programmatically.

def add(x: int, y: int) -> int:
    """Add two numbers together."""
    return x + y

Creating a function with metadata: When you define a function like add with type hints and a docstring, Python automatically stores this information. The function itself becomes an object with special attributes (starting with double underscores __). This metadata gets saved so you can inspect it later - useful for debugging, documentation tools, and understanding what a function does without reading its code.

# Access function name
print(add.__name__)  # Output: add

Function name attribute: Every function has a __name__ attribute storing its name as a string. This is helpful when you're passing functions around as variables - you can check what function you're actually holding. For example, if you have a list of functions, you can print their names to see what each one is.

# Access function docstring
print(add.__doc__)  # Output: Add two numbers together.

Docstring access: The __doc__ attribute contains the docstring (that triple-quoted text under the function definition). This is how the help() function shows you documentation - it reads __doc__! If you have multiple similar functions, checking their docstrings programmatically helps you pick the right one to use.

# Access type annotations
print(add.__annotations__)
# Output: {'x': , 'y': , 'return': }

Annotations dictionary: __annotations__ is a dictionary storing all type hints. Keys are parameter names (plus 'return' for the return type), values are the actual type objects. This looks cryptic but it's powerful - tools like FastAPI use this to automatically validate incoming data matches the expected types. For example, if x should be an int, the framework can reject string inputs automatically.

# Get help on a function
help(add)
# Output:
# Help on function add in module __main__:
# add(x: int, y: int) -> int
#     Add two numbers together.

Built-in help function: help() is your friend when you forget what a function does! It reads the function's name, type hints (from __annotations__), and docstring (from __doc__) and displays them in a readable format. This works for built-in functions too - try help(len) or help(print). It's like having instant documentation without leaving your code.

# Check if something is callable
import inspect

print(callable(add))  # True

Checking if something is callable: callable() returns True if you can "call" something with parentheses (like add()). Functions are callable, but so are classes (when you create instances) and objects with a __call__ method. This is useful when you receive a variable and need to verify it's actually a function before trying to execute it - prevents crashes!

print(inspect.isfunction(add))  # True

Function type check: inspect.isfunction() is more specific than callable() - it returns True ONLY for actual functions (defined with def or lambda). Classes are callable but not functions, so this distinguishes between them. Use this when you specifically need a function, not just anything callable.

# Get function signature
sig = inspect.signature(add)
print(sig)  # Output: (x: int, y: int) -> int

for param_name, param in sig.parameters.items():
    print(f"{param_name}: {param.annotation}")

Inspecting function signature: inspect.signature() gives you a detailed Signature object containing everything about how to call the function. You can loop through sig.parameters.items() to see each parameter's name and type annotation. This is what IDE autocomplete uses - when you type add(, your IDE inspects the signature to show you need x and y as integers. Libraries use this to validate you're calling functions correctly.

Function Naming Conventions

Follow PEP 8 naming guidelines for Python functions.

Convention Example Description
snake_case calculate_total Lowercase with underscores (standard for Python)
Verb first get_user, set_name, is_valid Start with action verb for clarity
Boolean prefixes is_active, has_permission Use is_, has_, can_ for boolean returns
No abbreviations calculate_total not calc_tot Write full words for readability
Consistent naming get_user, get_order, get_product Use same verbs for similar operations
Avoid reserved words list_items not list Don't shadow built-in names
Good Names
  • calculate_discount(price, percent)
  • is_valid_email(email)
  • get_user_by_id(user_id)
  • send_welcome_email(user)
  • format_currency(amount)
Bad Names
  • calc(x, y) - Too abbreviated
  • func1(data) - Generic, meaningless
  • GetUser(id) - Wrong case (not snake_case)
  • do_stuff(thing) - Vague purpose
  • list(items) - Shadows built-in
03

Parameters and Arguments

Parameters make functions flexible. Instead of hardcoding values, you pass data into functions as arguments. Parameters are the placeholders in the function definition; arguments are the actual values you pass when calling.

Parameters vs Arguments
Parameters (Definition)
def greet(name):

Variables in the function signature that receive values

Arguments (Call)
greet("Alice")

Actual values passed when calling the function

Positional Parameters

Parameters are matched to arguments by position. The first argument goes to the first parameter, and so on.

def greet(name):
    print(f"Hello, {name}!")

greet("Alice")  # Output: Hello, Alice!
greet("Bob")    # Output: Hello, Bob!

# Multiple parameters
def add(a, b):
    result = a + b
    print(f"{a} + {b} = {result}")

add(5, 3)   # Output: 5 + 3 = 8
add(10, 20) # Output: 10 + 20 = 30

Arguments are matched left to right. The number of arguments must match the number of parameters (unless you use defaults).

Default Parameters

Default parameters have predefined values. If the caller does not provide an argument, the default is used. Default parameters must come after required parameters.

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Alice")              # Output: Hello, Alice!
greet("Bob", "Hi")          # Output: Hi, Bob!
greet("Carol", "Welcome")   # Output: Welcome, Carol!

# Multiple defaults
def create_user(name, role="user", active=True):
    print(f"Created {name} as {role}, active={active}")

create_user("Alice")                    # Uses all defaults
create_user("Bob", "admin")             # Override role
create_user("Carol", "mod", False)      # Override both

Defaults make parameters optional. Put required parameters first, then optional ones with defaults.

Keyword Arguments

Keyword arguments let you specify which parameter gets which value by name. This makes code more readable and allows arguments in any order.

def describe_pet(name, animal, age):
    print(f"{name} is a {age}-year-old {animal}")

# Positional arguments (order matters)
describe_pet("Buddy", "dog", 5)

# Keyword arguments (order flexible)
describe_pet(animal="cat", age=3, name="Whiskers")

# Mix positional and keyword
describe_pet("Max", animal="hamster", age=1)

Keyword arguments improve readability, especially with many parameters. Positional arguments must come before keyword arguments. Using names prevents mistakes with parameter order.

Practice: Parameters

Task: Write a function welcome that takes a name parameter and prints "Welcome, [name]!". Test it with three different names.

Show Solution
def welcome(name):
    print(f"Welcome, {name}!")

welcome("Alice")   # Output: Welcome, Alice!
welcome("Bob")     # Output: Welcome, Bob!
welcome("Carol")   # Output: Welcome, Carol!

Task: Create a function power that takes a base and an exponent (default 2). Print the result of base raised to exponent. Test with and without the exponent argument.

Show Solution
def power(base, exponent=2):
    result = base ** exponent
    print(f"{base}^{exponent} = {result}")

power(5)        # Output: 5^2 = 25
power(2, 10)    # Output: 2^10 = 1024
power(3, 3)     # Output: 3^3 = 27

Task: Create a function build_profile with parameters: name (required), age (default 18), city (default "Unknown"), and job (default "Student"). Print a formatted profile. Call it with different combinations of keyword arguments.

Show Solution
def build_profile(name, age=18, city="Unknown", job="Student"):
    print(f"Name: {name}")
    print(f"Age: {age}")
    print(f"City: {city}")
    print(f"Job: {job}")
    print("-" * 20)

build_profile("Alice")
build_profile("Bob", age=25, job="Engineer")
build_profile("Carol", city="NYC", age=30, job="Designer")

Variable-Length Arguments (*args)

Sometimes you don't know how many arguments a function will receive. Use *args to accept any number of positional arguments. They are collected into a tuple.

def sum_all(*numbers):
    """Sum any number of values."""
    total = 0
    for num in numbers:
        total += num
    return total

print(sum_all(1, 2, 3))           # Output: 6
print(sum_all(10, 20, 30, 40))    # Output: 100
print(sum_all(5))                 # Output: 5
print(sum_all())                  # Output: 0

def greet_all(*names):
    """Greet multiple people."""
    for name in names:
        print(f"Hello, {name}!")

greet_all("Alice", "Bob", "Carol")
# Output:
# Hello, Alice!
# Hello, Bob!
# Hello, Carol!

The asterisk * before args tells Python to pack all extra positional arguments into a tuple. You can iterate over them or access by index. Perfect for functions that work with variable numbers of inputs.

Combining Regular and *args Parameters

You can mix regular parameters with *args. Regular parameters come first, then *args captures the rest.

def make_pizza(size, *toppings):
    """Make a pizza with size and any number of toppings."""
    print(f"Making a {size}-inch pizza with:")
    for topping in toppings:
        print(f"  - {topping}")

make_pizza(12, "pepperoni", "mushrooms")
# Output:
# Making a 12-inch pizza with:
#   - pepperoni
#   - mushrooms

make_pizza(16, "olives", "sausage", "peppers", "onions")
# Output:
# Making a 16-inch pizza with:
#   - olives
#   - sausage
#   - peppers
#   - onions

def calculate_average(name, *scores):
    """Calculate average of student scores."""
    if len(scores) == 0:
        return f"{name}: No scores"
    
    avg = sum(scores) / len(scores)
    return f"{name}: {avg:.2f} average"

print(calculate_average("Alice", 85, 90, 78))  # Alice: 84.33 average
print(calculate_average("Bob", 95, 88))        # Bob: 91.50 average

Regular parameters must be provided first. The *args can be empty. Python fills required parameters first, then collects remaining arguments. Great for flexible APIs where some args are required but others vary.

Keyword Arguments (**kwargs)

Use **kwargs (keyword arguments) to accept any number of keyword arguments. They are collected into a dictionary.

def build_user(**info):
    """Create a user profile from keyword arguments."""
    print("User Profile:")
    for key, value in info.items():
        print(f"  {key}: {value}")

build_user(name="Alice", age=25, city="NYC")
# Output:
# User Profile:
#   name: Alice
#   age: 25
#   city: NYC

build_user(username="bob123", email="bob@example.com", role="admin")
# Output:
# User Profile:
#   username: bob123
#   email: bob@example.com
#   role: admin

def save_settings(**settings):
    """Save application settings."""
    for setting, value in settings.items():
        print(f"Setting {setting} = {value}")
    return settings

config = save_settings(theme="dark", font_size=14, auto_save=True)
# Output:
# Setting theme = dark
# Setting font_size = 14
# Setting auto_save = True

The double asterisk ** before kwargs packs all keyword arguments into a dictionary. You can access values using dictionary methods like .items(), .get(), or bracket notation. Ideal for configuration functions and flexible APIs.

Complete Parameter Combination

You can use regular parameters, *args, and **kwargs together. The order must be: regular, *args, keyword-only, **kwargs.

def complex_function(required, *args, default_param=None, **kwargs):
    """Demonstrate all parameter types together."""
    print(f"Required: {required}")
    print(f"Args: {args}")
    print(f"Default: {default_param}")
    print(f"Kwargs: {kwargs}")

Defining function with all parameter types: This function signature shows ALL parameter types in the correct order: 1) required (regular positional parameter - MUST be provided), 2) *args (catches extra positional arguments into a tuple), 3) default_param=None (keyword-only parameter with default value), 4) **kwargs (catches extra keyword arguments into a dictionary). This order is enforced by Python - you can't rearrange them or you'll get a syntax error!

complex_function(
    "value1",                    # required
    "extra1", "extra2",          # *args
    default_param="custom",      # keyword parameter
    option1="A", option2="B"     # **kwargs
)
# Output:
# Required: value1
# Args: ('extra1', 'extra2')
# Default: custom
# Kwargs: {'option1': 'A', 'option2': 'B'}

Calling with all parameter types: Watch how each argument gets sorted into the right bucket! "value1" fills required (first mandatory spot). "extra1" and "extra2" have no named parameter waiting, so they get collected into args as a tuple. default_param="custom" matches the named parameter exactly. option1="A" and option2="B" don't match any defined parameters, so they go into kwargs dictionary. It's like sorting mail into different mailboxes based on the address!

def send_email(to, *cc, subject="No Subject", **headers):
    """Send email with flexible recipients and headers."""
    print(f"To: {to}")
    if cc:
        print(f"CC: {', '.join(cc)}")
    print(f"Subject: {subject}")
    for header, value in headers.items():
        print(f"{header}: {value}")

Real-world email function: This practical example shows how parameter combination creates flexible APIs. to is required (every email needs a recipient). *cc lets you CC as many people as you want - zero, one, or a hundred! subject has a sensible default. **headers allows any custom email headers (priority, reply-to, etc.) without defining every possible option. Notice the if cc: check - since cc might be empty, we only print it if there are CC recipients.

send_email(
    "alice@example.com",
    "bob@example.com", "carol@example.com",
    subject="Meeting Tomorrow",
    priority="High",
    reply_to="noreply@example.com"
)
# Output:
# To: alice@example.com
# CC: bob@example.com, carol@example.com
# Subject: Meeting Tomorrow
# priority: High
# reply_to: noreply@example.com

Calling the email function: Breaking down the call: "alice@example.com" goes to to (first position). "bob@example.com" and "carol@example.com" get collected into cc tuple (any extra positional arguments). subject="Meeting Tomorrow" overrides the default "No Subject". priority="High" and reply_to="noreply@example.com" become key-value pairs in the headers dictionary. This pattern lets users of your function add any custom data without you predicting every possible need!

Parameter Order Rule: When combining parameter types, always follow this order: regular positional, *args, keyword-only (or defaults), **kwargs. Python enforces this order.

Unpacking Arguments

You can unpack lists and dictionaries into function arguments using * and ** operators.

def calculate(a, b, c):
    """Perform calculation with three numbers."""
    return a + b * c

Simple function expecting three arguments: This calculate function expects exactly three separate arguments: a, b, and c. It performs a + b * c (remember order of operations - multiplication happens first, then addition). So calculate(2, 3, 4) returns 2 + 3 * 4 = 2 + 12 = 14. Nothing fancy yet - just a regular function call.

# Unpack a list as arguments
numbers = [2, 3, 4]
result = calculate(*numbers)  # Same as calculate(2, 3, 4)
print(result)  # Output: 14

Unpacking a list with * operator: The * before numbers "explodes" the list into separate arguments. Python takes [2, 3, 4] and spreads it out like spreading cards on a table - it becomes calculate(2, 3, 4). This is the OPPOSITE of *args in function definitions. There, *args COLLECTS arguments into a tuple. Here, *numbers SPREADS a list into separate arguments. Think of it like: *args = vacuum (sucks up arguments), *list = explosion (spreads them out)!

def greet(first_name, last_name, age):
    return f"{first_name} {last_name} is {age} years old"

Function with keyword parameters: This function expects three named parameters: first_name, last_name, and age. It returns a formatted string using an f-string. You could call it normally with greet("Alice", "Smith", 30), but what if your data is already in a dictionary with matching key names? That's where ** unpacking shines!

person = {"first_name": "Alice", "last_name": "Smith", "age": 30}
message = greet(**person)  # Same as greet(first_name="Alice", last_name="Smith", age=30)
print(message)  # Output: Alice Smith is 30 years old

Unpacking a dictionary with ** operator: The ** before person spreads the dictionary into keyword arguments. Python looks at each key-value pair and converts them: "first_name": "Alice" becomes first_name="Alice", and so on. The dictionary keys MUST match the parameter names exactly or you'll get a TypeError. This is incredibly useful when loading data from JSON, databases, or config files - you don't have to manually extract each field!

def make_sandwich(bread, *fillings, toasted=False):
    sandwich = f"{bread} bread with {', '.join(fillings)}"
    if toasted:
        sandwich += " (toasted)"
    return sandwich

ingredients = ["wheat", "ham", "cheese", "lettuce"]
bread = ingredients[0]
fillings = ingredients[1:]

result = make_sandwich(bread, *fillings, toasted=True)
print(result)  # Output: wheat bread with ham, cheese, lettuce (toasted)

Combining unpacking with flexible parameters: This example shows the power of unpacking! The function accepts one bread type, then *fillings (any number of fillings), plus a toasted option. We have a list with ["wheat", "ham", "cheese", "lettuce"]. We slice it: ingredients[0] gets "wheat" for bread, ingredients[1:] gets everything after index 0 for fillings ["ham", "cheese", "lettuce"]. Then *fillings unpacks this list into separate arguments. The result: bread="wheat", fillings=("ham", "cheese", "lettuce"), toasted=True. It's like having a recipe that adapts to whatever ingredients you have!

Common Use Cases: Use *args for functions like sum, max, min that work with any number of values. Use **kwargs for functions that accept optional configuration like database connections, API requests, or UI components.
04

Return Values

Functions can send data back to the caller using the return statement. This lets you capture results and use them elsewhere in your code.

return Statement

Sends a value back to the caller and exits the function immediately. Code after return does not execute.

No return = None

Functions without return (or with bare return) automatically return None.

Basic Return

Use return to send a value back. The caller can store this value in a variable or use it directly.

def add(a, b):
    return a + b

# Capture the returned value
result = add(5, 3)
print(result)  # Output: 8

# Use return value directly
print(add(10, 20))  # Output: 30

# Use in expressions
total = add(1, 2) + add(3, 4)
print(total)  # Output: 10 (3 + 7)

The return value replaces the function call. You can assign it, print it, or use it in calculations.

Multiple Return Values

Python functions can return multiple values as a tuple. You can unpack them directly into separate variables.

def get_min_max(numbers):
    return min(numbers), max(numbers)

# Unpack into two variables
lowest, highest = get_min_max([5, 2, 8, 1, 9])
print(f"Min: {lowest}, Max: {highest}")
# Output: Min: 1, Max: 9

def divide_and_remainder(a, b):
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide_and_remainder(17, 5)
print(f"17 / 5 = {q} remainder {r}")
# Output: 17 / 5 = 3 remainder 2

Returning multiple values creates a tuple. Use tuple unpacking to capture each value in its own variable.

Early Return

Return can exit a function early, skipping remaining code. This is useful for guard clauses and error handling.

def divide(a, b):
    if b == 0:
        return "Error: Division by zero"
    return a / b

print(divide(10, 2))   # Output: 5.0
print(divide(10, 0))   # Output: Error: Division by zero

def get_grade(score):
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    return "F"

print(get_grade(95))  # Output: A
print(get_grade(65))  # Output: F

Early returns simplify code by eliminating nested else blocks. Check conditions and return immediately when matched.

Practice: Return Values

Task: Create a function double that takes a number and returns it multiplied by 2. Print the result of doubling 7 and 15.

Show Solution
def double(num):
    return num * 2

print(double(7))   # Output: 14
print(double(15))  # Output: 30

Task: Write a function rectangle_info that takes length and width, and returns both the area and perimeter. Unpack and print both values.

Show Solution
def rectangle_info(length, width):
    area = length * width
    perimeter = 2 * (length + width)
    return area, perimeter

a, p = rectangle_info(5, 3)
print(f"Area: {a}, Perimeter: {p}")
# Output: Area: 15, Perimeter: 16

Task: Create a function safe_get that takes a list and an index. If the index is valid, return the element. If out of range, return "Index out of range". Use early return for the error case.

Show Solution
def safe_get(items, index):
    if index < 0 or index >= len(items):
        return "Index out of range"
    return items[index]

colors = ["red", "green", "blue"]
print(safe_get(colors, 1))   # Output: green
print(safe_get(colors, 10))  # Output: Index out of range

Returning None vs Empty Values

Understanding what your function returns is crucial. Python returns None if no return statement is used, which is different from returning empty collections.

Returns None
def no_return():
    x = 10  # Does something
    # No return statement

result = no_return()
print(result)  # None
print(result is None)  # True
Returns Empty List
def empty_list():
    return []

result = empty_list()
print(result)  # []
print(result is None)  # False
print(len(result))  # 0
Returns Empty String
def empty_string():
    return ""

result = empty_string()
print(result)  # (empty)
print(result is None)  # False
print(len(result))  # 0
Design Choice: Return None to indicate "no result" or "not found". Return empty collections ([], {}, "") when you want to return a container that happens to be empty. This makes your API more predictable.

Returning Different Types

Functions can return different types based on conditions, but it's often better to return consistent types.

Inconsistent Returns
def find_user(user_id):
    """Find user by ID."""
    if user_id > 0:
        return {"id": user_id, "name": "User"}
    else:
        return "Invalid ID"  # Different type!

user = find_user(5)
# Need to check type before using
if isinstance(user, dict):
    print(user["name"])
else:
    print(user)

Harder to use, requires type checking

Consistent Returns
def find_user(user_id):
    """Find user by ID."""
    if user_id > 0:
        return {"id": user_id, "name": "User"}
    else:
        return None  # Same concept, consistent

user = find_user(5)
if user:  # Simple check
    print(user["name"])
else:
    print("User not found")

Easier to use, predictable behavior

Using Return Values in Expressions

Return values can be used directly in expressions, assignments, or as arguments to other functions.

def square(x):
    return x * x

def add(a, b):
    return a + b

# Use return value in expression
result = square(5) + square(3)
print(result)  # Output: 34 (25 + 9)

# Pass return value to another function
total = add(square(2), square(3))
print(total)  # Output: 13 (4 + 9)

# Chain function calls
def increment(x):
    return x + 1

def double(x):
    return x * 2

result = double(increment(5))  # increment(5) = 6, double(6) = 12
print(result)  # Output: 12

# Use in conditionals
def is_even(n):
    return n % 2 == 0

if is_even(10):
    print("Even number")

# Store in data structures
numbers = [1, 2, 3, 4, 5]
squares = [square(n) for n in numbers]
print(squares)  # Output: [1, 4, 9, 16, 25]

Return values make functions composable. The output of one function can feed into another, creating powerful data transformation pipelines. This is the foundation of functional programming.

Boolean Returns for Validation

Functions that return True or False are often called predicates. Name them with is_ or has_ prefixes.

def is_adult(age):
    """Check if person is 18 or older."""
    return age >= 18

def has_discount_code(order):
    """Check if order has a discount code."""
    return "discount_code" in order and order["discount_code"] != ""

def is_valid_password(password):
    """Validate password meets requirements."""
    return (len(password) >= 8 and
            any(c.isupper() for c in password) and
            any(c.islower() for c in password) and
            any(c.isdigit() for c in password))

# Using boolean functions in conditions
age = 25
if is_adult(age):
    print("Can vote")

password = "MyPass123"
if is_valid_password(password):
    print("Password accepted")
else:
    print("Password too weak")

# Combine with logical operators
def can_purchase_alcohol(age, has_id):
    return is_adult(age) and has_id

if can_purchase_alcohol(21, True):
    print("Sale approved")

Boolean functions make conditionals more readable and reusable. Name them with is_, has_, or can_ prefixes. Combine them with logical operators to build complex validations from simple building blocks.

Return Value Best Practices

Do This
  • Return consistent types
  • Use None for "no result"
  • Return early on errors
  • Document what is returned
  • Return multiple values as tuple
  • Use boolean returns for checks
Avoid This
  • Returning different types for different cases
  • Forgetting to return (implicit None)
  • Deeply nested returns
  • Returning mutable objects that can be modified
  • Using print instead of return
  • Returning magic numbers without explanation
05

Scope and Namespaces

Scope determines where variables can be accessed in your code. Understanding scope prevents bugs and helps you write cleaner functions. Python has local, enclosing, global, and built-in scopes (LEGB rule).

Core Concept

The LEGB Rule

Python searches for variables in this order: Local (inside function), Enclosing (in outer functions), Global (module level), Built-in (Python keywords). This determines which value a name refers to.

Why it matters: Variables with the same name can exist in different scopes. Python uses LEGB to decide which one to use.

Local

Variables inside the current function

Enclosing

Variables in outer functions

Global

Module-level variables

Built-in

Python keywords like print, len

Local Scope

Variables created inside a function are local. They only exist within that function and cannot be accessed from outside.

def calculate():
    result = 10 * 5  # Local variable
    print(result)

calculate()  # Output: 50

# This would cause NameError
# print(result)  # Error: result is not defined outside the function

def greet():
    message = "Hello"  # Local to greet()
    print(message)

def farewell():
    message = "Goodbye"  # Different local variable
    print(message)

greet()     # Output: Hello
farewell()  # Output: Goodbye

Local variables are created when the function is called and destroyed when it returns. Each function call gets its own set of local variables. Variables with the same name in different functions are completely independent.

Global Scope

Variables defined at the top level of a module are global. They can be read from inside functions, but modifying them requires the global keyword.

# Global variable
counter = 0

def read_global():
    print(f"Counter is {counter}")  # Can read global

read_global()  # Output: Counter is 0

def increment():
    global counter  # Declare we want to modify global
    counter += 1
    print(f"Counter is now {counter}")

increment()  # Output: Counter is now 1
increment()  # Output: Counter is now 2
print(counter)  # Output: 2

# Without global keyword
def broken_increment():
    counter = counter + 1  # Error: local variable referenced before assignment

# broken_increment()  # Would raise UnboundLocalError

Functions can read global variables without the global keyword, but modifying them requires declaring global first. Without global, Python creates a new local variable instead. Use global sparingly as it makes code harder to test and debug.

Best Practice: Minimize use of global variables. Prefer passing data through parameters and returning results. Global state makes code harder to understand and maintain.

Enclosing Scope (Nested Functions)

When you define a function inside another function, the inner function can access variables from the outer function.

def outer():
    outer_var = "I'm from outer"
    
    def inner():
        # Can access outer_var from enclosing scope
        print(outer_var)
        inner_var = "I'm from inner"
        print(inner_var)
    
    inner()
    # Cannot access inner_var here
    # print(inner_var)  # NameError

outer()
# Output:
# I'm from outer
# I'm from inner

Nested functions and scope access: When you define inner() inside outer(), the inner function can "see" variables from outer. It's like being in a glass box inside a room - you can see everything in the room (outer_var), but people in the room can't see what's inside your box (inner_var). The inner function prints outer_var just fine, but if outer tries to print inner_var, you get a NameError because inner_var only exists inside the inner function's scope. Scope flows ONE WAY: from outer to inner, not the reverse!

def make_multiplier(n):
    def multiply(x):
        return x * n  # n comes from enclosing scope
    return multiply

Function factory pattern: This is where nested functions get really powerful! make_multiplier(n) RETURNS the inner function (notice: return multiply, not return multiply()). The inner function multiply(x) uses n from the outer function's scope. When you call make_multiplier(3), it creates an inner function that remembers n=3. This pattern is called a "closure" - the inner function CLOSES OVER (captures and remembers) variables from the outer function even after the outer function finishes running!

times_3 = make_multiplier(3)
times_5 = make_multiplier(5)

print(times_3(10))  # Output: 30
print(times_5(10))  # Output: 50

Using closures to create custom functions: Here's the magic in action! times_3 and times_5 are DIFFERENT functions with DIFFERENT captured values of n. times_3 remembers n=3, so times_3(10) returns 10 * 3 = 30. times_5 remembers n=5, so times_5(10) returns 10 * 5 = 50. It's like creating a factory that stamps out customized tools - each tool remembers its configuration. This is incredibly useful for creating variations of functions without duplicating code or using classes!

The nonlocal Keyword

Use nonlocal to modify variables from an enclosing (but not global) scope.

def counter():
    count = 0
    
    def increment():
        nonlocal count  # Modify variable from enclosing scope
        count += 1
        return count
    
    def decrement():
        nonlocal count
        count -= 1
        return count
    
    def get_count():
        return count
    
    return increment, decrement, get_count

inc, dec, get = counter()
print(inc())  # Output: 1
print(inc())  # Output: 2
print(dec())  # Output: 1
print(get())  # Output: 1

nonlocal lets you modify variables from an enclosing (but not global) scope. The count variable persists across multiple calls because it's stored in the closure. This pattern creates stateful functions without using classes.

Scope Visualization Example

Here is a comprehensive example showing all scope levels working together:

# Built-in scope: len, print, etc.
# Global scope
global_var = "I'm global"

def outer_function():
    # Enclosing scope for inner_function
    outer_var = "I'm in outer"
    
    def inner_function():
        # Local scope
        local_var = "I'm local"
        
        # Access all scopes
        print(f"Local: {local_var}")
        print(f"Enclosing: {outer_var}")
        print(f"Global: {global_var}")
        print(f"Built-in: {len('hello')}")
    
    inner_function()
    print(f"Outer can access: {outer_var}")
    # print(local_var)  # Error: not accessible

outer_function()
print(f"Global level: {global_var}")
# print(outer_var)  # Error: not accessible
# print(local_var)  # Error: not accessible

This demonstrates the LEGB lookup order. Inner functions can access all outer scopes, but outer scopes cannot access inner variables. Python searches Local first, then Enclosing, then Global, then Built-in.

Memory Tip: Variables flow outward, not inward. Inner functions can see outer variables, but outer cannot see inner. Think of it like Russian nesting dolls - the inner doll can see all outer dolls, but outer dolls cannot see inside.

Practice: Scope

Task: Create a global variable name = "Global" and a function test_scope() that creates a local variable name = "Local" and prints it. Then print the global name outside the function to show they are different.

Show Solution
name = "Global"  # Global variable

def test_scope():
    name = "Local"  # Local variable (shadows global)
    print(f"Inside function: {name}")

test_scope()  # Output: Inside function: Local
print(f"Outside function: {name}")  # Output: Outside function: Global

Task: Create a global total = 0. Write a function add_to_total(amount) that uses the global keyword to modify total. Call it three times with different amounts and print the running total after each call.

Show Solution
total = 0  # Global variable

def add_to_total(amount):
    global total
    total += amount
    print(f"Total is now: {total}")

add_to_total(10)  # Output: Total is now: 10
add_to_total(25)  # Output: Total is now: 35
add_to_total(5)   # Output: Total is now: 40

Task: Create a function make_counter() that returns a nested count() function. The nested function should use nonlocal to maintain a counter that increments each time it's called. Demonstrate creating two independent counters.

Show Solution
def make_counter():
    count = 0
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    return increment

counter1 = make_counter()
counter2 = make_counter()

print(counter1())  # Output: 1
print(counter1())  # Output: 2
print(counter2())  # Output: 1 (independent counter)
print(counter1())  # Output: 3

Task: Create a function create_calculator() that maintains a result variable. Return three nested functions: add(n), subtract(n), and get_result(). All should use nonlocal to share the same result variable.

Show Solution
def create_calculator():
    result = 0
    
    def add(n):
        nonlocal result
        result += n
    
    def subtract(n):
        nonlocal result
        result -= n
    
    def get_result():
        return result
    
    return add, subtract, get_result

add, sub, get = create_calculator()
add(10)
add(5)
sub(3)
print(get())  # Output: 12
06

Best Practices and Common Patterns

Writing good functions takes practice. Follow these guidelines to create clean, maintainable, and reusable code that other developers (including future you) will appreciate.

Function Naming Conventions

Clear names make code self-documenting. Follow Python's PEP 8 style guide for naming.

Good Names
def calculate_total_price(items, tax_rate):
    pass

def is_valid_email(email):
    pass

def get_user_by_id(user_id):
    pass

def send_welcome_email(user):
    pass

Descriptive, uses snake_case, verbs for actions, clear purpose

Bad Names
def calc(x, y):  # Too abbreviated
    pass

def func1(data):  # Generic, meaningless
    pass

def GetUser(id):  # Wrong case (not snake_case)
    pass

def do_stuff(thing):  # Vague
    pass

Unclear purpose, poor naming, inconsistent style

Naming Rules: Use lowercase with underscores (snake_case). Start with a verb (get, calculate, validate, send). Be specific but concise. Avoid abbreviations unless universally understood (like id, url, html).

Single Responsibility Principle

Each function should do one thing and do it well. If a function does too much, split it into smaller functions.

Too Many Responsibilities
def process_order(user, items):
    # Validates user
    if not user.is_active:
        return "Error"
    
    # Calculates total
    total = sum(item.price for item in items)
    
    # Applies discount
    if user.is_premium:
        total *= 0.9
    
    # Sends email
    send_email(user.email, f"Total: {total}")
    
    # Updates database
    db.save_order(user, items, total)
    
    # Logs activity
    log(f"Order for {user.name}")
Single Responsibilities
def validate_user(user):
    return user.is_active

def calculate_total(items):
    return sum(item.price for item in items)

def apply_discount(total, user):
    return total * 0.9 if user.is_premium else total

def process_order(user, items):
    if not validate_user(user):
        return "Error"
    
    total = calculate_total(items)
    total = apply_discount(total, user)
    
    send_confirmation(user, total)
    save_order(user, items, total)
    log_order(user)
    
    return total

Function Length Guidelines

Keep functions short and focused. A good rule of thumb is 10-20 lines of code per function.

The Screen Rule: If a function doesn't fit on your screen without scrolling, it's probably too long. Consider breaking it into smaller functions.

Comprehensive Docstrings

Document your functions with docstrings. Describe what the function does, parameters, return values, and any exceptions raised.

def calculate_discount(price, discount_percent, min_price=0):
    """
    Calculate the discounted price of an item.
    
    Args:
        price (float): The original price of the item
        discount_percent (float): The discount percentage (0-100)
        min_price (float, optional): Minimum price threshold. 
            Discount only applies if price >= min_price. Defaults to 0.
    
    Returns:
        float: The price after applying the discount
    
    Raises:
        ValueError: If discount_percent is negative or greater than 100
        ValueError: If price is negative
    
    Examples:
        >>> calculate_discount(100, 10)
        90.0
        >>> calculate_discount(50, 20, min_price=60)
        50.0
    """

Complete docstring structure: This shows a professional-grade docstring with ALL the important sections! It starts with a one-line summary "Calculate the discounted price of an item." Then comes Args: documenting each parameter with its type (float) and description. Notice how min_price is marked "optional" and mentions the default value (Defaults to 0). The Returns: section tells you what type comes back. Raises: warns about exceptions that might be thrown (ValueError in this case). Examples: shows actual usage with >>> (Python shell prompt) and expected output. This is like writing a user manual for your function!

    if price < 0:
        raise ValueError("Price cannot be negative")
    if discount_percent < 0 or discount_percent > 100:
        raise ValueError("Discount must be between 0 and 100")

Input validation matching docstring: These checks match the Raises: section in the docstring! The docstring promised to raise ValueError for invalid inputs, and here's where that happens. First check: price must not be negative (you can't have a -$10 item). Second check: discount must be 0-100 (you can't have 150% discount or -20% discount). This is called "defensive programming" - checking inputs before doing calculations. The error messages are clear and specific, helping users understand what went wrong.

    if price < min_price:
        return price
    
    discount_amount = price * (discount_percent / 100)
    return price - discount_amount

Business logic implementation: First, check if price is below min_price threshold - if so, no discount applies, return original price (this implements "discount only applies if price >= min_price" from the docstring). Otherwise, calculate discount: convert percentage to decimal (divide by 100), multiply by price to get discount amount, then subtract from original price. For example: $100 with 10% discount → discount_amount = 100 * (10/100) = 100 * 0.1 = $10 → final price = 100 - 10 = $90. The code does exactly what the docstring promises!

# Access the docstring
help(calculate_discount)

Viewing documentation: The help() function displays the docstring in a formatted, readable way. This is how users of your function can learn how to use it without reading the source code. All that effort writing a detailed docstring pays off - anyone can call help(calculate_discount) and immediately understand what parameters to pass, what to expect back, and what errors might occur. Professional libraries like NumPy and pandas have extensive docstrings making them easy to use!

DRY Principle (Don't Repeat Yourself)

If you find yourself writing the same code in multiple places, extract it into a function.

Repetitive Code
# Calculating tax in multiple places
order1_total = 100
order1_with_tax = order1_total * 1.08

order2_total = 200
order2_with_tax = order2_total * 1.08

order3_total = 150
order3_with_tax = order3_total * 1.08
DRY with Function
def add_tax(amount, rate=0.08):
    """Add tax to an amount."""
    return amount * (1 + rate)

order1_with_tax = add_tax(100)
order2_with_tax = add_tax(200)
order3_with_tax = add_tax(150)

# Easy to change tax rate everywhere
order4_with_tax = add_tax(300, rate=0.10)

Default Argument Gotcha: Mutable Defaults

Never use mutable objects (lists, dicts) as default arguments. They are created once and shared across all calls.

Dangerous Pattern
def add_item(item, items=[]):
    items.append(item)
    return items

# Unexpected behavior!
list1 = add_item("apple")
print(list1)  # ['apple']

list2 = add_item("banana")
print(list2)  # ['apple', 'banana'] - WRONG!

# The default list is shared!

Problem: The default list persists across calls

Safe Pattern
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

# Correct behavior
list1 = add_item("apple")
print(list1)  # ['apple']

list2 = add_item("banana")
print(list2)  # ['banana'] - CORRECT!

Solution: Use None as default, create new list inside function

Common Mistake: Default argument values are evaluated once when the function is defined, not each time it's called. Use None for mutable defaults and create the object inside the function.

Type Hints (Python 3.5+)

Type hints make your code more self-documenting and enable better IDE support and static analysis tools.

def greet(name: str, age: int) -> str:
    """Greet a person with their name and age."""
    return f"Hello, {name}! You are {age} years old."

def calculate_area(width: float, height: float) -> float:
    """Calculate rectangle area."""
    return width * height

def process_items(items: list[str]) -> dict[str, int]:
    """Count occurrences of each item."""
    counts = {}
    for item in items:
        counts[item] = counts.get(item, 0) + 1
    return counts

# Type hints are documentation, not enforcement
result = greet("Alice", 25)  # Correct types
result = greet("Bob", "25")  # Python still runs this!

# Use mypy or other tools for type checking:
# mypy your_script.py

Type hints improve code readability and help IDEs provide better autocomplete. They don't affect runtime behavior but can be checked with tools like mypy.

Guard Clauses for Early Exit

Check error conditions first and return early. This reduces nesting and makes code clearer.

Nested Conditions
def process_user(user):
    if user is not None:
        if user.is_active:
            if user.has_permission:
                # Do actual work here
                return user.process()
            else:
                return "No permission"
        else:
            return "Inactive user"
    else:
        return "User not found"

Hard to read with deep nesting

Guard Clauses
def process_user(user):
    if user is None:
        return "User not found"
    
    if not user.is_active:
        return "Inactive user"
    
    if not user.has_permission:
        return "No permission"
    
    # Main logic at top level
    return user.process()

Clear, linear flow with early exits - guard clauses check conditions and exit early, keeping main logic at the top level with minimal nesting

Function Composition

Build complex operations by combining simple functions. Each function does one thing, and you chain them together.

def clean_text(text):
    """Remove whitespace and convert to lowercase."""
    return text.strip().lower()

Text cleaning function: This function does two simple things in one line: .strip() removes leading and trailing whitespace (spaces, tabs, newlines) from the text, and .lower() converts all letters to lowercase. For example, " Hello World " becomes "hello world". Chaining methods like this (using dots) is common in Python - each method operates on the result of the previous one. This makes the text consistent and ready for further processing.

def remove_punctuation(text):
    """Remove common punctuation marks."""
    for char in ".,!?;:":
        text = text.replace(char, "")
    return text

Punctuation removal: This loops through common punctuation marks (period, comma, exclamation, question, semicolon, colon) and uses .replace(char, "") to replace each one with an empty string (essentially deleting them). The loop goes through the string ".,!?;:" one character at a time, removing each from the text. For example, "hello, world!" becomes "hello world". The text = text.replace(...) reassigns the modified text back to the same variable - each replacement builds on the previous one.

def count_words(text):
    """Count words in text."""
    words = text.split()
    return len(words)

Word counting logic: .split() with no arguments splits text at any whitespace (spaces, tabs, newlines), creating a list of words. For example, "hello world how are you" becomes ["hello", "world", "how", "are", "you"]. Then len(words) counts how many items are in the list - in this case, 5. This is a reliable way to count words because split() automatically handles multiple spaces between words (treating "hello world" with double spaces the same as "hello world").

def analyze_sentence(sentence):
    """Clean and analyze a sentence."""
    cleaned = clean_text(sentence)
    no_punct = remove_punctuation(cleaned)
    word_count = count_words(no_punct)
    
    return {
        "original": sentence,
        "cleaned": cleaned,
        "word_count": word_count
    }

Composing functions into a pipeline: This is where function composition shines! Instead of doing everything in one huge function, we call three smaller functions in sequence, each building on the result of the previous one. First, clean_text(sentence) cleans the input. Second, remove_punctuation(cleaned) takes the cleaned text and removes punctuation. Third, count_words(no_punct) counts the words in the punctuation-free text. We return a dictionary with three pieces of information: original input (for reference), the cleaned version, and the word count. This is like an assembly line - each station does one job, passing the product to the next station.

result = analyze_sentence("  Hello, World! How are you?  ")
print(result)
# Output: {'original': '  Hello, World! How are you?  ', 
#          'cleaned': 'hello, world! how are you?', 
#          'word_count': 4}

Pipeline in action: Watch the data flow through the pipeline! Input: " Hello, World! How are you? " (with extra spaces). After clean_text: "hello, world! how are you?" (trimmed spaces, lowercase). After remove_punctuation: "hello world how are you" (no commas or exclamation marks). After count_words: 4 (counting "hello", "world", "how", "are", "you" - wait, that's 5 words! But the punctuation removal happened before word counting). The beauty of composition is that each function is simple and testable on its own. If word counting is wrong, you know exactly which function to fix. This modular approach makes code easier to maintain, test, and understand compared to one giant function doing everything at once.

Function Best Practices Summary
  • Use descriptive names with verbs
  • Keep functions short (10-20 lines)
  • One purpose per function
  • Write comprehensive docstrings
  • Use type hints for clarity
  • Avoid mutable default arguments
  • Prefer guard clauses over nesting
  • Return early on error conditions
  • Compose simple functions into complex ones
Common Pitfalls to Avoid
  • Generic names like func1, do_stuff
  • Functions that do too many things
  • Missing or poor documentation
  • Deep nesting (more than 3 levels)
  • Using global variables unnecessarily
  • Mutable default arguments
  • Functions longer than 50 lines
  • Duplicate code instead of functions
  • Side effects without clear documentation

Troubleshooting Common Mistakes

Learn to identify and fix common function errors. Understanding these pitfalls will save you hours of debugging.

Mistake 1: Forgetting to Call the Function

Wrong
def greet():
    return "Hello"

message = greet  # Forgot parentheses!
print(message)
# Output: 

# The function object, not the result

Missing parentheses means you're referencing the function, not calling it

Correct
def greet():
    return "Hello"

message = greet()  # Called with parentheses
print(message)
# Output: Hello

# Function was executed, result stored

Always use parentheses () to call a function

Mistake 2: Print vs Return Confusion

Wrong (Prints but doesn't return)
def add(a, b):
    print(a + b)  # Prints, but no return

result = add(5, 3)
# Output: 8 (printed)

print(result)
# Output: None

# Can't use the value in calculations
total = result * 2  # Error: can't multiply None
Correct (Returns value)
def add(a, b):
    return a + b  # Returns the value

result = add(5, 3)
# No output (nothing printed)

print(result)
# Output: 8

# Can use the value
total = result * 2  # Works! total = 16
Remember: print() displays output to the console. return sends a value back to the caller. Use print for debugging, return for function results.

Mistake 3: Modifying Mutable Default Arguments

Dangerous (Shared default)
def append_to(item, target=[]):
    target.append(item)
    return target

list1 = append_to(1)
print(list1)  # [1]

list2 = append_to(2)
print(list2)  # [1, 2] - Wrong!

list3 = append_to(3)
print(list3)  # [1, 2, 3] - All share same list!

Default list is created once and reused

Safe (New list each time)
def append_to(item, target=None):
    if target is None:
        target = []  # Create fresh list
    target.append(item)
    return target

list1 = append_to(1)
print(list1)  # [1]

list2 = append_to(2)
print(list2)  # [2] - Correct!

list3 = append_to(3)
print(list3)  # [3] - Each gets own list

Use None as default, create mutable object inside function

Mistake 4: Wrong Number of Arguments

# Function definition
def calculate(a, b, c):
    return a + b + c

# Common errors:

# Too few arguments
calculate(1, 2)
# TypeError: calculate() missing 1 required positional argument: 'c'

# Too many arguments
calculate(1, 2, 3, 4)
# TypeError: calculate() takes 3 positional arguments but 4 were given

# Fix 1: Provide exact number
result = calculate(1, 2, 3)  # Correct

# Fix 2: Use default parameters
def calculate(a, b, c=0):  # c is optional now
    return a + b + c

result = calculate(1, 2)     # Works, c defaults to 0
result = calculate(1, 2, 3)  # Also works

# Fix 3: Use *args for variable arguments
def calculate(*args):
    return sum(args)

result = calculate(1, 2)        # Works
result = calculate(1, 2, 3, 4)  # Also works

TypeError occurs when argument count doesn't match parameters. Use default parameters for optional values or *args for variable-length arguments. Always check function definition to see what it expects.

Mistake 5: Indentation Errors

Wrong Indentation
def greet(name):
print(f"Hello, {name}")  # Not indented!
# IndentationError: expected an indented block

def calculate():
    x = 10
  y = 20  # Wrong indentation level
# IndentationError: unindent does not match
Correct Indentation
def greet(name):
    print(f"Hello, {name}")  # Properly indented

def calculate():
    x = 10
    y = 20  # Same level as x
    return x + y
Indentation Tip: Use 4 spaces per indentation level. Most editors can convert Tab to 4 spaces automatically. Mixing tabs and spaces causes errors.

Mistake 6: Scope Issues

# Problem: Trying to access local variable outside function
def calculate():
    result = 100
    return result

print(result)  # NameError: name 'result' is not defined

# Solution 1: Use the return value
def calculate():
    result = 100
    return result

value = calculate()
print(value)  # Works

# Problem: Modifying global variable without declaration
counter = 0

def increment():
    counter = counter + 1  # Error: local variable referenced before assignment
    return counter

# Solution: Use global keyword
counter = 0

def increment():
    global counter
    counter = counter + 1
    return counter

# Better Solution: Avoid globals, use parameters and returns
def increment(counter):
    return counter + 1

counter = 0
counter = increment(counter)

Local variables only exist inside functions. To share data between function and main program, use return values and parameters instead of global variables. Global variables make code harder to test and debug.

Mistake 7: Name Shadowing

# Problem: Using same name as built-in function
def list(items):  # Shadows built-in list()
    return items

result = list([1, 2, 3])
# Now you can't use list() to convert other types!
# numbers = list(range(5))  # Error

# Solution: Use different name
def create_list(items):  # Different name
    return items

# Problem: Parameter shadows global
name = "Global"

def greet(name):  # Parameter name shadows global name
    print(f"Hello, {name}")  # Uses parameter, not global
    
greet("Local")  # Output: Hello, Local

# This is actually OK in most cases, but can be confusing
# Use descriptive parameter names that make the distinction clear

Name shadowing occurs when a local name hides a global or built-in name. Avoid shadowing built-ins like list, dict, str, or sum. Parameter shadowing global variables is usually acceptable but use clear names to avoid confusion.

Debugging Tips

Use Print Debugging
def calculate(x, y):
    print(f"Input: x={x}, y={y}")
    result = x * y
    print(f"Result: {result}")
    return result
Test with Simple Inputs
def complex_calc(a, b):
    return (a + b) * 2

# Test with easy numbers
print(complex_calc(1, 1))  # Should be 4
print(complex_calc(0, 5))  # Should be 10
Check Type and Value
def process(data):
    print(f"Type: {type(data)}")
    print(f"Value: {data}")
    # Continue processing

Quick Reference Guide

A comprehensive cheat sheet of function syntax and patterns for quick lookup.

Function Definition Syntax

Syntax Example Description
Basic function def func_name(): No parameters, no return value
With parameters def func_name(param1, param2): Accepts two positional arguments
With return def func_name(): return value Returns a value to caller
Default parameters def func_name(param=default): Parameter has default value
Variable positional args def func_name(*args): Accepts any number of positional args (tuple)
Variable keyword args def func_name(**kwargs): Accepts any number of keyword args (dict)
Combined def func(req, *args, key=val, **kwargs): All parameter types together
Type hints def func(x: int) -> str: Annotate types for documentation

Function Call Syntax

Call Method Example When to Use
Positional arguments func(1, 2, 3) Arguments matched by position
Keyword arguments func(x=1, y=2) Arguments matched by name
Mixed func(1, y=2) Positional first, then keyword
Unpack list func(*[1, 2, 3]) Expand list as positional args
Unpack dict func(**{'x': 1, 'y': 2}) Expand dict as keyword args

Return Statement Patterns

# Single value
return 42

# Multiple values (tuple)
return value1, value2, value3

# Conditional return
return True if condition else False

# Early return (guard clause)
if error:
    return None

# No return (implicit None)
# Function ends without return statement

# Return None explicitly
return None

# Return complex data structure
return {
    "status": "success",
    "data": [1, 2, 3],
    "count": 3
}

Common Function Templates

Validation Function Template
def is_valid_something(value):
    """
    Check if value meets criteria.
    
    Args:
        value: The value to validate
    
    Returns:
        bool: True if valid, False otherwise
    """
    # Validation logic
    if not condition:
        return False
    
    # More checks...
    
    return True
Calculation Function Template
def calculate_something(param1, param2):
    """
    Calculate result based on inputs.
    
    Args:
        param1: First parameter
        param2: Second parameter
    
    Returns:
        The calculated result
    """
    # Input validation
    if not valid:
        return None
    
    # Calculation
    result = param1 + param2
    
    return result

Calculation functions validate inputs first, perform the computation, then return the result. Return None or a special value for invalid inputs to signal errors. This pattern ensures reliable calculations.

Data Transformer Template
def transform_data(input_data):
    """
    Transform input to output format.
    
    Args:
        input_data: Raw input data
    
    Returns:
        Transformed data
    """
    # Handle empty input
    if not input_data:
        return []
    
    # Transform
    output = []
    for item in input_data:
        transformed = process(item)
        output.append(transformed)
    
    return output

Transformer functions iterate over input data, apply transformations, and build output collections. Always handle empty inputs gracefully. Use list comprehensions for simple transformations to make code more concise.

Aggregator Function Template
def aggregate_data(items):
    """
    Aggregate items into summary.
    
    Args:
        items: Collection of items
    
    Returns:
        dict: Summary statistics
    """
    if not items:
        return {}
    
    return {
        "count": len(items),
        "total": sum(items),
        "average": sum(items) / len(items),
        "min": min(items),
        "max": max(items)
    }

Aggregator functions summarize data into dictionaries with labeled results. Return empty dict for empty input. This pattern is excellent for data analysis, reporting, and dashboard functions that need multiple metrics.

Parameter Order Rules

Required Parameter Order

When defining functions with multiple parameter types, they must appear in this order:

  1. Regular positional parameters: def func(a, b):
  2. *args (variable positional): def func(a, *args):
  3. Keyword-only parameters: def func(a, *, key):
  4. **kwargs (variable keyword): def func(a, *, key, **kwargs):
# Complete example with all parameter types
def complete_function(
    required_param,           # 1. Required positional
    default_param="default",  # 2. Positional with default
    *args,                    # 3. Variable positional
    keyword_only,             # 4. Keyword-only (after *args)
    key_with_default="value", # 5. Keyword-only with default
    **kwargs                  # 6. Variable keyword
):
    """Function showing all parameter types."""
    print(f"Required: {required_param}")
    print(f"Default: {default_param}")
    print(f"Args: {args}")
    print(f"Keyword-only: {keyword_only}")
    print(f"Key with default: {key_with_default}")
    print(f"Kwargs: {kwargs}")

# Call example
complete_function(
    "value1",                    # required_param
    "value2",                    # default_param
    "extra1", "extra2",          # *args
    keyword_only="must_name",    # keyword_only
    key_with_default="custom",   # key_with_default
    option1="A", option2="B"     # **kwargs
)

This demonstrates all six parameter types in proper order. Required params come first, then defaults, then *args, then keyword-only, finally **kwargs. Python enforces this order strictly.

Scope Quick Reference

LEGB Rule (Variable Lookup Order)
Local
Function scope
Enclosing
Outer function
Global
Module level
Built-in
Python keywords
# Scope modification keywords
global var_name    # Modify global variable
nonlocal var_name  # Modify enclosing scope variable

Best Practices Checklist

Do These
  • Use descriptive function names (verb + noun)
  • Write docstrings for all functions
  • Keep functions short (10-20 lines)
  • One purpose per function
  • Use type hints for clarity
  • Return consistent types
  • Validate input parameters
  • Use default parameters wisely (immutable only)
  • Return early on errors (guard clauses)
  • Test functions with simple inputs
Avoid These
  • Generic names (func1, do_stuff)
  • Missing or poor documentation
  • Functions longer than 50 lines
  • Doing too many things in one function
  • Deep nesting (more than 3 levels)
  • Inconsistent return types
  • Using print instead of return
  • Mutable default arguments ([], {})
  • Modifying global variables
  • Forgetting to call with parentheses ()

Common Function Patterns

Learn reusable patterns that solve common programming problems. These recipes are building blocks for larger applications.

Validation Functions

Functions that check if data meets certain criteria:

String Validators
def is_palindrome(text):
    """Check if text reads same forwards and backwards."""
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

def has_digits(text):
    """Check if text contains any digits."""
    return any(char.isdigit() for char in text)

def is_all_caps(text):
    """Check if all letters are uppercase."""
    return text.isupper() and text != text.lower()

# Usage
print(is_palindrome("racecar"))  # True
print(is_palindrome("A man a plan a canal Panama"))  # True
print(has_digits("abc123"))      # True
print(is_all_caps("HELLO"))      # True

String validators check text properties. They use built-in string methods and comprehensions to test conditions. Return boolean values for easy use in if statements.

Number Validators
def is_even(n):
    """Check if number is even."""
    return n % 2 == 0

def is_prime(n):
    """Check if number is prime."""
    if n < 2:
        return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0:
            return False
    return True

def is_in_range(value, min_val, max_val):
    """Check if value is within range."""
    return min_val <= value <= max_val

# Usage
print(is_even(10))            # True
print(is_prime(17))           # True
print(is_in_range(5, 1, 10))  # True

Number validators test mathematical properties. is_prime uses trial division algorithm. is_in_range uses comparison operators. All return True/False for conditional logic.

Transformation Functions

Functions that convert data from one form to another:

def capitalize_words(text):
    """Capitalize first letter of each word."""
    return ' '.join(word.capitalize() for word in text.split())

Capitalizing each word: This function transforms text by making the first letter of each word uppercase. Here's how it works step-by-step: text.split() breaks the text into a list of words (e.g., "hello world" becomes ["hello", "world"]). Then word.capitalize() for word in ... is a generator expression that goes through each word and capitalizes it (["Hello", "World"]). Finally, ' '.join(...) combines these words back into a single string with spaces between them: "Hello World". This is useful for formatting titles, names, or headings.

def snake_to_camel(snake_str):
    """Convert snake_case to camelCase."""
    components = snake_str.split('_')
    return components[0] + ''.join(x.title() for x in components[1:])

Snake case to camel case conversion: This converts naming styles commonly used in programming. "user_name" (snake_case) becomes "userName" (camelCase). Breaking it down: split('_') splits at underscores, turning "user_name" into ["user", "name"]. components[0] keeps the first word lowercase ("user"). Then x.title() for x in components[1:] capitalizes the first letter of each remaining word (["Name"]), and ''.join() combines them with no separator. Result: "user" + "Name" = "userName". This is essential when working across different programming languages that use different naming conventions.

def seconds_to_time(seconds):
    """Convert seconds to HH:MM:SS format."""
    hours = seconds // 3600
    minutes = (seconds % 3600) // 60
    secs = seconds % 60
    return f"{hours:02d}:{minutes:02d}:{secs:02d}"

Time conversion math: This converts a number of seconds into readable time format (like on a digital clock). The math: there are 3600 seconds in an hour (60 × 60), so seconds // 3600 gives whole hours using integer division (e.g., 3665 seconds ÷ 3600 = 1 hour). seconds % 3600 gives the remaining seconds after removing full hours (3665 % 3600 = 65 seconds left). Then 65 // 60 gives minutes (1 minute), and 65 % 60 gives remaining seconds (5). The f-string {hours:02d} formats each number with 2 digits, padding with zeros if needed (1 becomes "01"). Example: 3665 seconds = 01:01:05.

def list_to_string(items, separator=", "):
    """Join list items into a string."""
    return separator.join(str(item) for item in items)

List to string conversion: This transforms a list into a single string with a custom separator between items. The str(item) for item in items generator converts each item to a string first (important because join() only works with strings). If you have [1, 2, 3], it becomes ["1", "2", "3"]. Then separator.join(...) puts the separator between each item. Default separator is ", " (comma and space), so [1, 2, 3] becomes "1, 2, 3". But you can pass any separator - using " | " gives "1 | 2 | 3". Perfect for creating readable lists in output or log messages.

def truncate(text, length, suffix="..."):
    """Truncate text to specified length."""
    if len(text) <= length:
        return text
    return text[:length - len(suffix)] + suffix

Text truncation logic: This shortens long text to a maximum length, adding "..." to show it was cut off. First check: if len(text) <= length: - if the text is already short enough, return it unchanged. Otherwise, we need to truncate. text[:length - len(suffix)] slices the text but leaves room for the suffix. For example, to truncate "Long text here" to 10 characters: we calculate 10 - len("...") = 10 - 3 = 7, take the first 7 characters "Long te", then add "..." to get "Long te...". This prevents the final result from exceeding the desired length. Useful for previews, tooltips, or displaying long strings in limited space.

# Usage examples
print(capitalize_words("hello world"))      # Hello World
print(snake_to_camel("user_name"))          # userName
print(seconds_to_time(3665))                # 01:01:05
print(list_to_string([1, 2, 3], " | "))     # 1 | 2 | 3
print(truncate("Long text here", 10))       # Long te...

Transformations in action: These examples show each transformation function working with real data. Notice how each function takes input in one format and returns it in a completely different format without modifying the original data. "hello world" becomes "Hello World" (capitalized). "user_name" becomes "userName" (different naming convention). 3665 becomes "01:01:05" (number to time string). [1, 2, 3] becomes "1 | 2 | 3" (list to delimited string). "Long text here" becomes "Long te..." (truncated with ellipsis). All these transformations are "pure" - they don't change the original input, they create new output. This is the immutability principle in action!

Aggregation Functions

Functions that combine or summarize data:

def count_occurrences(items, target):
    """Count how many times target appears in items."""
    return items.count(target)

def unique_items(items):
    """Return list with duplicates removed."""
    return list(set(items))

def most_common(items):
    """Return the most frequently occurring item."""
    if not items:
        return None
    return max(set(items), key=items.count)

def group_by_length(words):
    """Group words by their length."""
    groups = {}
    for word in words:
        length = len(word)
        if length not in groups:
            groups[length] = []
        groups[length].append(word)
    return groups

def calculate_stats(numbers):
    """Return dictionary with basic statistics."""
    if not numbers:
        return {}
    return {
        "count": len(numbers),
        "sum": sum(numbers),
        "average": sum(numbers) / len(numbers),
        "min": min(numbers),
        "max": max(numbers)
    }

# Usage
print(count_occurrences([1, 2, 2, 3, 2], 2))  # 3
print(unique_items([1, 2, 2, 3, 1]))          # [1, 2, 3]
print(most_common(['a', 'b', 'a', 'c', 'a'])) # 'a'

words = ["cat", "dog", "bird", "fish", "ant"]
print(group_by_length(words))
# {3: ['cat', 'dog', 'ant'], 4: ['bird', 'fish']}

print(calculate_stats([10, 20, 30, 40, 50]))
# {'count': 5, 'sum': 150, 'average': 30.0, 'min': 10, 'max': 50}

Aggregation functions combine or summarize collections into meaningful results. They're essential for data analysis, reporting, and extracting insights from datasets. Often return dictionaries with multiple metrics.

Helper Functions (Pure Functions)

Small, reusable functions with no side effects:

def clamp(value, min_value, max_value):
    """Restrict value to be within min and max."""
    return max(min_value, min(value, max_value))

def percentage(part, whole):
    """Calculate what percentage part is of whole."""
    if whole == 0:
        return 0
    return (part / whole) * 100

def average_of_two(a, b):
    """Calculate mean of two numbers."""
    return (a + b) / 2

def sign(number):
    """Return -1, 0, or 1 based on number's sign."""
    if number > 0:
        return 1
    elif number < 0:
        return -1
    else:
        return 0

def swap_values(a, b):
    """Swap two values and return them."""
    return b, a

# Usage
print(clamp(15, 0, 10))            # 10 (clamped to max)
print(clamp(-5, 0, 10))            # 0 (clamped to min)
print(percentage(25, 200))         # 12.5
print(average_of_two(10, 20))      # 15.0
print(sign(-5))                    # -1
print(swap_values(1, 2))           # (2, 1)

Helper functions perform simple, focused tasks with no side effects. They take inputs, perform calculations, and return results without modifying global state. Perfect building blocks for larger operations.

Pure Functions: Functions that always return the same output for the same input and have no side effects (don't modify external state). They're easier to test, debug, and reason about.

Factory Functions

Functions that create and return other objects or functions:

def make_multiplier(factor):
    """Create a function that multiplies by factor."""
    def multiply(x):
        return x * factor
    return multiply

def create_counter(start=0, step=1):
    """Create a counter that increments by step."""
    count = start - step  # Start one step before
    
    def next_value():
        nonlocal count
        count += step
        return count
    
    return next_value

def make_greeter(greeting):
    """Create a customized greeting function."""
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

# Usage
times_3 = make_multiplier(3)
times_5 = make_multiplier(5)
print(times_3(10))  # 30
print(times_5(10))  # 50

counter = create_counter(start=0, step=5)
print(counter())  # 5
print(counter())  # 10
print(counter())  # 15

say_hello = make_greeter("Hello")
say_hi = make_greeter("Hi")
print(say_hello("Alice"))  # Hello, Alice!
print(say_hi("Bob"))       # Hi, Bob!

Factory functions create customized functions or objects. They use closures to remember the configuration data.

Decorator Pattern Preview

Functions that modify or enhance other functions (advanced topic):

def timer_wrapper(func):
    """Measure how long a function takes to run."""
    import time
    
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    
    return wrapper

def validate_positive(func):
    """Ensure all arguments are positive numbers."""
    def wrapper(*args):
        if any(arg <= 0 for arg in args):
            return "Error: All arguments must be positive"
        return func(*args)
    
    return wrapper

# Create enhanced functions
@timer_wrapper
def slow_calculation(n):
    """Simulate slow calculation."""
    total = 0
    for i in range(n):
        total += i
    return total

@validate_positive
def divide(a, b):
    """Divide a by b."""
    return a / b

# Usage
result = slow_calculation(1000000)
# Output: slow_calculation took 0.0234 seconds

print(divide(10, 2))   # 5.0
print(divide(-10, 2))  # Error: All arguments must be positive

Decorators wrap functions to add extra behavior. The @syntax applies decorators cleanly. They're powerful for logging, timing, validation, and other cross-cutting concerns.

Advanced Topic: Decorators are covered in detail in Module 5.2 (Advanced Functions). This is a preview to show the power of functions that work with functions.

Interactive Demonstrations

Visualize how functions work with these interactive examples.

Function Call Visualizer

See how data flows through function parameters and return values:

Parameter Flow Demonstration
1. Function Definition
def calculate_tax(amount, rate):
    tax = amount * rate
    total = amount + tax
    return total
2. Function Call
price = 100
tax_rate = 0.08

result = calculate_tax(price, tax_rate)
Data Flow:
  • price (100) → amount
  • tax_rate (0.08) → rate
3. Execution & Return
# Inside function:
# tax = 100 * 0.08 = 8.0
# total = 100 + 8.0 = 108.0
# return 108.0

# result = 108.0
Result: 108.0

Arguments flow into parameters, calculations happen inside the function, and the return value flows back to the caller. This data flow is fundamental to how functions work.

Scope Visualizer

Understand variable scope with this step-by-step breakdown:

Scope Levels Demonstration
# Global scope
global_var = "I'm global"

def outer():
    # Enclosing scope
    outer_var = "I'm in outer"
    
    def inner():
        # Local scope
        local_var = "I'm local"
        print(f"1. {local_var}")
        print(f"2. {outer_var}")
        print(f"3. {global_var}")
    
    inner()

outer()
Variable Lookup Order (LEGB)
Local: local_var found here first
Enclosing: outer_var from outer function
Global: global_var at module level
Built-in: print, len, etc.

Default Arguments Behavior

See how default arguments work (and the mutable default pitfall):

Mutable Default Pitfall
def add_item(item, items=[]):
    items.append(item)
    return items

# First call
list1 = add_item("apple")
print(list1)  # ['apple']

# Second call
list2 = add_item("banana")
print(list2)  # ['apple', 'banana']

# Same list object!
print(list1 is list2)  # True
Problem: Default list is created once and shared across all calls
Safe Solution
def add_item(item, items=None):
    if items is None:
        items = []  # New list each time
    items.append(item)
    return items

# First call
list1 = add_item("apple")
print(list1)  # ['apple']

# Second call
list2 = add_item("banana")
print(list2)  # ['banana'] ✓

# Different list objects
print(list1 is list2)  # False
Solution: Use None as default, create new list inside function

*args and **kwargs Visualizer

See how variable-length arguments are collected:

Argument Packing Demonstration
*args (Positional)
def sum_all(*args):
    print(f"args type: {type(args)}")
    print(f"args value: {args}")
    return sum(args)

result = sum_all(1, 2, 3, 4, 5)

# Output:
# args type: 
# args value: (1, 2, 3, 4, 5)
# result: 15
*args collects positional arguments into a tuple
**kwargs (Keyword)
def build_profile(**kwargs):
    print(f"kwargs type: {type(kwargs)}")
    print(f"kwargs value: {kwargs}")

build_profile(name="Alice", age=25, city="NYC")

# Output:
# kwargs type: 
# kwargs value: {'name': 'Alice', 
#                'age': 25, 
#                'city': 'NYC'}
**kwargs collects keyword arguments into a dictionary

Return Value Patterns

Compare different return patterns and their use cases:

Pattern Code Example Use Case Return Type
Single Value return total Most common, one result Any type
Multiple Values return min_val, max_val Related values, unpack at call site Tuple
None (Implicit) # No return statement Side-effect functions (print, save) None
None (Explicit) return None "Not found" or "no result" None
Boolean return is_valid Yes/no questions, predicates bool
Dictionary return {"key": value} Structured data with labels dict
List/Tuple return [item1, item2] Collection of items list/tuple
Early Return if error: return "Error" Error handling, guard clauses Any type

Function Composition Pipeline

See how small functions combine to create complex behavior:

Data Pipeline Demonstration
# Step 1: Define small, focused functions
def remove_whitespace(text):
    """Remove leading/trailing whitespace."""
    return text.strip()

def lowercase(text):
    """Convert to lowercase."""
    return text.lower()

def remove_punctuation(text):
    """Remove common punctuation."""
    for char in ".,!?;:":
        text = text.replace(char, "")
    return text

def word_count(text):
    """Count words in text."""
    return len(text.split())

# Step 2: Compose into pipeline
def analyze_text(text):
    """Complete text analysis pipeline."""
    # Data flows through transformations
    cleaned = remove_whitespace(text)        # "  Hello, World!  " → "Hello, World!"
    lowered = lowercase(cleaned)             # "Hello, World!" → "hello, world!"
    no_punct = remove_punctuation(lowered)   # "hello, world!" → "hello world"
    count = word_count(no_punct)             # "hello world" → 2
    
    return {
        "original": text,
        "cleaned": no_punct,
        "word_count": count
    }

# Step 3: Use the pipeline
result = analyze_text("  Hello, World! How are you?  ")
print(result)
# Output: {
#     'original': '  Hello, World! How are you?  ',
#     'cleaned': 'hello world how are you',
#     'word_count': 5
# }
Pipeline Benefits: Each function is simple and testable. You can reuse individual functions or swap them out. The pipeline is easy to understand and modify. This is a cornerstone of functional programming.

Key Takeaways

def Keyword

Define functions with def, a descriptive name, parentheses for parameters, and a colon. Indent the body

Parameters and Arguments

Parameters are placeholders in definitions. Arguments are actual values passed when calling functions

Default Values

Default parameters make arguments optional. They must come after required parameters in the signature

Return Statement

Return sends values back to callers. Functions without return implicitly return None

Multiple Returns

Return multiple values as tuples. Unpack them directly into separate variables at the call site

Docstrings

Add triple-quoted docstrings after def to document what functions do, their parameters, and return values

Knowledge Check

1 What keyword is used to define a function in Python?
2 What is the output of this code?
def greet(): return "Hi"
greet()
3 What's the difference between parameters and arguments?
4 Which function definition is valid?
5 What happens if a function doesn't have a return statement?
6 What is a docstring?
Answer all questions to check your score