Python – Modules & Packages

As your Python projects grow beyond a single script, you need a way to organize code into logical, reusable units. Copy-pasting functions between files is a maintenance disaster waiting to happen. This is where modules and packages come in — they are Python’s answer to code organization, reusability, and namespace management. Every serious Python project relies on them, and understanding how they work is essential for writing professional-grade software.

In this tutorial, we will cover everything from basic imports to creating your own distributable packages, managing dependencies with virtual environments, and avoiding the common pitfalls that trip up even experienced developers.

What is a Module?

A module is simply a .py file containing Python definitions — functions, classes, variables, and executable statements. The file name (minus the .py extension) becomes the module name. If you have a file called math_utils.py, you have a module called math_utils. That is it — there is no special registration step or configuration required.

Every Python file you have ever written is already a module. The only difference between a “script” and a “module” is how you use it: a script is executed directly, while a module is imported by other code.

# math_utils.py - this file IS a module

PI = 3.14159265358979

def circle_area(radius):
    """Calculate the area of a circle."""
    return PI * radius ** 2

def rectangle_area(length, width):
    """Calculate the area of a rectangle."""
    return length * width

def fahrenheit_to_celsius(f):
    """Convert Fahrenheit to Celsius."""
    return (f - 32) * 5 / 9

Now any other Python file can import and use these definitions without rewriting them.

Importing Modules

Python provides several ways to import modules, each with different trade-offs in terms of readability, namespace pollution, and convenience.

The import Statement

The most straightforward way to import a module. You access its contents using dot notation, which keeps it clear where each name comes from.

import math_utils

area = math_utils.circle_area(5)
print(f"Circle area: {area}")  # Circle area: 78.53981633974483

temp = math_utils.fahrenheit_to_celsius(212)
print(f"212°F = {temp}°C")  # 212°F = 100.0°C

The from…import Statement

When you only need specific items from a module, use from...import. This brings the names directly into your namespace so you do not need the module prefix.

from math_utils import circle_area, fahrenheit_to_celsius

area = circle_area(5)
temp = fahrenheit_to_celsius(100)

print(f"Area: {area}")   # Area: 78.53981633974483
print(f"Temp: {temp}")   # Temp: 37.77777777777778

Aliasing with as

You can rename a module or an imported name using as. This is useful when module names are long or when you want to avoid name collisions.

# Alias a module
import math_utils as mu

area = mu.circle_area(10)

# Alias a specific import
from math_utils import fahrenheit_to_celsius as f2c

temp = f2c(98.6)
print(f"Body temp: {temp:.1f}°C")  # Body temp: 37.0°C

You will see this convention everywhere in the Python ecosystem: import numpy as np, import pandas as pd, import matplotlib.pyplot as plt. These aliases are so standard that using different ones will confuse other developers reading your code.

The Wildcard import *

You can import everything from a module with from module import *. This pulls all public names (those not starting with an underscore) into your namespace.

from math_utils import *

# Now circle_area, rectangle_area, fahrenheit_to_celsius, and PI
# are all available directly
print(circle_area(3))     # 28.274333882308138
print(PI)                 # 3.14159265358979

Avoid wildcard imports in production code. They pollute your namespace, make it impossible to tell where a name came from, and can silently overwrite existing names. The only acceptable use case is in interactive sessions or the Python REPL for quick exploration.

The Module Search Path

When you write import math_utils, Python needs to find math_utils.py somewhere on disk. It searches the following locations, in order:

  1. The directory containing the script that was executed (or the current directory in an interactive session)
  2. Directories listed in the PYTHONPATH environment variable (if set)
  3. The installation-dependent default directories (site-packages, standard library)

You can inspect and modify this search path at runtime via sys.path.

import sys

# Print the module search path
for path in sys.path:
    print(path)

# Add a custom directory to the search path at runtime
sys.path.append("/home/folau/my_custom_libs")

Modifying sys.path at runtime is a quick fix, but not a best practice. For production code, install your modules properly as packages or use PYTHONPATH environment variable configuration.

Creating Your Own Module

Let us build a practical module step by step. Create a file called string_helpers.py.

# string_helpers.py

def slugify(text):
    """Convert a string to a URL-friendly slug."""
    import re
    text = text.lower().strip()
    text = re.sub(r'[^\w\s-]', '', text)
    text = re.sub(r'[\s_]+', '-', text)
    text = re.sub(r'-+', '-', text)
    return text

