src Layout & Package Structure
The src/ layout installs your package before tests run, so imports match production and accidental "works on my laptop" path hacks fail in CI.
Search across all documentation pages
The src/ layout installs your package before tests run, so imports match production and accidental "works on my laptop" path hacks fail in CI.
Quick-reference recipe card - copy-paste ready.
# pyproject.toml (excerpt)
[project]
name = "billing"
version = "0.1.0"
requires-python = ">=3.14"
[tool.setuptools.packages.find]
where = ["src"]
# src/billing/__init__.py
"""Billing domain package."""
# tests/test_invoice.py
from billing.invoices import compute_total
def test_compute_total() -> None:
assert compute_total([10, 20]) == 30When to reach for this:
pip install# pyproject.toml
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "orders"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = ["pydantic>=2"]
[project.scripts]
orders-api = "orders.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
# src/orders/__init__.py
__all__ = ["domain", "adapters"]
# src/orders/domain/models.py
from dataclasses import dataclass
@dataclass(frozen=True)
class LineItem:
sku: str
quantity: int
unit_price_cents: int
# src/orders/domain/totals.py
from orders.domain.models import LineItem
def order_total(items: list[LineItem]) -> int:
return sum(i.quantity * i.unit_price_cents for i in items)
# src/orders/adapters/memory_repo.py
from orders.domain.models import LineItem
class InMemoryOrderRepo:
def __init__(self) -> None:
self._items: list[LineItem] = []
def add(self, item: LineItem) -> None:
self._items.append(item)
def list_items(self) -> list[LineItem]:
return list(self._items)
# src/orders/cli.py
from orders.adapters.memory_repo import InMemoryOrderRepo
from orders.domain.models import LineItem
from orders.domain.totals import order_total
def main() -> None:
repo = InMemoryOrderRepo()
repo.add(LineItem("ABC", 2, 1500))
print(order_total(repo.list_items()))
if __name__ == "__main__":
main()What this demonstrates:
src/orders/, tests import orders as an installed namemodels, totals) stays free of CLI and persistence detailsproject.scripts exposes a stable console entry pointrequires-python documents the 3.14 floor from day onepip install -e . (or uv sync) puts src/orders on sys.path as top-level ordersorders.domain, not src.orders.domainPYTHONPATH=. hides missing packaging metadatamy-service/
pyproject.toml
src/
my_service/
__init__.py
domain/
adapters/
api/ # FastAPI/Flask routers
tests/
test_domain.py
README.md
| Task | Command |
|---|---|
| Editable install | uv sync or pip install -e . |
| Run tests | pytest (after install) |
| Console script | orders-api after install |
# Avoid package/module name collisions
# BAD: src/orders.py AND src/orders/ package
# GOOD: one package directory with submodules
# Explicit public API in __init__.py
from orders.domain.totals import order_total as order_total
__all__ = ["order_total"]pytest passes locally because the repo root is on sys.path, then fails in Docker. Fix: always pip install -e . in CI before tests.orders.py file next to orders/ shadows the package. Fix: one layout convention; delete stray top-level modules.from models import X breaks when run as a module. Fix: absolute imports from the package root (from orders.domain.models import X).__init__.py - namespace packages can work but confuse tooling and explicit exports. Fix: add __init__.py unless you deliberately need PEP 420 namespaces.src/foo/bar/baz/qux with one function per file. Fix: flatten until a folder has a clear boundary (domain, api, adapters).src/ - shipped accidentally in wheels. Fix: keep tests in top-level tests/.| Alternative | Use When | Don't Use When |
|---|---|---|
| Flat layout (package at repo root) | Single-file scripts, notebooks | Libraries and services installed in CI |
Namespace packages (no __init__.py) | Splitting subpackages across repos | Small teams wanting simple tooling |
Monorepo packages/ tree | Multiple publishable libs in one repo | One service with one package |
Flat layouts let the repository root satisfy imports during development even when packaging metadata is wrong. src layout forces installation, surfacing missing pyproject.toml entries before production.
Yes. Docker images and CI still install the app as a package. src layout keeps uvicorn orders.api:app consistent everywhere.
Keep migrations at repo root (alembic/) or under src/orders/adapters/db/migrations/ - but import models via the installed package name, not relative file paths.
Declare optional dependency groups in pyproject.toml ([project.optional-dependencies]) and lazy-import heavy adapters (e.g. PyTorch) inside functions.
Yes. Keep tests/ as a sibling of src/, never inside the package. Shared test utilities go in tests/helpers/ without shipping them.
Export the stable public API only. Internal modules stay undocumented in __all__ so refactors do not break external callers.
Each workspace member gets its own src/<package>/ tree and pyproject.toml. The workspace root coordinates shared lockfiles with uv 0.6+.
Start as subpackages (orders.domain, orders.adapters). Split into separate distributions only when another service needs domain without adapters.
python -m orders.cli works when the package is installed and cli.py defines a main or module body guard.
Point ruff at src/ and tests/ separately. src = ["src", "tests"] in pyproject.toml keeps import sorting aligned with the installed package name.
Wrap legacy scripts in a thin console entry point that imports the installed package. Deprecate PYTHONPATH hacks in README and CI.
One deployable service: one primary package. Multiple packages: use a workspace with clear ownership per src/<name>/.
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.
Reviewed by Chris St. John·Last updated Jul 19, 2026