Skip to content

Modules and Environments

A module is a .py file; a package is a directory of them. Understanding how Python finds and loads those files — and how to isolate the third-party ones per project — is the difference between a project that works everywhere and one that works on your laptop.

Any .py file is a module. Importing it executes it top to bottom, once, and caches the result in sys.modules.

greetings.py
GREETING = "Hello"
def greet(name):
return f"{GREETING}, {name}!"
print("greetings module loaded")
main.py
import greetings
greetings.greet("Ada") # => 'Hello, Ada!'
greetings.GREETING # => 'Hello'

Running python3 main.py prints greetings module loaded once, no matter how many times other modules import it.

import json # bind the module name
import numpy as np # bind under an alias
from pathlib import Path # bind one name from the module
from pathlib import Path as P
from collections import defaultdict, Counter
from . import sibling # relative: same package
from .models import User # relative: submodule of this package
from ..utils import helper # relative: parent package

All forms execute the entire module. from x import y does not load “less”; it just binds a different name.

mymodule.py
__all__ = ["public_function"] # what `from mymodule import *` exports

__all__ also documents a module’s intended public API, and some tools honour it.

Absolute imports name the full path from a top-level package: from myapp.models import User. Relative imports use leading dots and are resolved from the current module’s package.

PEP 8 prefers absolute imports; they are unambiguous and survive files being moved. Relative imports are reasonable inside a self-contained package where the internal layout is an implementation detail.

If a.py imports b and b.py imports a, one of them will see a half-initialised module and fail with ImportError or AttributeError. Fixes, in order of preference:

  1. Move the shared code into a third module both can import.
  2. Import inside the function that needs it, deferring the load.
  3. Use import a rather than from a import thing — module-level attribute lookup happens later.

A package is a directory Python treats as a module namespace.

myapp/
├── __init__.py
├── main.py
├── models.py
└── utils/
├── __init__.py
└── text.py
import myapp.utils.text
from myapp.utils.text import slugify

__init__.py runs when the package is first imported. It can be empty (marking the directory as a package) or re-export a curated API:

myapp/__init__.py
from .models import User, Account
__version__ = "1.0.0"
__all__ = ["User", "Account"]

That lets consumers write from myapp import User regardless of your internal file layout.

Keep __init__.py cheap. Heavy work there slows every import of the package and creates circular-import risk.

Every module has a __name__. It is "__main__" when the file is run directly, and the module’s dotted name when it is imported.

tool.py
def main():
print("running")
if __name__ == "__main__":
main()
Terminal window
python3 tool.py # prints "running"
import tool # prints nothing

Without the guard, importing the module would execute the script body — which is why every module intended to be both importable and runnable uses it. It also matters for multiprocessing on Windows and macOS, which re-imports the main module in child processes and will fork-bomb without the guard.

__main__.py in a package makes the package itself runnable:

myapp/
├── __init__.py
└── __main__.py
Terminal window
python3 -m myapp

import x searches sys.path, a list of directories, in order, and stops at the first match.

import sys
for p in sys.path:
print(p)

sys.path is built from:

  1. The directory of the script being run (or the current directory for -c, -m, and the REPL).
  2. PYTHONPATH environment variable entries.
  3. Installation-dependent defaults, including the active environment’s site-packages.
import json
json.__file__ # where it was actually loaded from

For finding your own package, do not mutate sys.path at runtime. Install the project in editable mode instead (below), which is the supported mechanism.

pip installs packages from PyPI, the Python Package Index. Always invoke it through the interpreter you mean to install into:

Terminal window
python3 -m pip install requests
python3 -m pip install "requests>=2.31,<3"
python3 -m pip install -r requirements.txt
python3 -m pip install -e . # editable install of the local project
python3 -m pip uninstall requests
python3 -m pip list
python3 -m pip show requests # version, location, dependencies
python3 -m pip freeze # exact versions, requirements.txt format

Version specifiers:

Specifier Meaning
requests Any version
requests==2.31.0 Exactly this
requests>=2.31 At least this
requests~=2.31.0 >=2.31.0, <2.32.0 — compatible release
requests>=2.31,<3 An explicit range

A virtual environment is a directory with its own site-packages and its own interpreter symlink. Activating it puts that interpreter first on PATH, so installs and imports are scoped to one project.

Why it is not optional:

  • Two projects needing different versions of the same library would otherwise conflict.
  • Installing globally can break OS tooling that depends on the system Python. Most Linux distributions now refuse global pip install outright (PEP 668, error: externally-managed-environment).
  • The environment is disposable — delete and rebuild it when things get strange.
Terminal window
python3 -m venv .venv # create it
source .venv/bin/activate # Linux / macOS
.venv\Scripts\activate # Windows (cmd)
.venv\Scripts\Activate.ps1 # Windows (PowerShell)
python3 -m pip install requests
which python # => /path/to/project/.venv/bin/python
deactivate # leave it

Add .venv/ to .gitignore. The environment is a build artifact — requirements.txt or pyproject.toml is the thing you commit.

Alternatives you will encounter: uv (a very fast Rust-based installer and environment manager that also handles Python versions), Poetry and PDM (project managers with lockfiles), conda (which manages non-Python dependencies too, common in scientific work), and pipx (installs command-line tools into isolated environments so they land on PATH without polluting anything).

Terminal window
pipx install ruff # a CLI tool, isolated, globally available

requirements.txt is a flat list of packages to install. Simple, universally understood, and the right tool for pinning a deployable application.

requirements.txt
requests>=2.31,<3
rich==13.7.0
-r requirements-dev.txt
Terminal window
python3 -m pip install -r requirements.txt
python3 -m pip freeze > requirements.txt # snapshot exact versions

pyproject.toml (PEP 518/621) is the modern, standard project file. It declares metadata, dependencies, and build configuration in one place, and most tools read their settings from it.

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "myapp"
version = "1.0.0"
description = "An example application"
requires-python = ">=3.12"
dependencies = [
"requests>=2.31,<3",
"rich>=13",
]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff", "mypy"]
[project.scripts]
myapp = "myapp.main:main" # creates a `myapp` command on install
[tool.ruff]
line-length = 100
Terminal window
python3 -m pip install -e . # install the project, editable
python3 -m pip install -e ".[dev]" # plus the dev extras

Use pyproject.toml for anything you package or distribute, and for any project that wants tool configuration in one file. Use requirements.txt for a simple script’s dependency list or as a generated lock file alongside it.

A conventional layout for a real project:

myproject/
├── pyproject.toml
├── README.md
├── .gitignore
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── __main__.py
│ └── models.py
└── tests/
└── test_models.py

The src/ layout prevents the project directory itself being on sys.path during tests, so you test the installed package rather than accidentally importing the source tree — which catches packaging mistakes early.

-m resolves a name through the import system and runs it as __main__, using the interpreter you invoked. It is the correct way to run tooling and package entry points.

Terminal window
python3 -m venv .venv
python3 -m pip install x
python3 -m pytest
python3 -m http.server 8000
python3 -m json.tool file.json
python3 -m zipfile -c archive.zip files/
python3 -m timeit -s "import math" "math.sqrt(2)"
python3 -m myapp # runs myapp/__main__.py
python3 -m myapp.tools.migrate # runs a submodule, relative imports intact
  • Importing runs a module once and caches it in sys.modules.
  • Prefer absolute imports; never use import *.
  • __init__.py makes a package and defines its public surface — keep it light.
  • if __name__ == "__main__": separates “runnable” from “importable”.
  • sys.path decides what gets imported; a local file named after a stdlib module shadows it.
  • Use python3 -m pip, and always inside a virtual environment.
  • .venv/ is disposable and gitignored; the dependency declaration is what you commit.
  • pyproject.toml is the standard project file; requirements.txt is still fine for pinning an application.