Functions

Creating reusable, organized code blocks.

Introduction

Functions are one of the most important concepts in programming. They allow you to organize code into reusable blocks, avoid repetition, and make your programs more maintainable. A function is a named block of code that performs a specific task and can be called multiple times throughout your program. Functions help you write cleaner, more modular, and easier-to-debug code.

Defining and Calling Functions

To use a function, you first need to define it, then you can call it whenever you need it.

Basic Function Definition

# Define a function
def greet():
    print("Hello, World!")
    print("Welcome to Python!")

# Call the function
greet()
greet()  # Can call multiple times
Expected Output:
Hello, World!
Welcome to Python!
Hello, World!
Welcome to Python!

Key points: Use the def keyword, function name, parentheses, colon, and an indented function body. Function names follow the same rules as variable names (use snake_case).

Function Structure

def function_name(parameters):
    """
    Docstring describing what the function does.
    This is optional but highly recommended!
    """
    # Function body
    # Code that performs the task
    return value  # Optional

# Example
def calculate_area(length, width):
    """Calculate the area of a rectangle."""
    area = length * width
    return area

Why Use Functions?

Benefits
  • Reusability: Write once, use many times
  • Modularity: Break code into logical pieces
  • Maintainability: Fix bugs in one place
  • Readability: Descriptive names explain purpose
  • Testing: Test functions independently
Without Functions
  • Repeated code blocks
  • Hard to maintain
  • Difficult to debug
  • Long, hard-to-read code
  • Changes require multiple edits

Parameters and Arguments

Functions can accept input values called parameters (in the definition) and arguments (when calling the function).

Function with One Parameter

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

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

Function with Multiple Parameters

def add_numbers(a, b):
    result = a + b
    print(f"{a} + {b} = {result}")

add_numbers(5, 3)
add_numbers(10, 20)
Expected Output:
5 + 3 = 8
10 + 20 = 30

Positional Arguments

Arguments are matched to parameters by position:

def introduce(name, age, city):
    print(f"Hi, I'm {name}, {age} years old, from {city}")

# Positional arguments (order matters!)
introduce("Alice", 30, "New York")

# Wrong order = wrong result!
introduce("New York", "Alice", 30)
Expected Output:
Hi, I'm Alice, 30 years old, from New York
Hi, I'm New York, Alice years old, from 30

Keyword Arguments

You can specify arguments by name, which makes code more readable:

def introduce(name, age, city):
    print(f"Hi, I'm {name}, {age} years old, from {city}")

# Keyword arguments (order doesn't matter)
introduce(name="Alice", age=30, city="New York")
introduce(city="Boston", name="Bob", age=25)

# Mix positional and keyword (positional first)
introduce("Alice", age=30, city="New York")
# introduce(age=30, "Alice", city="New York")  # SyntaxError: positional after keyword
Expected Output:
Hi, I'm Alice, 30 years old, from New York
Hi, I'm Bob, 25 years old, from Boston
Hi, I'm Alice, 30 years old, from New York

Default Parameters

You can provide default values for parameters. If the caller omits them, the default is used:

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

greet("Alice")                        # uses default greeting
greet("Bob", "Hi")                    # positional override
greet("Charlie", greeting="Hey")      # keyword override
Expected Output:
Hello, Alice!
Hi, Bob!
Hey, Charlie!

Multiple Default Parameters

Functions can have several default parameters. You can override any subset of them, in any order, using keyword arguments:

def create_profile(name, age=0, city="Unknown", active=True):
    print(f"Name: {name}, Age: {age}, City: {city}, Active: {active}")

create_profile("Alice")                          # all defaults
create_profile("Bob", 25)                        # override age
create_profile("Charlie", city="Boston")         # skip age, override city
create_profile("Diana", 30, "New York", False)   # override everything
Expected Output:
Name: Alice, Age: 0, City: Unknown, Active: True
Name: Bob, Age: 25, City: Unknown, Active: True
Name: Charlie, Age: 0, City: Boston, Active: True
Name: Diana, Age: 30, City: New York, Active: False

Important: Parameters with default values must come after parameters without defaults.

Variable-Length Arguments

Use *args for multiple positional arguments and **kwargs for multiple keyword arguments:

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

print(sum_numbers(1, 2, 3))
print(sum_numbers(1, 2, 3, 4, 5))

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