def truncate(text, max_length=100, suffix="..."):
    """Truncate text to max_length, adding suffix if truncated."""
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)].rsplit(' ', 1)[0] + suffix

def title_case(text):
    """Convert text to title case, handling common prepositions."""
    small_words = {'a', 'an', 'the', 'and', 'but', 'or', 'for', 'nor',
                   'on', 'at', 'to', 'by', 'in', 'of', 'up'}
    words = text.split()
    result = []
    for i, word in enumerate(words):
        if i == 0 or word.lower() not in small_words:
            result.append(word.capitalize())
        else:
            result.append(word.lower())
    return ' '.join(result)

def count_words(text):
    """Count the number of words in a string."""
    return len(text.split())


# This block runs ONLY when the file is executed directly,
# NOT when it is imported as a module
if __name__ == "__main__":
    print("Testing string_helpers module:")
    print(slugify("Hello World! This is a Test"))   # hello-world-this-is-a-test
    print(truncate("This is a very long string that should be truncated", 30))
    print(title_case("the quick brown fox jumps over the lazy dog"))
    print(count_words("Python modules are powerful"))  # 4

The __name__ == "__main__" Pattern

This is one of Python's most important idioms. Every module has a built-in __name__ attribute. When a file is run directly (e.g., python string_helpers.py), __name__ is set to "__main__". When the file is imported as a module, __name__ is set to the module's name (e.g., "string_helpers").

This pattern lets you include test code or a CLI interface in the same file as your module without it running on import.

# Using the module in another file
from string_helpers import slugify, truncate

title = "Python Modules & Packages: A Complete Guide"
slug = slugify(title)
print(slug)  # python-modules-packages-a-complete-guide

summary = truncate("This comprehensive tutorial covers everything you need...", 40)
print(summary)  # This comprehensive tutorial covers...

Packages

A package is a directory that contains Python modules and a special __init__.py file. Packages let you organize related modules into a hierarchical directory structure — think of them as "folders of modules."

Basic Package Structure

myproject/
├── utils/
│   ├── __init__.py
│   ├── string_helpers.py
│   ├── math_helpers.py
│   └── file_helpers.py
├── models/
│   ├── __init__.py
│   ├── user.py
│   └── product.py
└── main.py

The __init__.py file tells Python that the directory should be treated as a package. It can be empty, or it can contain initialization code and define what gets exported when someone uses from package import *.

The __init__.py File

# utils/__init__.py

# You can import commonly used items here for convenience
from .string_helpers import slugify, truncate
from .math_helpers import circle_area

# Define what 'from utils import *' exports
__all__ = ['slugify', 'truncate', 'circle_area']

# Package-level constants
VERSION = "1.0.0"

With this __init__.py, users of your package get a cleaner import experience.

# Without __init__.py convenience imports:
from utils.string_helpers import slugify

# With __init__.py convenience imports:
from utils import slugify  # much cleaner

Importing from Packages

# Import a specific module from a package
from utils import string_helpers
string_helpers.slugify("Hello World")

# Import a specific function from a module in a package
from utils.string_helpers import slugify
slugify("Hello World")

# Import the package itself (uses __init__.py)
import utils
utils.slugify("Hello World")  # only works if __init__.py exports it

Sub-packages

Packages can contain other packages, creating a hierarchy as deep as you need.

myproject/
├── services/
│   ├── __init__.py
│   ├── auth/
│   │   ├── __init__.py
│   │   ├── jwt_handler.py
│   │   └── oauth.py
│   └── payments/
│       ├── __init__.py
│       ├── stripe_client.py
│       └── paypal_client.py
└── main.py
# Importing from sub-packages
from services.auth.jwt_handler import create_token
from services.payments.stripe_client import charge_customer

Relative Imports

Inside a package, you can use relative imports to reference sibling modules. A single dot (.) refers to the current package, two dots (..) to the parent package.

# Inside services/auth/jwt_handler.py

# Relative import from the same package (auth)
from .oauth import get_oauth_token

# Relative import from the parent package (services)
from ..payments.stripe_client import charge_customer

Important: Relative imports only work inside packages. They will fail if you try to run the file directly as a script. Always prefer absolute imports unless you have a strong reason to use relative ones.

