Python Functions — Complete Guide to Defining and Using Functions

Python BasicsFunctionsFree Lesson

Advertisement

Python Functions — Complete Guide

Functions are the building blocks of Python programs. They let you organize code into reusable pieces, avoid repetition, and write programs that are easier to read, test, and maintain.


Learning Objectives

By the end of this tutorial, you will be able to:

  1. Define functions using def with proper naming conventions
  2. Use parameters, default values, *args, and **kwargs
  3. Return values including multiple values via tuples
  4. Understand variable scope and the LEGB rule
  5. Write closures and understand late binding
  6. Create lambda functions and higher-order functions
  7. Implement basic recursion with proper base cases
  8. Write effective docstrings and type hints
  9. Avoid common function-related mistakes

Defining Functions

The def Keyword

def greet(name):
    """Return a greeting string."""
    return f"Hello, {name}!"

print(greet("Alice"))  # Hello, Alice!

Naming Conventions (PEP 8)

ConventionExampleDescription
lowercase + underscorescalculate_averageStandard snake_case
Verbs firstget_user, compute_totalAction verb prefix
No built-in namesDon't use list, dict, sumAvoid shadowing

Docstrings

def calculate_bmi(weight_kg, height_m):
    """Calculate Body Mass Index (BMI).

    Args:
        weight_kg: Weight in kilograms.
        height_m: Height in meters.

    Returns:
        BMI value as a float.
    """
    return weight_kg / (height_m ** 2)

Parameters and Arguments

Parameters are variable names in the function definition. Arguments are actual values passed when calling.

Positional and Keyword Arguments

def power(base, exponent):
    return base ** exponent

print(power(2, 3))                    # 8 (positional)
print(power(exponent=3, base=2))      # 8 (keyword)

Default Parameter Values

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

print(greet("Alice"))              # Hello, Alice!
print(greet("Bob", "Good morning")) # Good morning, Bob!

āš ļø Mutable Default Argument Gotcha: Never use mutable objects (lists, dicts) as default values. They are created once and shared across calls.

# WRONG — shared list across calls
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['apple', 'banana'] — unexpected!

# CORRECT — new list each time
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item("apple"))   # ['apple']
print(add_item("banana"))  # ['banana'] — correct!

*args and **kwargs

def total(*numbers):
    """Sum any number of arguments."""
    return sum(numbers)

print(total(1, 2, 3))        # 6
print(total(1, 2, 3, 4, 5))  # 15

def build_profile(**info):
    """Build a profile dictionary."""
    return info

profile = build_profile(name="Alice", age=30, city="NYC")
print(profile)  # {'name': 'Alice', 'age': 30, 'city': 'NYC'}

Parameter Ordering Rules

1. Regular parameters
2. Default parameters
3. *args
4. Keyword-only parameters (after *)
5. **kwargs
def func(positional, keyword_default, *args, key_only, **kwargs):
    print(f"positional: {positional}")
    print(f"*args: {args}")
    print(f"key_only: {key_only}")
    print(f"**kwargs: {kwargs}")

func(1, 2, 3, 4, 5, key_only="mandatory", extra="optional")

Positional-Only and Keyword-Only (Python 3.8+)

def pos_only(a, b, /, c=10):
    return a + b + c

print(pos_only(1, 2))    # 3
# pos_only(a=1, b=2)  # TypeError

def kw_only(*, x, y=0):
    return x + y

print(kw_only(x=5))      # 5
# kw_only(5)  # TypeError

Return Values

Returning Multiple Values (Tuples)

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

low, high = min_max([3, 1, 7, 2, 9])
print(f"Min: {low}, Max: {high}")  # Min: 1, Max: 9

Implicit None Return

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

print(say_hello("Alice"))  # None

Early Returns

def process(value):
    if value is None:
        return None
    if value <= 0:
        return 0
    return value * 2

Variable Scope

LEGB Rule

Python searches for variables in this order:

ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”
│  Built-in (B)                       │
│  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”    │
│  │  Global (G)                 │    │
│  │  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”    │    │
│  │  │  Enclosing (E)      │    │    │
│  │  │  ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”    │    │    │
│  │  │  │  Local (L)  │    │    │    │
│  │  │  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜    │    │    │
│  │  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜    │    │
│  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜    │
ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

Local → Enclosing → Global → Built-in

Local and Global Scope

x = "global"

def my_function():
    x = "local"
    print(f"Inside: {x}")