print_info(name="Alice", age=30, city="NYC")
Expected Output:
6
15
name: Alice
age: 30
city: NYC

Return Values

Functions can return values using the return statement. This allows you to use the function's result in other parts of your code.

Basic Return

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

print(add(5, 3))

# Can use directly
print(add(10, 20))

# Can use in expressions
total = add(5, 3) + add(2, 4)
print(total)
Expected Output:
8
30
14

Multiple Return Values

Functions can return multiple values as a tuple:

def calculate_stats(numbers):
    total = sum(numbers)
    count = len(numbers)
    average = total / count if count > 0 else 0
    maximum = max(numbers) if numbers else 0
    minimum = min(numbers) if numbers else 0
    return total, average, maximum, minimum

scores = [85, 90, 88, 92, 87]
total, avg, max_score, min_score = calculate_stats(scores)

print(f"Total: {total}, Average: {avg:.1f}")
print(f"Max: {max_score}, Min: {min_score}")

# Or receive as tuple
result = calculate_stats(scores)
print(result)
Expected Output:
Total: 442, Average: 88.4
Max: 92, Min: 85
(442, 88.4, 92, 85)

Early Return

You can (and often should) return early from a function. This is called "early return" and is recommended because it helps make your code simpler, clearer, and easier to read. By returning as soon as you've handled an error, met a condition, or finished your work, you avoid unnecessary nesting and complex logic.

def check_age(age):
    if age < 0:
        return "Invalid age"
    if age < 18:
        return "Minor"
    if age < 65:
        return "Adult"
    return "Senior"

print(check_age(25))
print(check_age(70))
print(check_age(-5))

# Function without return returns None
def no_return():
    print("This function doesn't return anything")

result = no_return()
print(result)
Expected Output:
Adult
Senior
Invalid age
This function doesn't return anything
None

Scope and Local/Global Variables

Scope determines where a variable can be accessed. Understanding scope is crucial for writing correct functions.

Local Variables

Variables defined inside a function are local to that function:

def my_function():
    local_var = "I'm local"
    print(local_var)

my_function()
# print(local_var)   # NameError: local_var is not defined outside function

# Each function call creates new local variables
def counter():
    count = 0
    count += 1
    return count

print(counter())
print(counter())  # Still 1: a new local variable is created each call
Expected Output:
I'm local
1
1

Global Variables

Variables defined outside functions are global and can be read inside functions:

# Global variable
global_var = "I'm global"

def read_global():
    print(global_var)  # Can read global variable

read_global()

# Without 'global' keyword, assignment creates a LOCAL variable
def try_modify():
    global_var = "Trying to change"  # Creates a LOCAL variable, not modifying global!
    print(global_var)

try_modify()
print(global_var)  # Global is unchanged
Expected Output:
I'm global
Trying to change
I'm global

The global Keyword

To modify a global variable inside a function, use the global keyword:

count = 0  # Global variable

def increment():
    global count  # Declare we're modifying the global variable
    count += 1
    print(f"Count is now {count}")

increment()
increment()
print(count)

# Best practice: avoid global variables when possible
# Pass values as parameters and return results instead
Expected Output:
Count is now 1
Count is now 2
2

Best Practice: Avoid using global variables when possible. It's better to pass values as parameters and return results. Global variables make code harder to understand and debug.

The nonlocal Keyword

global reaches all the way out to the module level. When a function is nested inside another function, the variable you want to modify often lives in the enclosing function, not at module level. That is what nonlocal is for.

The problem it solves: assigning to a name anywhere in a function makes that name local to the function, so an inner function cannot update a counter defined by its parent:

def make_counter():
    count = 0

    def increment():
        count += 1  # No 'nonlocal': Python treats count as local to increment()
        return count

    return increment

counter = make_counter()
print(counter())
Expected Output:
Traceback (most recent call last):
  File "script.py", line 11, in <module>
    print(counter())
          ~~~~~~~^^
  File "script.py", line 5, in increment
    count += 1  # No 'nonlocal': Python treats count as local to increment()
    ^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value

Declaring the name nonlocal tells Python to rebind the existing variable in the enclosing scope instead of creating a new local one:

def make_counter():
    count = 0  # Lives in make_counter's scope (the "enclosing" scope)

    def increment():
        nonlocal count  # Rebind the enclosing count, don't create a new local
        count += 1
        return count

    return increment