The Standard Library

Python ships with an extensive standard library — often described as "batteries included." Here are the modules you will reach for most often in real-world projects.

os — Operating System Interface

import os

# Get environment variables
db_host = os.environ.get("DB_HOST", "localhost")
debug = os.environ.get("DEBUG", "false")

# Work with file paths (prefer pathlib for new code)
current_dir = os.getcwd()
home_dir = os.path.expanduser("~")
full_path = os.path.join(current_dir, "data", "output.csv")

# Check if files/directories exist
print(os.path.exists("/tmp/myfile.txt"))
print(os.path.isdir("/tmp"))

# Create directories
os.makedirs("output/reports", exist_ok=True)

# List directory contents
files = os.listdir(".")
print(files)

sys — System-Specific Parameters

import sys

# Python version info
print(sys.version)        # 3.12.0 (main, Oct 2 2023, ...)
print(sys.version_info)   # sys.version_info(major=3, minor=12, ...)

# Command-line arguments
print(sys.argv)  # ['script.py', 'arg1', 'arg2']

# Module search path
print(sys.path)

# Exit the program with a status code
# sys.exit(0)  # 0 = success, non-zero = error

# Platform information
print(sys.platform)   # 'darwin', 'linux', 'win32'
print(sys.maxsize)    # Maximum integer size

json — JSON Encoding and Decoding

import json

# Python dict to JSON string
user = {"name": "Folau", "age": 30, "skills": ["Python", "Java", "SQL"]}
json_string = json.dumps(user, indent=2)
print(json_string)

# JSON string to Python dict
data = json.loads('{"status": "active", "count": 42}')
print(data["status"])  # active

# Read JSON from a file
with open("config.json", "r") as f:
    config = json.load(f)

# Write JSON to a file
with open("output.json", "w") as f:
    json.dump(user, f, indent=2)

datetime — Date and Time

from datetime import datetime, timedelta, date

# Current date and time
now = datetime.now()
print(now)  # 2026-02-26 10:30:45.123456

# Formatting dates
formatted = now.strftime("%Y-%m-%d %H:%M:%S")
print(formatted)  # 2026-02-26 10:30:45

# Parsing date strings
parsed = datetime.strptime("2026-02-26", "%Y-%m-%d")
print(parsed)  # 2026-02-26 00:00:00

# Date arithmetic
tomorrow = date.today() + timedelta(days=1)
next_week = datetime.now() + timedelta(weeks=1)
thirty_days_ago = datetime.now() - timedelta(days=30)

# Difference between dates
deadline = datetime(2026, 12, 31)
remaining = deadline - datetime.now()
print(f"Days remaining: {remaining.days}")

math — Mathematical Functions

import math

print(math.pi)          # 3.141592653589793
print(math.e)           # 2.718281828459045
print(math.sqrt(144))   # 12.0
print(math.ceil(4.2))   # 5
print(math.floor(4.8))  # 4
print(math.log(100, 10))  # 2.0
print(math.factorial(5))  # 120
print(math.gcd(48, 18))   # 6

random — Random Number Generation

import random

# Random integer in range [1, 100]
print(random.randint(1, 100))

# Random float in [0.0, 1.0)
print(random.random())

# Random choice from a sequence
colors = ["red", "green", "blue", "yellow"]
print(random.choice(colors))

# Shuffle a list in place
cards = list(range(1, 53))
random.shuffle(cards)
print(cards[:5])  # first 5 cards after shuffle

# Sample without replacement
lottery = random.sample(range(1, 50), 6)
print(sorted(lottery))

pathlib — Modern File Path Handling

from pathlib import Path

# Create Path objects
home = Path.home()
project = Path("/home/folau/projects/myapp")
config_file = project / "config" / "settings.json"

print(config_file)          # /home/folau/projects/myapp/config/settings.json
print(config_file.name)     # settings.json
print(config_file.stem)     # settings
print(config_file.suffix)   # .json
print(config_file.parent)   # /home/folau/projects/myapp/config

# Check existence
print(project.exists())
print(config_file.is_file())

# Create directories
output_dir = project / "output"
output_dir.mkdir(parents=True, exist_ok=True)

# Read and write files
readme = project / "README.md"
# readme.write_text("# My Project\n")
# content = readme.read_text()

