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.
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:
# 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
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.
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):
"""Calculate rectangle area."""
area = length * width
return area
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).
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 abbreviatedfunc1(data)- Generic, meaninglessGetUser(id)- Wrong case (not snake_case)do_stuff(thing)- Vague purposelist(items)- Shadows built-in
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!
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!
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.
def no_return():
x = 10 # Does something
# No return statement
result = no_return()
print(result) # None
print(result is None) # True
def empty_list():
return []
result = empty_list()
print(result) # []
print(result is None) # False
print(len(result)) # 0
def empty_string():
return ""
result = empty_string()
print(result) # (empty)
print(result is None) # False
print(len(result)) # 0
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.
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
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
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).
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.
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.
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
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.
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
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
Single Responsibility Principle
Each function should do one thing and do it well. If a function does too much, split it into smaller functions.
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}")
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.
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.
# 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
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.
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
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
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.
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
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
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
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
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
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
Mistake 3: Modifying Mutable Default Arguments
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
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
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
def greet(name):
print(f"Hello, {name}") # Properly indented
def calculate():
x = 10
y = 20 # Same level as x
return x + y
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
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
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.
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.
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:
- Regular positional parameters:
def func(a, b): - *args (variable positional):
def func(a, *args): - Keyword-only parameters:
def func(a, *, key): - **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)
Function scope
Outer function
Module level
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:
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.
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.
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.
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)
- 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
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_var found here first
outer_var from outer function
global_var at module level
print, len, etc.
Default Arguments Behavior
See how default arguments work (and the 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
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
*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
**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'}
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
# }
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()
greet()