counter = make_counter()
print(counter())
print(counter())
print(counter())

# Each call to make_counter() gets its own independent count
other = make_counter()
print(other())
Expected Output:
1
2
3
1

global vs nonlocal

Both keywords do the same kind of job: they tell Python that an assignment should rebind an existing variable rather than create a local one. They differ in which scope they target. This example has the same name in three scopes at once:

x = "module level"

def outer():
    x = "enclosing"  # outer()'s own x

    def change_nonlocal():
        nonlocal x  # Targets outer()'s x
        x = "changed by nonlocal"

    def change_global():
        global x  # Skips outer() entirely, targets the module-level x
        x = "changed by global"

    change_nonlocal()
    print(f"outer's x after nonlocal: {x}")

    change_global()
    print(f"outer's x after global:   {x}")

outer()
print(f"module-level x:           {x}")
Expected Output:
outer's x after nonlocal: changed by nonlocal
outer's x after global:   changed by nonlocal
module-level x:           changed by global

Notice that change_global() left outer's x untouched: it jumped straight past the enclosing scope to the module-level variable.

globalnonlocal
Scope it targetsModule level, alwaysNearest enclosing function
Needs a nested function?No, works in any functionYes, only meaningful inside a nested function
If the name does not existCreates it on assignmentSyntaxError at compile time
Typical useModule-level state (best avoided)Closures, e.g. a counter or accumulator

That third row is the one that surprises people. global happily creates a name that was never defined:

def create_it():
    global brand_new  # Name does not exist yet at module level
    brand_new = 42

create_it()
print(brand_new)
Expected Output:
42

nonlocal refuses, because there is no existing binding for it to attach to. This is a compile-time error, so it fires before any of the code runs:

def outer():
    def inner():
        nonlocal missing  # No 'missing' in any enclosing function
        missing = 1
    inner()

outer()
Expected Output:
  File "script.py", line 3
    nonlocal missing  # No 'missing' in any enclosing function
    ^^^^^^^^^^^^^^^^
SyntaxError: no binding for nonlocal 'missing' found

Scope Example

x = "global"  # Global variable

def outer():
    x = "outer"  # Local to outer()

    def inner():
        x = "inner"  # Local to inner()
        print(f"inner: x = {x}")

    inner()
    print(f"outer: x = {x}")

outer()
print(f"global: x = {x}")
Expected Output:
inner: x = inner
outer: x = outer
global: x = global

How Python looks up a name: the LEGB rule

Localx = 'inner'Enclosingx = 'outer'Globalx = 'global'Built-inprint, lennot foundnot foundnot found

Figure 1: A name is resolved by searching Local, then Enclosing, then Global, then Built-in scope. The first match wins, which is why inner() prints 'inner'. When you assign instead of read, nonlocal points the assignment at the Enclosing box and global points it at the Global box.

Lambda Functions

Lambda functions are small, anonymous functions defined with the lambda keyword. They're useful for short, simple operations.

Basic Lambda Syntax

# Regular function
def add(a, b):
    return a + b

# Lambda function (equivalent)
add_lambda = lambda a, b: a + b

print(add(5, 3))
print(add_lambda(5, 3))

# Syntax: lambda parameters: expression
# No return keyword needed: result is returned implicitly
Expected Output:
8
8

Common Use Cases

Lambda functions are often used with built-in functions like map(), filter(), and sorted():

numbers = [1, 2, 3, 4, 5]

# map() - apply function to each element
squares = list(map(lambda x: x ** 2, numbers))
print(squares)

# filter() - keep elements that meet condition
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)

# sorted() - custom sorting key
students = [("Alice", 25), ("Bob", 20), ("Charlie", 30)]
sorted_by_age = sorted(students, key=lambda student: student[1])
print(sorted_by_age)
Expected Output:
[1, 4, 9, 16, 25]
[2, 4]
[('Bob', 20), ('Alice', 25), ('Charlie', 30)]

Lambda vs Regular Functions

Use Lambda When:
  • Short, simple operations
  • One-line expressions
  • Used as arguments to other functions
  • Temporary, throwaway functions
Skip Lambda When:
  • Complex logic
  • Multiple statements
  • Needs documentation
  • Reused multiple times
  • Needs to be testable