# Glob for file patterns
python_files = list(project.glob("**/*.py"))
print(f"Found {len(python_files)} Python files")

collections — Specialized Container Types

from collections import Counter, defaultdict, namedtuple, deque

# Counter - count occurrences
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
word_counts = Counter(words)
print(word_counts)                # Counter({'apple': 3, 'banana': 2, 'cherry': 1})
print(word_counts.most_common(2)) # [('apple', 3), ('banana', 2)]

# defaultdict - dict with default values for missing keys
grouped = defaultdict(list)
students = [("math", "Alice"), ("science", "Bob"), ("math", "Charlie")]
for subject, student in students:
    grouped[subject].append(student)
print(dict(grouped))  # {'math': ['Alice', 'Charlie'], 'science': ['Bob']}

# namedtuple - lightweight immutable objects
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(f"x={p.x}, y={p.y}")  # x=3, y=4

# deque - double-ended queue with O(1) appends/pops on both ends
queue = deque(["first", "second", "third"])
queue.append("fourth")       # add to right
queue.appendleft("zeroth")   # add to left
print(queue.popleft())       # zeroth - remove from left

itertools — Iterator Building Blocks

import itertools

# chain - combine multiple iterables
combined = list(itertools.chain([1, 2], [3, 4], [5, 6]))
print(combined)  # [1, 2, 3, 4, 5, 6]

# product - cartesian product
sizes = ["S", "M", "L"]
colors = ["red", "blue"]
combos = list(itertools.product(sizes, colors))
print(combos)  # [('S', 'red'), ('S', 'blue'), ('M', 'red'), ...]

# groupby - group consecutive elements
data = [("A", 1), ("A", 2), ("B", 3), ("B", 4), ("A", 5)]
data.sort(key=lambda x: x[0])  # must be sorted first
for key, group in itertools.groupby(data, key=lambda x: x[0]):
    print(f"{key}: {list(group)}")

# islice - slice an iterator
first_five = list(itertools.islice(range(100), 5))
print(first_five)  # [0, 1, 2, 3, 4]

functools — Higher-Order Functions

from functools import lru_cache, partial, reduce

# lru_cache - memoize expensive function calls
@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))  # 12586269025 - computed instantly with caching

# partial - create a new function with some arguments pre-filled
def power(base, exponent):
    return base ** exponent

square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5))  # 25
print(cube(3))    # 27

# reduce - apply a function cumulatively to a sequence
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda a, b: a * b, numbers)
print(product)  # 120

Installing Third-Party Packages

While the standard library is extensive, real-world projects almost always need third-party packages. Python's package installer, pip, downloads and installs packages from the Python Package Index (PyPI).

Basic pip Usage

# Install a package
pip install requests

# Install a specific version
pip install requests==2.31.0

# Install minimum version
pip install "requests>=2.28.0"

# Upgrade a package
pip install --upgrade requests

# Uninstall a package
pip uninstall requests

# Show installed package info
pip show requests

# List all installed packages
pip list

requirements.txt

A requirements.txt file lists all the packages your project depends on, one per line. This is the standard way to share dependencies so anyone can recreate your environment.

# requirements.txt
requests==2.31.0
flask==3.0.0
sqlalchemy==2.0.23
pytest==7.4.3
python-dotenv==1.0.0
# Install all dependencies from requirements.txt
pip install -r requirements.txt

# Generate requirements.txt from currently installed packages
pip freeze > requirements.txt

Warning: Running pip freeze dumps every installed package, including transitive dependencies. For a cleaner approach, manually maintain your requirements.txt with only your direct dependencies and use tools like pip-compile (from pip-tools) to resolve the full dependency tree.

Virtual Environments

A virtual environment is an isolated Python environment with its own set of installed packages. Without virtual environments, all your projects share the same global Python installation, which leads to version conflicts: Project A needs requests==2.28, but Project B needs requests==2.31. Virtual environments solve this completely.

Creating and Using Virtual Environments

# Create a virtual environment named 'venv'
python3 -m venv venv

# Activate it (macOS / Linux)
source venv/bin/activate

# Activate it (Windows)
venv\Scripts\activate

# Your prompt changes to show the active environment
# (venv) $

# Now pip installs packages into the virtual environment only
pip install requests flask

# Verify isolation - packages are installed in the venv
pip list

# Deactivate when done
deactivate

