Python Functions & Modules

TL;DR

Functions are reusable blocks of code. Modules organize functions into files. Packages bundle modules. Master def, *args/**kwargs, lambda, decorators, and how to structure a Python project.

Explain Like I'm 12

A function is like a recipe. You give it ingredients (arguments), it follows steps, and gives you back a dish (return value). You write the recipe once and use it anytime.

A module is like a recipe book — a file full of related functions. A package is a shelf of recipe books (a folder of modules). Python comes with a huge library of pre-written recipe books, and you can install more with pip.

Functions, Modules & Packages

Python functions, modules, and packages hierarchy: functions inside modules inside packages

Function Basics

Functions encapsulate logic into reusable, testable blocks. Every function should do one thing well.

Key insight: In Python, functions are first-class objects. You can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures.

Defining and Calling

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

message = greet("Alice")  # "Hello, Alice!"

# Function with no return (returns None)
def log(msg):
    print(f"[LOG] {msg}")

# Multiple return values (returns a tuple)
def divide(a, b):
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide(17, 5)  # q=3, r=2

Docstrings

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

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

    Returns:
        BMI as a float, rounded to 1 decimal place.

    Raises:
        ValueError: If height is zero or negative.
    """
    if height_m <= 0:
        raise ValueError("Height must be positive")
    return round(weight_kg / (height_m ** 2), 1)

# Access the docstring
help(calculate_bmi)
calculate_bmi.__doc__
Pro tip: Always write docstrings for public functions. Use the Google or NumPy docstring style for consistency. Your IDE and tools like Sphinx use them to generate documentation.

Parameters & Arguments

Python has five kinds of parameters, giving you full control over how functions accept input.

Positional and Keyword

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

power(2, 10)                  # Positional: 1024
power(exponent=10, base=2)    # Keyword: 1024

# Default values
def connect(host, port=5432, ssl=True):
    print(f"Connecting to {host}:{port} (SSL={ssl})")

connect("db.example.com")              # Uses defaults
connect("db.example.com", port=3306)   # Override port

*args and **kwargs

# *args — captures extra positional arguments as a tuple
def add(*nums):
    return sum(nums)

add(1, 2, 3)          # 6
add(10, 20, 30, 40)   # 100

# **kwargs — captures extra keyword arguments as a dict
def build_profile(**kwargs):
    return kwargs

build_profile(name="Alice", age=30, role="engineer")
# {'name': 'Alice', 'age': 30, 'role': 'engineer'}

# Combining all parameter types (order matters!)
def func(pos, /, normal, *, kw_only, **kwargs):
    pass
# pos: positional-only (Python 3.8+)
# normal: positional or keyword
# kw_only: keyword-only (after *)
# **kwargs: catch-all keyword args
Mutable default trap: Never use a mutable object as a default argument. def add_item(item, lst=[]) shares the same list across all calls. Use None as default and create inside the function.
# WRONG — shared mutable default
def add_item(item, lst=[]):
    lst.append(item)
    return lst

add_item("a")  # ['a']
add_item("b")  # ['a', 'b'] — BUG! Same list reused

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

Lambda Functions

Anonymous, single-expression functions. Useful for short callbacks and sort keys.

Key insight: Lambdas are syntactic sugar for simple functions. They can only contain a single expression (no statements, no assignments). If you need more than one line, use def.
# Syntax: lambda arguments: expression
square = lambda x: x ** 2
square(5)  # 25

# Common use: sort key
users = [("Alice", 30), ("Bob", 25), ("Charlie", 35)]
users.sort(key=lambda u: u[1])  # Sort by age
# [('Bob', 25), ('Alice', 30), ('Charlie', 35)]

# Common use: filter
nums = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda x: x % 2 == 0, nums))  # [2, 4, 6]

# Common use: map
doubled = list(map(lambda x: x * 2, nums))  # [2, 4, 6, 8, 10, 12]
Style guide: PEP 8 discourages assigning lambdas to variables (square = lambda x: x**2). Use a regular def instead — it's more readable and gives the function a proper name in tracebacks.

Scope: The LEGB Rule

Python resolves variable names in this order: Local → Enclosing → Global → Built-in.

x = "global"             # Global scope

def outer():
    x = "enclosing"       # Enclosing scope

    def inner():
        x = "local"       # Local scope
        print(x)          # "local"

    inner()
    print(x)              # "enclosing"

outer()
print(x)                  # "global"

global and nonlocal

# global — modify a global variable from inside a function
counter = 0

def increment():
    global counter
    counter += 1

increment()
print(counter)  # 1

# nonlocal — modify an enclosing scope variable
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = make_counter()
counter()  # 1
counter()  # 2
counter()  # 3
Avoid global in production code. Global state makes functions unpredictable and hard to test. Pass values as arguments and return results instead. Use nonlocal sparingly — closures are the right pattern when you need it.

Decorators

Decorators wrap a function to add behavior before/after it runs. They're the most powerful Python pattern for cross-cutting concerns like logging, caching, and auth.

Key insight: A decorator is just a function that takes a function and returns a new function. The @decorator syntax is sugar for func = decorator(func).

Basic Decorator

import functools
import time

def timer(func):
    """Log how long a function takes to run."""
    @functools.wraps(func)  # Preserve original name/docstring
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

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

slow_function()  # Prints: "slow_function took 1.0012s"

Decorator with Arguments

def retry(max_attempts=3):
    """Retry a function up to max_attempts times on exception."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying...")
        return wrapper
    return decorator

@retry(max_attempts=5)
def fetch_data(url):
    # Might fail due to network issues
    pass

Practical Decorators

# Cache decorator (memoization)
from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(100)  # Instant — cached

# Built-in decorators
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        """Access like an attribute: circle.area"""
        return 3.14159 * self._radius ** 2

    @staticmethod
    def is_valid_radius(r):
        """No access to instance or class."""
        return r > 0

    @classmethod
    def from_diameter(cls, d):
        """Alternative constructor."""
        return cls(d / 2)
Always use @functools.wraps(func) in your decorators. Without it, the decorated function loses its original __name__, __doc__, and other metadata, which breaks debugging and documentation tools.

Modules & Imports

A module is any .py file. Importing a module gives you access to its functions, classes, and variables.

Key insight: When you import a module, Python executes the file top to bottom. The if __name__ == "__main__" guard prevents code from running when the file is imported (vs run directly).

Import Styles

# Import the module
import math
math.sqrt(16)            # 4.0

# Import specific names
from math import sqrt, pi
sqrt(16)                 # 4.0

# Import with alias
import pandas as pd
import numpy as np

# Import all (avoid — pollutes namespace)
from math import *       # Bad practice

The __name__ Guard

# utils.py
def add(a, b):
    return a + b

def main():
    print(add(2, 3))

# Only runs when executed directly: python utils.py
# Does NOT run when imported: from utils import add
if __name__ == "__main__":
    main()
Import order (PEP 8): (1) Standard library imports, (2) third-party imports, (3) local imports. Separate each group with a blank line. Tools like isort automate this.

Packages & Project Structure

A package is a directory of modules with an __init__.py file. Packages organize large projects into logical groups.

Package Structure

# Typical project layout
my_project/
    pyproject.toml          # Project metadata & dependencies
    README.md
    src/
        my_package/
            __init__.py     # Makes it a package (can be empty)
            core.py         # Main logic
            utils.py        # Helper functions
            models/
                __init__.py
                user.py
                order.py
    tests/
        test_core.py
        test_utils.py

Installing Packages

# Install from PyPI
pip install requests
pip install pandas numpy matplotlib

# Install from requirements file
pip install -r requirements.txt

# List installed packages
pip list
pip freeze > requirements.txt  # Save current packages

Virtual Environments

Isolate project dependencies. Each project gets its own copy of Python packages, preventing version conflicts.

Never install packages globally. Different projects need different versions of the same library. Virtual environments keep them isolated. Always create one per project.
# Create a virtual environment
python3 -m venv .venv

# Activate it
source .venv/bin/activate      # macOS/Linux
.venv\Scripts\activate         # Windows

# Install packages (now isolated)
pip install requests flask

# Deactivate when done
deactivate

Modern Alternatives

# uv — ultra-fast package manager (Rust-based)
uv venv                        # Create venv
uv pip install requests        # 10-100x faster than pip

# poetry — dependency management + packaging
poetry init
poetry add requests
poetry install

# pipenv — Pipfile-based workflow
pipenv install requests
pipenv shell
Recommended in 2026: Use uv for speed. It's a drop-in replacement for pip and venv, written in Rust, and is now the fastest Python package manager by a large margin.

Project Structure Best Practices

# Production-ready layout
my_api/
    pyproject.toml            # Single config file (replaces setup.py)
    .gitignore
    .env                      # Secrets (NEVER commit)
    src/
        my_api/
            __init__.py
            app.py            # Entry point
            config.py         # Settings from env vars
            routes/
                __init__.py
                users.py
                orders.py
            services/
                __init__.py
                auth.py
                email.py
            models/
                __init__.py
                user.py
            utils/
                __init__.py
                validators.py
    tests/
        conftest.py           # Shared test fixtures
        test_users.py
        test_orders.py
    docs/
        api.md
Key principles: (1) Use src/ layout to prevent accidental imports from the project root. (2) Group by feature (routes, services, models), not by type. (3) Keep __init__.py files minimal — import only public APIs. (4) Use pyproject.toml as the single config file.

Test Yourself

Q: What is the difference between *args and **kwargs?

*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict. Together, they let a function accept any number and type of arguments: def func(*args, **kwargs).

Q: What does the LEGB rule stand for?

Local, Enclosing, Global, Built-in. This is the order Python searches for variable names. Local is inside the current function. Enclosing is inside any outer function (closures). Global is at the module level. Built-in is Python's own names like print, len.

Q: Why should you never use a mutable default argument like def f(lst=[])?

Default arguments are evaluated once at function definition time, not at each call. A mutable default (like a list or dict) is shared across all calls. Mutations from one call persist to the next. Use None as default and create the mutable object inside the function body.

Q: What does @functools.wraps(func) do in a decorator?

It copies the original function's __name__, __doc__, __module__, and other metadata to the wrapper function. Without it, the decorated function appears to have the wrapper's name and docstring, which breaks help(), logging, and debugging.

Q: What is the purpose of if __name__ == "__main__"?

It guards code that should only run when the file is executed directly (python my_file.py), not when imported as a module. Python sets __name__ to "__main__" for the entry script and to the module name for imported files.

Interview Questions

Q: Explain closures in Python. Give an example.

A closure is a function that remembers variables from its enclosing scope, even after that scope has finished executing.
def make_multiplier(n):
    def multiplier(x):
        return x * n  # 'n' is captured from enclosing scope
    return multiplier

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

The inner function multiplier "closes over" the variable n. Even after make_multiplier returns, double still has access to n=2. Closures are the foundation of decorators, callbacks, and factory functions.

Q: What is the difference between a decorator and a regular function wrapper?

They're the same thing. A decorator is syntactic sugar. @my_decorator above a function is equivalent to func = my_decorator(func). The @ syntax just makes it clearer that the function is being wrapped. Under the hood, a decorator is a higher-order function that takes a function and returns a new function (usually with added behavior).

Q: What is a generator? How does it differ from a regular function?

A generator uses yield instead of return. It produces values lazily — one at a time — instead of computing all results upfront and storing them in memory.
def count_up(limit):
    n = 0
    while n < limit:
        yield n    # Pauses here, resumes on next()
        n += 1

for num in count_up(1000000):
    if num > 5:
        break

Key differences: (1) Memory efficient — only one value in memory at a time. (2) Lazy — values computed on demand. (3) Can represent infinite sequences. (4) State is preserved between yield calls.

Q: Explain the difference between import module and from module import func. When would you use each?

import module imports the whole module — you access names via module.func(). This avoids name collisions and makes the source clear. from module import func imports func directly into your namespace. Use import module when the module name is short or you use many of its names. Use from ... import when you use one or two names frequently. Never use from module import * — it pollutes the namespace and makes it unclear where names come from.