Python Programming Functions
Core Concept Modular

Python Functions Complete Guide

Learn Python functions from the ground up: defining and calling functions, parameters, return values, scope, type hints, and recursion — with examples and best practices.

Modular

Reusable code

Parameters

Flexible inputs

Return values

Single or multiple

Recursion

Base case & patterns

Introduction to Functions

Functions are reusable blocks of code that perform specific tasks. They help in organizing code, reducing repetition, and improving maintainability.

Benefits of Functions

  • Code reusability
  • Modular programming
  • Easier debugging
  • Better organization

Key idea

Functions follow the DRY (Don't Repeat Yourself) principle. Use def to define them, parameters for inputs, and return for outputs.

Defining and Calling Functions

Basic function definition

Define and call simple functions
# Simple function without parameters
def greet():
    print("Hello, World!")

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

# Function with one parameter
def greet_person(name):
    print(f"Hello, {name}!")

greet_person("Alice")  # Output: Hello, Alice!

# Function with multiple parameters
def add_numbers(a, b):
    result = a + b
    print(f"{a} + {b} = {result}")

add_numbers(5, 3)  # Output: 5 + 3 = 8

Function with return value

Return values
def multiply(x, y):
    return x * y

result = multiply(4, 5)
print(result)  # Output: 20

# Functions can have multiple return statements
def check_even(number):
    if number % 2 == 0:
        return True
    else:
        return False

print(check_even(4))  # Output: True
print(check_even(7))  # Output: False

pass placeholder

Empty function placeholder
def function_to_implement_later():
    pass  # Placeholder, does nothing

# This allows the code to run without errors
function_to_implement_later()

Function Parameters and Arguments

Positional arguments

Positional arguments
def describe_person(name, age, city):
    print(f"{name} is {age} years old and lives in {city}")

# Arguments must be in correct order
describe_person("Alice", 30, "New York")
# Output: Alice is 30 years old and lives in New York

Keyword arguments

Keyword arguments
def describe_person(name, age, city):
    print(f"{name} is {age} years old and lives in {city}")

# Order doesn't matter with keyword arguments
describe_person(city="Boston", name="Bob", age=25)
# Output: Bob is 25 years old and lives in Boston

Default parameters

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

greet("Alice")              # Output: Hello, Alice!
greet("Bob", "Hi")          # Output: Hi, Bob!
greet("Charlie", greeting="Good morning")  # Output: Good morning, Charlie!

# Default parameters must come after non-default
def example(a, b=10, c=20):  # Correct
    pass

# def example(a=10, b):  # Incorrect - syntax error
#     pass

*args and **kwargs

Variable number of arguments
# *args - variable number of positional arguments (tuple)
def sum_all(*numbers):
    total = 0
    for num in numbers:
        total += num
    return total

print(sum_all(1, 2, 3))        # Output: 6
print(sum_all(5, 10, 15, 20))  # Output: 50
print(sum_all())               # Output: 0

# **kwargs - variable number of keyword arguments (dictionary)
def print_info(**info):
    for key, value in info.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=30, city="New York")
# Output:
# name: Alice
# age: 30
# city: New York

# Combining *args and **kwargs
def combined_function(*args, **kwargs):
    print(f"Positional arguments: {args}")
    print(f"Keyword arguments: {kwargs}")

combined_function(1, 2, 3, name="Alice", age=30)
# Output:
# Positional arguments: (1, 2, 3)
# Keyword arguments: {'name': 'Alice', 'age': 30}

Return Values

Single return value

Single value
def square(x):
    return x ** 2

result = square(5)
print(result)  # Output: 25

Multiple return values

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

minimum, maximum = get_min_max([1, 5, 3, 9, 2])
print(f"Min: {minimum}, Max: {maximum}")  # Output: Min: 1, Max: 9

# Functions return None if no return statement
def no_return():
    pass

result = no_return()
print(result)  # Output: None

Early returns

Guard clauses
def check_access(age, has_id):
    if age < 18:
        return "Too young"
    if not has_id:
        return "ID required"
    return "Access granted"

print(check_access(16, True))   # Output: Too young
print(check_access(20, False))  # Output: ID required
print(check_access(25, True))   # Output: Access granted

Scope and Lifetime of Variables

Local vs global

Local and global scope
# Global variable
global_var = "I am global"

def my_function():
    # Local variable
    local_var = "I am local"
    print(global_var)  # Can access global
    print(local_var)   # Can access local

my_function()
# print(local_var)  # Error! local_var not accessible outside

# Modifying global variables
counter = 0

def increment():
    global counter  # Declare that we're using global variable
    counter += 1

increment()
increment()
print(counter)  # Output: 2

Nested functions and nonlocal

nonlocal example
def outer_function():
    outer_var = "I'm in outer"

    def inner_function():
        nonlocal outer_var  # Modify variable from outer scope
        outer_var = "Modified by inner"
        print(outer_var)

    inner_function()
    print(outer_var)

outer_function()
# Output:
# Modified by inner
# Modified by inner

# Without nonlocal inside inner, assigning would create a new local name;
# reading still sees the enclosing variable.
def example():
    x = 10

    def inner():
        print(x)  # Reads from enclosing scope

    inner()

Function annotations (type hints)