Why Virtual Environments Matter

  • Dependency isolation: Each project gets its own set of packages at specific versions, preventing conflicts.
  • Reproducibility: Combined with requirements.txt, anyone can recreate the exact same environment.
  • Clean system Python: Your system Python installation stays clean and uncluttered.
  • Safe experimentation: You can install and test packages without affecting other projects.

Add venv to .gitignore

Never commit your virtual environment directory to version control. It contains platform-specific binaries and can be hundreds of megabytes. Instead, commit requirements.txt and let each developer create their own virtual environment.

# .gitignore
venv/
.venv/
env/
__pycache__/
*.pyc
.env

Package Management Best Practices

Pin Your Dependencies

Always specify exact versions in your requirements.txt for production deployments. Unpinned dependencies can break your application when a new version introduces a breaking change.

# BAD - unpinned, any version could be installed
requests
flask

# GOOD - pinned to exact versions
requests==2.31.0
flask==3.0.0

# ACCEPTABLE - minimum version constraints for libraries
requests>=2.28.0,<3.0.0
flask>=3.0.0,<4.0.0

Separate Development Dependencies

Keep your production and development dependencies separate. You do not need pytest or black on your production server.

# requirements.txt - production dependencies
requests==2.31.0
flask==3.0.0
sqlalchemy==2.0.23
gunicorn==21.2.0
# requirements-dev.txt - development dependencies
-r requirements.txt
pytest==7.4.3
black==23.12.0
flake8==6.1.0
mypy==1.7.1
# Install dev dependencies (includes production deps via -r)
pip install -r requirements-dev.txt

Using pip-compile for Dependency Resolution

The pip-tools package provides pip-compile, which resolves your dependencies and their transitive dependencies into a fully pinned requirements.txt.

# Install pip-tools
pip install pip-tools

# Create a requirements.in with your direct dependencies
# requirements.in
# flask
# sqlalchemy
# requests

# Compile to a fully resolved requirements.txt
pip-compile requirements.in

# The output requirements.txt will include all transitive
# dependencies with pinned versions and hash checking

Creating a Distributable Package

When you want to share your code as a reusable package that others can install with pip, you need a proper project structure with packaging metadata.

Modern Package Structure

my-awesome-package/
├── pyproject.toml          # Package metadata and build config
├── README.md
├── LICENSE
├── src/
│   └── my_package/
│       ├── __init__.py
│       ├── core.py
│       └── utils.py
├── tests/
│   ├── __init__.py
│   ├── test_core.py
│   └── test_utils.py
└── requirements.txt

pyproject.toml (Modern Approach)

The pyproject.toml file is the modern standard for Python project configuration. It replaces the older setup.py approach.

# pyproject.toml
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend"

[project]
name = "my-awesome-package"
version = "1.0.0"
description = "A short description of the package"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.9"
authors = [
    {name = "Folau Kaveinga", email = "folau@example.com"}
]
dependencies = [
    "requests>=2.28.0",
    "click>=8.0.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=7.0.0",
    "black>=23.0.0",
]

setup.py (Legacy Approach)

You may still encounter setup.py in older projects. It serves the same purpose but uses imperative Python code instead of declarative TOML.

# setup.py
from setuptools import setup, find_packages

setup(
    name="my-awesome-package",
    version="1.0.0",
    packages=find_packages(where="src"),
    package_dir={"": "src"},
    install_requires=[
        "requests>=2.28.0",
        "click>=8.0.0",
    ],
    python_requires=">=3.9",
)

Building and Installing

# Build the package
python -m build

# Install in development mode (editable install)
pip install -e .

# Install with optional dev dependencies
pip install -e ".[dev]"

Practical Examples

Project Structure for a Web App

Here is a realistic structure for a Flask web application that demonstrates proper use of packages and modules.

webapp/
├── pyproject.toml
├── requirements.txt
├── requirements-dev.txt
├── .env
├── .gitignore
├── src/
│   └── webapp/
│       ├── __init__.py           # App factory
│       ├── config.py             # Configuration classes
│       ├── models/
│       │   ├── __init__.py
│       │   ├── user.py
│       │   └── product.py
│       ├── routes/
│       │   ├── __init__.py
│       │   ├── auth.py
│       │   └── api.py
│       ├── services/
│       │   ├── __init__.py
│       │   ├── email_service.py
│       │   └── payment_service.py
│       └── utils/
│           ├── __init__.py
│           ├── validators.py
│           └── formatters.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_models/
│   ├── test_routes/
│   └── test_services/
└── scripts/
    ├── seed_db.py
    └── run_migrations.py