my_function()             # Inside: local
print(f"Outside: {x}")    # Outside: global

The global and nonlocal Keywords

counter = 0

def increment():
    global counter
    counter += 1

increment()
increment()
print(counter)  # 2

def make_counter():
    count = 0

    def increment():
        nonlocal count
        count += 1
        return count

    return increment

counter = make_counter()
print(counter())  # 1
print(counter())  # 2

First-Class Functions

In Python, functions are first-class objects — they can be assigned to variables, passed as arguments, and returned from other functions.

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

plus = add
print(plus(3, 4))  # 7

def apply(func, value):
    return func(value)

def square(x):
    return x ** 2

print(apply(square, 5))   # 25
print(apply(len, "hello")) # 5

def make_multiplier(factor):
    def multiplier(x):
        return x * factor
    return multiplier

double = make_multiplier(2)
print(double(5))  # 10

Closures

A closure is a function that remembers variables from its enclosing scope.

def make_greeting(prefix):
    def greet(name):
        return f"{prefix}, {name}!"
    return greet

hello = make_greeting("Hello")
goodbye = make_greeting("Goodbye")
print(hello("Alice"))  # Hello, Alice!
print(goodbye("Bob"))  # Goodbye, Bob!

Late Binding Gotcha

Closures capture variables by reference, not by value:

# WRONG — classic gotcha
functions = []
for i in range(5):
    functions.append(lambda: i)
print([f() for f in functions])  # [4, 4, 4, 4, 4]

# CORRECT — capture the current value
functions = []
for i in range(5):
    functions.append(lambda i=i: i)
print([f() for f in functions])  # [0, 1, 2, 3, 4]

Practical Closure Uses

def make_counter(start=0):
    count = [start]
    def counter():
        count[0] += 1
        return count[0]
    return counter

c = make_counter()
print(c())  # 1
print(c())  # 2

def make_cache():
    cache = {}
    def cached_func(key, value=None):
        if value is not None:
            cache[key] = value
        return cache.get(key)
    return cached_func

settings = make_cache()
settings("theme", "dark")
print(settings("theme"))  # dark

Lambda Functions

Lambda functions are anonymous, single-expression functions:

square = lambda x: x ** 2
print(square(5))  # 25

When to Use Lambdas

Best as arguments to higher-order functions:

students = [("Alice", 90), ("Bob", 80), ("Charlie", 95)]
students.sort(key=lambda student: student[1])
print(students)  # [('Bob', 80), ('Alice', 90), ('Charlie', 95)]

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4, 6, 8]

Limitations

  • Only a single expression (no statements)
  • No docstrings or type hints
  • Harder to debug

Higher-Order Functions

A higher-order function takes a function as argument or returns a function.

Built-in Higher-Order Functions

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

doubled = list(map(lambda x: x * 2, numbers))
print(doubled)  # [2, 4, 6, 8, 10]

evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4]

words = ["banana", "apple", "cherry"]
print(sorted(words, key=len))  # ['apple', 'banana', 'cherry']

Decorators — Higher-Order Functions in Practice

A decorator wraps a function to add behavior:

import time

def timer(func):
    """Measure execution time of a function."""
    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

@timer
def slow_function():
    time.sleep(1)
    return "Done"

result = slow_function()
# slow_function took 1.0012 seconds

Recursion

A function that calls itself is recursive. It requires a base case to stop.

Base Case and Recursive Case

def factorial(n):
    if n <= 1:       # Base case
        return 1
    return n * factorial(n - 1)  # Recursive case

print(factorial(5))  # 120
factorial(5)
  → 5 * factorial(4)
    → 4 * factorial(3)
      → 3 * factorial(2)
        → 2 * factorial(1)
          → 1  (base case)
        → 2
      → 6
    → 24
  → 120

Fibonacci Sequence

def fibonacci(n):
    if n <= 0:
        return 0
    if n == 1:
        return 1
    return fibonacci(n - 1) + fibonacci(n - 2)

