Modules, Packages & Project Layout
How import actually works, how packages are put together, and how to lay out a project other people can install.
Modules & the import Statement
A module is just a .py file. Importing it runs the file once, top to bottom, and binds the resulting namespace to a name you can reach through.
import math # math.pi import numpy as np # np.array — an alias from math import pi, sqrt # pi, sqrt — bound directly from math import sqrt as root # root from math import * # avoid: hides where a name came from
The import runs the module body once per process. Python caches it in sys.modules, so a second import of the same module is a dictionary lookup, not a re-execution. That is why a print statement at module level fires once however many files import it.
import sys import json import json # no re-execution sys.modules['json'] # the same module object both times json.__file__ # where it was loaded from
Packages & __init__.py
A package is a directory of modules. __init__.py marks it as one and runs when the package is first imported — which makes it the place to present a public API, and the place people accidentally put slow work.
myapp/
├── __init__.py # runs on: import myapp
├── config.py
├── models/
│ ├── __init__.py
│ ├── user.py
│ └── order.py
└── services/
├── __init__.py
└── billing.py
# Re-export so callers write the short form from .user import User from .order import Order __all__ = ['User', 'Order'] # what 'from myapp.models import *' gives # now: from myapp.models import User # instead of: from myapp.models.user import User
Absolute imports name the full path from the project root; relative imports use leading dots to mean "this package" and "the one above it".
from myapp.models.user import User # absolute — clear, and PEP 8 prefers it from .user import User # same package from ..config import SETTINGS # one package up # Relative imports only work inside a package — running the file # directly gives: ImportError: attempted relative import with no known parent package
__init__.py still imports, as a namespace package. That is a feature for splitting one package across distributions — but as an accident it silently changes behaviour, so keep the __init__.py unless you specifically want the other thing.How Python Finds a Module
On import x, Python checks sys.modules first, then walks sys.path in order and takes the first match. sys.path starts with the script's own directory, then PYTHONPATH, then the installed site-packages.
import sys for p in sys.path: print(p) # 1. the directory of the script you ran (or cwd for -m) # 2. anything in the PYTHONPATH environment variable # 3. the standard library # 4. site-packages — where pip installs
random.py or json.py next to your script wins over the standard library, and every later import of that name gets your file. The symptom is an AttributeError on a function that certainly exists. Rename the file and delete the stale __pycache__.A circular import is two modules importing each other. The second import finds a half-executed module in sys.modules and gets a name that does not exist yet.
# a.py from b import helper # b runs, and b imports a, which is half-built # 1. import the module, not the name — resolution is deferred import b b.helper() # 2. import inside the function that needs it def run(): from b import helper return helper() # 3. the real fix: move the shared thing into a third module
Structuring & Packaging a Project
Once a project outgrows one file, the layout below is the one the ecosystem expects. Keeping the package inside src/ stops your working directory from shadowing the installed copy, so tests exercise what users actually get.
myproject/
├── pyproject.toml # metadata, dependencies, build config
├── README.md
├── src/
│ └── myapp/
│ ├── __init__.py
│ └── main.py
└── tests/
└── test_main.py
[project] name = "myapp" version = "0.1.0" requires-python = ">=3.10" dependencies = ["requests>=2.31", "pydantic>=2"] [project.scripts] myapp = "myapp.main:cli" # installs a `myapp` command [build-system] requires = ["hatchling"] build-backend = "hatchling.build"
pip install -e . # editable: your edits take effect without reinstalling python -m build # produces dist/*.whl and dist/*.tar.gz # A wheel is the built, installable artefact; the .tar.gz is the source # distribution. pip prefers the wheel because it needs no build step.
Run the package with python -m myapp rather than by path: -m puts the current directory on sys.path and executes the module as part of its package, so relative imports resolve. Running python src/myapp/main.py directly is what produces "attempted relative import with no known parent package".
Quick Quiz
Five questions on imports, packages and layout.
random.py sits next to your script. What breaks?__init__.py do?ImportError on a name that exists. Best fix?python -m myapp instead of python src/myapp/main.py?