Adding type hints (Python 3.5+) documents expected types. They are optional at runtime unless you use a type checker.

Annotations
# Adding type hints (Python 3.5+)
def greet(name: str) -> str:
    return f"Hello, {name}!"

def divide(dividend: float, divisor: float) -> float:
    if divisor == 0:
        raise ValueError("Cannot divide by zero")
    return dividend / divisor

# Annotations are stored in __annotations__
print(greet.__annotations__)  # {'name': <class 'str'>, 'return': <class 'str'>}

# Complex annotations (Python 3.9+ built-in generics)
def process_data(
    data: list[int],
    threshold: float = 0.5,
    verbose: bool = False
) -> dict[str, float]:
    """Process data and return statistics"""
    result = {
        "mean": sum(data) / len(data),
        "max": max(data),
        "min": min(data)
    }
    return result

Recursion Basics

Recursion is a technique where a function calls itself to solve a problem by breaking it down into smaller, similar subproblems.

Basic recursion structure

Template
def recursive_function(parameters):
    # Base case — condition to stop recursion
    if base_condition:
        return base_value

    # Recursive case — function calls itself
    return recursive_function(modified_parameters)

Simple examples

Countdown and counting
# Countdown using recursion
def countdown(n):
    if n <= 0:  # Base case
        print("Blast off!")
        return
    print(n)
    countdown(n - 1)  # Recursive call

countdown(5)
# Output:
# 5
# 4
# 3
# 2
# 1
# Blast off!

# Print numbers from 1 to n using recursion
def print_numbers(n):
    if n == 0:
        return
    print_numbers(n - 1)
    print(n)

print_numbers(5)
# Output: 1 2 3 4 5 (each on new line)
Classic examples: factorial and Fibonacci follow the same base case / recursive case idea. Prefer iterative or memoized Fibonacci for large n because of overlapping subproblems.

Tail recursion

In Python, tail calls are not optimized; loops are usually better when depth grows large.

Tail-style factorial and sum
# Tail recursion - the recursive call is the last operation
def factorial_tail(n, accumulator=1):
    if n <= 1:
        return accumulator
    return factorial_tail(n - 1, n * accumulator)

print(factorial_tail(5))  # Output: 120

# Sum with tail recursion
def sum_tail(numbers, accumulator=0):
    if not numbers:
        return accumulator
    return sum_tail(numbers[1:], accumulator + numbers[0])

print(sum_tail([1, 2, 3, 4, 5]))  # Output: 15

Mutual recursion

Two functions calling each other
def is_even(n):
    if n == 0:
        return True
    return is_odd(n - 1)

def is_odd(n):
    if n == 0:
        return False
    return is_even(n - 1)

print(is_even(4))   # Output: True
print(is_even(5))   # Output: False
print(is_odd(4))    # Output: False
print(is_odd(5))    # Output: True

Tree recursion

Binary strings of length n
def generate_binary_strings(n, prefix=""):
    if n == 0:
        print(prefix)
        return
    generate_binary_strings(n - 1, prefix + "0")
    generate_binary_strings(n - 1, prefix + "1")

print("Binary strings of length 3:")
generate_binary_strings(3)
# Output:
# 000
# 001
# 010
# 011
# 100
# 101
# 110
# 111

When to use recursion

Good for
  • Tree-like structures (file systems, HTML DOM)
  • Divide-and-conquer (merge sort, quicksort)
  • Backtracking (mazes, N-Queens)
  • Graph traversal (DFS)
  • Problems with natural recursive definitions (factorial, Fibonacci)
Avoid for
  • Simple loops (prefer iteration)
  • Very deep recursion (stack limits / recursion depth)
  • Tight performance loops (call overhead)
  • Problems without a clear, reachable base case

Memoization

Caching results avoids redundant work in recursive algorithms with overlapping subproblems.

Fibonacci with a manual cache
memo = {}

def fib_memo(n):
    if n in memo:
        return memo[n]
    if n <= 1:
        memo[n] = n
        return n
    memo[n] = fib_memo(n - 1) + fib_memo(n - 2)
    return memo[n]

print(fib_memo(30))
Recursion depth: Python limits recursion depth (often around 1000 frames). Very deep recursion raises RecursionError. Use iteration, functools.lru_cache, or redesign the algorithm if needed.

Key Takeaways

Functions
  • Functions promote code reuse and modularity
  • Parameters can be positional, keyword, default, or variable-length (*args, **kwargs)
  • Return values can be single, multiple (as tuples), or None
  • Variables have scope — local, global, and nonlocal for enclosing scopes
  • Lambda functions are anonymous single-expression functions (see the Lambda tutorial)
Recursion
  • Every recursive function needs a base case
  • Recursion breaks problems into smaller, similar subproblems
  • Recursion adds overhead (calls, stack memory)
  • Tail recursion is a stylistic pattern; Python does not eliminate tail calls
  • Memoization can greatly speed up recursive solutions with overlapping subproblems
Best practices
  • Use iteration for simple repetition
  • Use recursion when the problem has a natural recursive or tree-shaped structure
  • Always ensure the base case is reachable
  • Mind recursion depth for large inputs
  • Use memoization for overlapping subproblems (for example naive Fibonacci)