for i in range(10):
    print(fibonacci(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34

Recursion Limit

import sys
print(sys.getrecursionlimit())  # 1000 (default)

When to Use Recursion vs Iteration

Use Recursion WhenUse Iteration When
Problem has recursive structure (trees, graphs)Simple loops over sequences
Depth is guaranteed smallDepth could be large
Code clarity matters mostPerformance is critical

Docstrings and Type Hints

Writing Good Docstrings

def calculate_statistics(data):
    """Calculate basic statistics for a dataset.

    Args:
        data: A list of numerical values. Must not be empty.

    Returns:
        A dictionary with keys 'mean', 'median', and 'stdev'.

    Raises:
        ValueError: If data is empty.
    """
    if not data:
        raise ValueError("Data must not be empty")
    n = len(data)
    mean = sum(data) / n
    sorted_data = sorted(data)
    median = sorted_data[n // 2]
    variance = sum((x - mean) ** 2 for x in data) / n
    stdev = variance ** 0.5
    return {"mean": mean, "median": median, "stdev": stdev}

Type Annotations

def greet(name: str) -> str:
    return f"Hello, {name}!"

def add_numbers(a: float, b: float) -> float:
    return a + b

from typing import Optional

def find_item(items: list[str], target: str) -> Optional[str]:
    for item in items:
        if item == target:
            return item
    return None

Common Mistakes

1. Mutable Default Argument

# WRONG
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))  # [1]
print(append_to(2))  # [1, 2] — unexpected!

# CORRECT
def append_to(item, target=None):
    if target is None:
        target = []
    target.append(item)
    return target

2. Modifying Global Without global

x = 10

def change_x():
    x = 20  # Creates local variable, doesn't modify global

change_x()
print(x)  # Still 10!

3. Forgetting to Return

def calculate_sum(a, b):
    total = a + b
    # Missing return!

print(calculate_sum(3, 4))  # None — not 7!

4. Late Binding in Closures

# WRONG
functions = [lambda: i for i in range(5)]
print([f() for f in functions])  # [4, 4, 4, 4, 4]

# CORRECT
functions = [lambda i=i: i for i in range(5)]
print([f() for f in functions])  # [0, 1, 2, 3, 4]

5. Too Many Parameters

# BAD
def create_user(name, age, email, phone, address, city, state, zip_code, country):
    pass

# BETTER
def create_user(**kwargs):
    required = {"name", "email"}
    if not required.issubset(kwargs.keys()):
        raise ValueError(f"Missing required: {required - kwargs.keys()}")
    return kwargs

Practice Exercises

Exercise 1: Function Composition

Write a function compose that takes two functions and returns a new function that applies them in sequence.

def compose(f, g):
    """Return a function that applies g then f."""
    def composed(x):
        return f(g(x))
    return composed

double = lambda x: x * 2
add_one = lambda x: x + 1

double_then_add = compose(add_one, double)
print(double_then_add(3))  # 7 (3 * 2 + 1)

add_then_double = compose(double, add_one)
print(add_then_double(3))  # 8 ((3 + 1) * 2)

Exercise 2: Memoization Decorator

Write a decorator memoize that caches function results.

import functools

def memoize(func):
    """Cache results of function calls."""
    cache = {}

    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]

    wrapper.cache = cache
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(30))   # 832040
print(fibonacci.cache) # {0: 0, 1: 1, 2: 1, ...}

Exercise 3: Curry Function

Write a function curry that transforms a multi-argument function into a chain of single-argument calls.

import inspect

def curry(func):
    """Curry a function — convert f(a, b, c) to f(a)(b)(c)."""
    params = inspect.signature(func).parameters

    def curried(*args):
        if len(args) >= len(params):
            return func(*args)
        def partial(*more_args):
            return curried(*(args + more_args))
        return partial

    return curried

@curry
def add(a, b, c):
    return a + b + c

print(add(1)(2)(3))   # 6
print(add(1, 2)(3))   # 6
print(add(1)(2, 3))   # 6

Key Takeaways

  1. Functions are reusable blocks of code that encapsulate logic
  2. Parameters define what a function accepts; arguments are actual values passed
  3. Default arguments make parameters optional; always use None for mutable defaults
  4. *args and **kwargs handle variable numbers of arguments flexibly
  5. Return values can be any type; multiple values are returned as tuples
  6. Scope follows the LEGB rule: Local → Enclosing → Global → Built-in
  7. Closures remember variables from enclosing scope — watch out for late binding
  8. Functions are first-class objects — assign them, pass them, return them
  9. Decorators are higher-order functions for adding cross-cutting concerns
  10. Recursion needs a base case; Python doesn't optimize tail calls
  11. Type hints and docstrings make functions self-documenting

What's Next

→ Lambda Functions and Functional Programming → Decorators Deep Dive → Error Handling with try/except → Object-Oriented Programming

Advertisement

Need Expert Python Help?

Get personalized tutoring, project support, or professional consulting.

Advertisement