Write a regular def instead: it can hold multiple statements, a docstring, and a name worth testing.

More Lambda Examples

# Simple operation
double = lambda x: x * 2
print(double(5))

# Multiple parameters
multiply = lambda x, y: x * y
print(multiply(3, 4))

# Conditional expression in lambda
max_value = lambda a, b: a if a > b else b
print(max_value(5, 3))

# Default parameter in lambda
power = lambda x, n=2: x ** n
print(power(5))
print(power(5, 3))
Expected Output:
10
12
5
25
125

Practical Examples

Example: Calculator Functions

def calculate(operation, a, b):
    """Perform a mathematical operation on two numbers."""
    if operation == "add":
        return a + b
    elif operation == "subtract":
        return a - b
    elif operation == "multiply":
        return a * b
    elif operation == "divide":
        if b != 0:
            return a / b
        else:
            return "Error: Division by zero"
    else:
        return "Error: Unknown operation"

print(calculate("add", 10, 5))
print(calculate("multiply", 4, 7))
print(calculate("divide", 20, 4))
print(calculate("divide", 10, 0))
Expected Output:
15
28
5.0
Error: Division by zero

Example: Data Processing Functions

def process_students(students, min_grade=80):
    """
    Filter and process student data.

    Args:
        students: List of dictionaries with 'name' and 'grade' keys
        min_grade: Minimum grade threshold (default: 80)

    Returns:
        List of students who meet the grade threshold
    """
    passed = []
    for student in students:
        if student["grade"] >= min_grade:
            passed.append({
                "name": student["name"],
                "grade": student["grade"],
                "status": "Pass"
            })
    return passed

students = [
    {"name": "Alice",   "grade": 85},
    {"name": "Bob",     "grade": 78},
    {"name": "Charlie", "grade": 92},
    {"name": "Diana",   "grade": 88}
]

top_students = process_students(students, min_grade=85)
for student in top_students:
    print(f"{student['name']}: {student['grade']} ({student['status']})")
Expected Output:
Alice: 85 (Pass)
Charlie: 92 (Pass)
Diana: 88 (Pass)

Intermediate: * and / in Function Parameters

In Python 3, you can use * and / in your function definitions to control how arguments are passed:

  • * marks the end of positional arguments. Parameters after * must be specified by keyword.
  • / marks the end of parameters that must be passed positionally. Parameters before / cannot be specified by keyword.(Available in Python 3.8+)
def greet(name, /, greeting="Hello"):
    # 'name' must be positional; 'greeting' can be positional or keyword
    print(f"{greeting}, {name}!")

greet("Alice")                 # OK
greet("Alice", greeting="Hi")  # OK
# greet(name="Alice")          # TypeError: 'name' is positional-only

def add(a, b, *, show=False):
    # 'show' MUST be passed by keyword
    res = a + b
    if show:
        print(f"Result: {res}")
    return res

add(3, 4)              # OK, show defaults to False
add(3, 4, show=True)   # OK
# add(3, 4, True)      # TypeError: 'show' must be a keyword argument
Expected Output:
Hello, Alice!
Hi, Alice!
Result: 7

Tip: Using * and / makes your APIs clearer and helps prevent bugs from argument misplacement.

Key Takeaways

Function Basics

  • Use def to define functions
  • Functions make code reusable and modular
  • Call functions by name with parentheses
  • Add docstrings to document functions

Parameters & Arguments

  • Positional and keyword arguments
  • Default parameter values
  • *args for variable arguments
  • **kwargs for keyword arguments

Return & Scope

  • return sends values back
  • Functions can return multiple values
  • Local vs global variables
  • global rebinds at module level, nonlocal in the enclosing function
  • Avoid global variables when possible

Lambda Functions

  • Anonymous, one-line functions
  • Useful with map(), filter()
  • Keep lambdas simple
  • Use regular functions for complex logic
What's Next?

Great job! Functions are fundamental to writing clean, maintainable code. You now have the tools to organize your programs effectively. In future lessons, we'll explore:

  • Error handling - Handle exceptions gracefully with try/except blocks
  • Working with files - Read from and write to files safely
  • Modules and imports - Organize code across multiple files
  • Beyond Foundations - After this course, Python Intermediate covers object-oriented programming (classes and objects), iterators, decorators, and more