# src/webapp/__init__.py - App factory pattern
from flask import Flask
from .config import Config

def create_app(config_class=Config):
    app = Flask(__name__)
    app.config.from_object(config_class)

    # Register blueprints (route modules)
    from .routes.auth import auth_bp
    from .routes.api import api_bp
    app.register_blueprint(auth_bp, url_prefix="/auth")
    app.register_blueprint(api_bp, url_prefix="/api")

    return app
# src/webapp/config.py
import os
from pathlib import Path
from dotenv import load_dotenv

load_dotenv()

class Config:
    SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
    DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///app.db")
    DEBUG = False

class DevelopmentConfig(Config):
    DEBUG = True

class ProductionConfig(Config):
    DEBUG = False
    SECRET_KEY = os.environ["SECRET_KEY"]  # must be set in production

Creating a Utility Module with Helper Functions

# src/webapp/utils/validators.py
import re
from typing import Optional

def validate_email(email: str) -> bool:
    """Validate email format."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

def validate_password(password: str) -> Optional[str]:
    """
    Validate password strength.
    Returns None if valid, error message if invalid.
    """
    if len(password) < 8:
        return "Password must be at least 8 characters"
    if not re.search(r'[A-Z]', password):
        return "Password must contain at least one uppercase letter"
    if not re.search(r'[a-z]', password):
        return "Password must contain at least one lowercase letter"
    if not re.search(r'\d', password):
        return "Password must contain at least one digit"
    return None

def validate_username(username: str) -> Optional[str]:
    """
    Validate username format.
    Returns None if valid, error message if invalid.
    """
    if len(username) < 3:
        return "Username must be at least 3 characters"
    if len(username) > 30:
        return "Username must be at most 30 characters"
    if not re.match(r'^[a-zA-Z0-9_]+$', username):
        return "Username can only contain letters, numbers, and underscores"
    return None
# src/webapp/utils/__init__.py
from .validators import validate_email, validate_password, validate_username
from .formatters import format_currency, format_date

__all__ = [
    'validate_email',
    'validate_password',
    'validate_username',
    'format_currency',
    'format_date',
]
# Using the utility module elsewhere in the project
from webapp.utils import validate_email, validate_password

email = "folau@example.com"
if validate_email(email):
    print(f"{email} is valid")

password = "MyStr0ngPass!"
error = validate_password(password)
if error:
    print(f"Invalid password: {error}")
else:
    print("Password is strong enough")

Managing Dependencies for a Project

# Step 1: Create and activate virtual environment
python3 -m venv venv
source venv/bin/activate

# Step 2: Install your project dependencies
pip install flask sqlalchemy requests python-dotenv

# Step 3: Install development tools
pip install pytest black flake8 mypy

# Step 4: Freeze production dependencies
pip freeze | grep -i "flask\|sqlalchemy\|requests\|dotenv\|werkzeug\|jinja2\|markupsafe\|click\|itsdangerous\|blinker\|greenlet\|typing-extensions\|certifi\|charset-normalizer\|idna\|urllib3" > requirements.txt

# Step 5: Create dev requirements
echo "-r requirements.txt" > requirements-dev.txt
echo "pytest==7.4.3" >> requirements-dev.txt
echo "black==23.12.0" >> requirements-dev.txt
echo "flake8==6.1.0" >> requirements-dev.txt
echo "mypy==1.7.1" >> requirements-dev.txt

# Step 6: Verify everything works
pip install -r requirements-dev.txt
pytest

Common Pitfalls

1. Circular Imports

Circular imports happen when module A imports module B, and module B imports module A. Python handles this partially, but it often leads to ImportError or AttributeError at runtime.

# models/user.py
from models.order import Order  # imports order module

class User:
    def get_orders(self):
        return Order.find_by_user(self.id)

# models/order.py
from models.user import User  # imports user module - CIRCULAR!

class Order:
    def get_user(self):
        return User.find_by_id(self.user_id)

Solutions:

  • Move the import inside the function that needs it (lazy import).
  • Restructure your code to break the circular dependency — often by creating a third module that both can import from.
  • Use TYPE_CHECKING for type hints that cause circular imports.
# Solution 1: Lazy import inside the function
class Order:
    def get_user(self):
        from models.user import User  # import here, not at the top
        return User.find_by_id(self.user_id)

# Solution 2: Use TYPE_CHECKING for type hints
from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from models.user import User  # only imported during type checking, not at runtime

class Order:
    def get_user(self) -> "User":
        from models.user import User
        return User.find_by_id(self.user_id)

2. Name Shadowing

Creating a file with the same name as a standard library module will shadow it, causing confusing import errors.

# If you have a file named 'random.py' in your project:
import random  # This imports YOUR random.py, NOT the standard library!

random.randint(1, 10)  # AttributeError: module 'random' has no attribute 'randint'

Solution: Never name your files after standard library modules. Common offenders: random.py, email.py, test.py, string.py, collections.py, json.py. If you have already done this, rename your file and delete the corresponding __pycache__ directory.

3. Forgetting __init__.py

In Python 3, directories without __init__.py are treated as "namespace packages" — a feature designed for splitting a single logical package across multiple directories. This is almost never what you want. Without __init__.py, some tools (like pytest, mypy, and IDE auto-importers) may not recognize your directory as a package.

# BAD - missing __init__.py
utils/
├── helpers.py
└── formatters.py

# GOOD - proper package
utils/
├── __init__.py
├── helpers.py
└── formatters.py

4. Relative vs Absolute Import Confusion

Relative imports (from . import module) only work inside packages and fail when you run a file directly as a script.

# This fails:
python src/webapp/routes/auth.py
# ImportError: attempted relative import with no known parent package

# This works - run from the project root as a module:
python -m webapp.routes.auth

Best Practices

1. Prefer absolute imports: They are more readable and work regardless of where the script is run from. Use from webapp.utils import validate_email instead of from ..utils import validate_email.

2. Keep modules small and focused: A module with 2,000 lines of unrelated functions is hard to navigate. Split it into smaller, focused modules grouped by responsibility. A module named validators.py should contain validation logic, not database queries.

3. Use __all__ to define your public API: The __all__ list in a module or __init__.py explicitly declares which names are part of the public API. This controls what gets exported with from module import * and serves as documentation for other developers.

# utils/validators.py
__all__ = ['validate_email', 'validate_password']

def validate_email(email):
    ...

def validate_password(password):
    ...

def _internal_helper():
    """Not exported - underscore prefix signals 'private'."""
    ...

4. Always use virtual environments: Every project should have its own virtual environment. No exceptions. It takes 10 seconds to set up and saves hours of debugging dependency conflicts.

5. Structure imports consistently: Follow PEP 8 import ordering — standard library imports first, then third-party packages, then local imports, with a blank line between each group.

# Standard library
import os
import sys
from datetime import datetime
from pathlib import Path

# Third-party packages
import requests
from flask import Flask, jsonify
from sqlalchemy import create_engine

# Local imports
from webapp.utils import validate_email
from webapp.models.user import User

6. Avoid import side effects: Importing a module should not perform heavy operations like connecting to a database, making HTTP requests, or writing to files. Move such operations into functions that are called explicitly.

7. Document your package structure: For larger projects, include a brief description of each package and module in the project README or in the package's __init__.py docstring.

Key Takeaways

  • A module is any .py file. A package is a directory with an __init__.py file containing modules.
  • Use import module for namespace clarity, from module import name for convenience. Avoid import * in production code.
  • The __name__ == "__main__" guard lets a file serve as both a module and a runnable script.
  • Python's standard library is vast — learn modules like pathlib, json, collections, itertools, and functools to write more Pythonic code.
  • Use pip to install third-party packages and requirements.txt to track dependencies.
  • Virtual environments are non-negotiable for professional Python development — use python -m venv for every project.
  • Pin your dependency versions for reproducible deployments. Separate production and development dependencies.
  • Use pyproject.toml for new packages — it is the modern standard replacing setup.py.
  • Watch out for circular imports, name shadowing, and missing __init__.py files — they are the most common module-related bugs.
  • Follow PEP 8 import ordering: standard library, third-party, local — with blank lines between groups.
  • Keep modules small and focused, use __all__ to define your public API, and prefer absolute imports over relative ones.



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *