Every .py file is a module. A package is a directory of modules with an __init__.py. Organizing code into modules and packages keeps large projects maintainable. Pair that with a virtual environment per project, and dependencies stay clean. This lesson walks through the import system, packaging basics, and the venv-based workflow.
The import system
import loads a module and binds its name in the current scope. from module import name brings a specific name into scope. import is cached, so the module code runs only once even if you import it many times. Avoid wildcard imports (from module import *) because they pollute the namespace.
import math
print(math.sqrt(144)) # 12.0
from math import sqrt, pi
print(sqrt(144), pi) # 12.0 3.141592653589793
import math as m
print(m.factorial(5)) # 120
How Python finds modules
Python searches sys.path for the imported name. The first item is the directory of the script or the current directory in interactive mode. The standard library is in the lib subdirectory of the Python install. Third-party packages go into site-packages.
import sys
print(sys.path[:3])
# ['/your/project', '/usr/lib/python3.12', '/usr/lib/python3.12/lib-dynload']
Packages organize related modules
A package is a directory that contains an __init__.py. The __init__.py runs when you import the package and is usually empty or contains package-level setup. Subdirectories that are also packages can be nested.
mypkg/
├── __init__.py
├── utils.py
└── io/
├── __init__.py
└── reader.py
# usage
from mypkg.utils import slugify
from mypkg.io.reader import read_json
Virtual environments isolate dependencies
A virtual environment is a self-contained Python install with its own site-packages. Create one with python -m venv .venv, activate it, and install dependencies with pip. Every project should have its own venv so dependency versions never collide.
$ python3 -m venv .venv
$ source .venv/bin/activate
(.venv) $ pip install requests
(.venv) $ pip freeze > requirements.txt
(.venv) $ deactivate
Modern packaging with pyproject.toml
pyproject.toml is the modern, standard way to declare a project. It replaces setup.py for most purposes. Build backends like hatchling, setuptools, and flit read it. Pinning build tooling keeps builds reproducible.
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The __name__ == "__main__" idiom
Every module has a __name__ attribute. When you run a file directly, __name__ is the string "__main__". When you import it, __name__ is the module's name. Use the idiom to make a file both importable and runnable.
def main():
print("running as a script")
if __name__ == "__main__":
main()
Common pitfalls with imports
Three traps catch newcomers. First, circular imports happen when module A imports module B at the top level and module B imports module A. Python handles simple cycles, but you get partially initialised modules and confusing ImportError messages. Break the cycle by moving the second import into the function that needs it, or refactor the common code into a third module. Second, shadowing standard library names is silent. Naming your package email.py or json.py breaks any code that imports the standard library name. Pick unique names, especially for top-level packages. Third, relative imports can be confusing: from . import sibling works inside a package but raises ImportError when the file is run as a script. Use absolute imports (from mypkg import utils) whenever possible, and reach for relative imports only inside tightly coupled subpackages.
Comparison with JavaScript ES modules and npm
If you come from JavaScript, Python's module story is more centralised but less ergonomic in some places. JavaScript has explicit ES module syntax (import x from "./y.js"), automatic module resolution through node_modules, and bundlers that shake the tree at build time. Python's import statement is implicit, the sys.path mechanism is older than npm, and there is no built-in dependency lockfile equivalent to package-lock.json. uv, pip-tools, and Poetry have closed the gap by generating a lockfile from pyproject.toml and supporting reproducible installs. JavaScript modules always export a namespace by default; Python modules expose whatever names the file binds at top level. A Python __init__.py acts like an index.js that re-exports the package's public surface.
Best practices for module design in 2026
Three habits keep packages maintainable. First, make the public API explicit. Define __all__ in each module to whitelist the names that from module import * would expose, and keep the public API small. Second, prefer many small modules to a few large ones. A 200-line module with one responsibility is easier to navigate than a 2000-line module with five. Third, expose functions and classes, not modules. Consumers should be able to from mypkg import calculate_score rather than from mypkg.stats import calculate_score. The shorter import means the public API is in one place, and you can reorganise the internals without breaking callers.
Summary
Use modules to split a project into manageable files. Use packages to group related modules. Always develop inside a venv. Pin dependencies in pyproject.toml. The next lesson covers advanced topics: async, typing, and Python's data classes for production-grade code.