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.
Recipe
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:
- Any installable library or service packaged for Docker/CI
- Teams hitting import errors only in CI or after
pip install - Projects splitting domain, adapters, and CLI entry points
- Monorepos with multiple packages under one repo
Working Example
# 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:
- Package code lives under
src/orders/, tests importordersas an installed name - Domain (
models,totals) stays free of CLI and persistence details project.scriptsexposes a stable console entry pointrequires-pythondocuments the 3.14 floor from day one
Deep Dive
How It Works
pip install -e .(oruv sync) putssrc/ordersonsys.pathas top-levelorders- Tests and apps import
orders.domain, notsrc.orders.domain - Editable installs re-read source changes without reinstalling
- CI that only sets
PYTHONPATH=.hides missing packaging metadata
Recommended Tree
my-service/
pyproject.toml
src/
my_service/
__init__.py
domain/
adapters/
api/ # FastAPI/Flask routers
tests/
test_domain.py
README.md
Packaging Commands
| Task | Command |
|---|---|
| Editable install | uv sync or pip install -e . |
| Run tests | pytest (after install) |
| Console script | orders-api after install |
Python Notes
# 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"]Gotchas
- Testing without install -
pytestpasses locally because the repo root is onsys.path, then fails in Docker. Fix: alwayspip install -e .in CI before tests. - Duplicate package names - a
orders.pyfile next toorders/shadows the package. Fix: one layout convention; delete stray top-level modules. - Implicit relative imports in scripts -
from models import Xbreaks when run as a module. Fix: absolute imports from the package root (from orders.domain.models import X). - Missing
__init__.py- namespace packages can work but confuse tooling and explicit exports. Fix: add__init__.pyunless you deliberately need PEP 420 namespaces. - Deep nesting without purpose -
src/foo/bar/baz/quxwith one function per file. Fix: flatten until a folder has a clear boundary (domain, api, adapters). - Tests inside
src/- shipped accidentally in wheels. Fix: keep tests in top-leveltests/.
Alternatives
| 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 |
FAQs
Why is src layout better than a flat package directory?
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.
Do I need src/ for a FastAPI app that is never published to PyPI?
Yes. Docker images and CI still install the app as a package. src layout keeps uvicorn orders.api:app consistent everywhere.
Where do Alembic migrations live?
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.
How do I structure optional extras?
Declare optional dependency groups in pyproject.toml ([project.optional-dependencies]) and lazy-import heavy adapters (e.g. PyTorch) inside functions.
Can tests import from tests.helpers?
Yes. Keep tests/ as a sibling of src/, never inside the package. Shared test utilities go in tests/helpers/ without shipping them.
What goes in __all__?
Export the stable public API only. Internal modules stay undocumented in __all__ so refactors do not break external callers.
How does uv workspaces relate to src layout?
Each workspace member gets its own src/<package>/ tree and pyproject.toml. The workspace root coordinates shared lockfiles with uv 0.6+.
Should domain and adapters be separate installable packages?
Start as subpackages (orders.domain, orders.adapters). Split into separate distributions only when another service needs domain without adapters.
How do I run a module without a console script?
python -m orders.cli works when the package is installed and cli.py defines a main or module body guard.
Does ruff need special src configuration?
Point ruff at src/ and tests/ separately. src = ["src", "tests"] in pyproject.toml keeps import sorting aligned with the installed package name.
What if third-party code expects repo root on PYTHONPATH?
Wrap legacy scripts in a thin console entry point that imports the installed package. Deprecate PYTHONPATH hacks in README and CI.
How many top-level packages belong in one repo?
One deployable service: one primary package. Multiple packages: use a workspace with clear ownership per src/<name>/.
Related
- Project Architecture Decision Checklist - layout decision in context
- Clean / Hexagonal Architecture - domain vs adapters folders
- Dependency Injection & Wiring - wiring modules at the composition root
- Configuration & Settings - settings module placement
- Circular Import Scenarios - cycles from bad package graphs
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+.