--- title: "Python Project Structure: Layouts, pyproject.toml, and Workspaces (2026)" description: "Guidance on src-layout vs flat-layout, pyproject.toml anatomy, dependency groups (PEP 735), monorepo vs polyrepo trade-offs, and uv workspace support for structuring Python projects at any scale." language: python framework: null category: language_overview tags: - project-structure - packaging - dependency-management - uv - monorepo - pyproject-toml keywords: - src layout vs flat layout python - pyproject.toml anatomy 2026 - python dependency groups pep 735 - uv workspace monorepo - python monorepo vs polyrepo last_updated: 2026-07-07 difficulty: intermediate version: "uv >= 0.5, Python >= 3.12" related: - README.md - best_practices.md - code_style_guide.md - testing.md - concurrency.md - performance.md - frameworks/fastapi/best_practices.md - frameworks/django/best_practices.md - ../../core/architecture/clean_architecture.md - ../../core/principles/clean_code.md - ../../core/devops/autonomous_least_privilege_environments.md search_priority: high status: published --- # Python Project Structure: Layouts, pyproject.toml, and Workspaces (2026) ## Introduction / Overview How a Python project is laid out on disk determines how reliably it can be tested, packaged, and reused. Two layouts dominate in 2026: **src-layout** and **flat-layout**. `pyproject.toml`, standardized across PEP 517/518/621/735, is now the single source of truth for metadata, dependencies, and tool configuration, replacing the old `setup.py`/`setup.cfg`/`requirements.txt` sprawl. `uv` has additionally brought first-class **workspace** support to the Python ecosystem, making monorepos with multiple interdependent packages practical without heavyweight tooling. This document covers structural decisions that apply regardless of framework; see [Python Language Overview](README.md) for the broader tooling context and framework-specific project layout in [FastAPI Best Practices](frameworks/fastapi/best_practices.md) and [Django Best Practices](frameworks/django/best_practices.md). **When to use this guidance**: Starting a new Python package or application, migrating a legacy `setup.py`-based project, or deciding between a monorepo and multiple repositories for a growing set of related Python services. ## Core Concepts - **src-layout**: Source code lives under `src//`, physically separated from tests, configuration, and tooling at the repository root. Prevents accidental imports of the uninstalled, in-development package via the current working directory. - **flat-layout**: The package directory sits directly at the repository root alongside `tests/`, `pyproject.toml`, etc. Simpler for very small projects or single-file scripts. - **`pyproject.toml` as unified configuration**: A single TOML file covering build system (`[build-system]`), project metadata (`[project]`), dependency groups (`[dependency-groups]`), and tool-specific configuration (`[tool.ruff]`, `[tool.pytest.ini_options]`, `[tool.uv]`). - **Dependency groups (PEP 735)**: A standardized, non-published way to declare development-only dependency sets (`dev`, `test`, `docs`, `lint`) distinct from `[project.optional-dependencies]`, which *are* published as installable extras for end users. - **Workspaces**: A `uv` (and Poetry/Hatch, following suit) concept for a single repository containing multiple installable packages that share a lockfile and can depend on each other by local path, without publishing intermediate versions to an index. ## Best Practices ### 1. Default to src-layout for Anything Beyond a Single Script **Rationale**: With a flat layout, running `pytest` or `python -m mypackage` from the repository root can silently import the local, uninstalled source tree instead of the installed package, masking packaging bugs (missing files in the wheel, incorrect `__init__.py` exports) until they reach production. ``` myproject/ ├── pyproject.toml ├── README.md ├── src/ │ └── myproject/ │ ├── __init__.py │ ├── py.typed │ ├── core/ │ ├── services/ │ └── cli.py ├── tests/ │ ├── unit/ │ └── integration/ └── docs/ ``` **Why this works well in production**: Tests and tooling exercise the *installed* package (via `pip install -e .` / `uv sync`), catching the same import errors that end users would hit. ### 2. Treat `pyproject.toml` as the Only Configuration Entry Point **Rationale**: Consolidating metadata, dependencies, and tool configuration in one file eliminates the historical spread across `setup.py`, `setup.cfg`, `requirements.txt`, `requirements-dev.txt`, `pytest.ini`, and `.flake8`. ```toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "myproject" version = "1.4.0" description = "Order processing service" requires-python = ">=3.12" dependencies = [ "pydantic>=2.5", "httpx>=0.27", ] [project.optional-dependencies] postgres = ["psycopg[binary]>=3.1"] [dependency-groups] dev = [ "ruff>=0.15", "pyright>=1.1", ] test = [ "pytest>=8.0", "pytest-cov", "pytest-mock", "hypothesis", ] [tool.uv] default-groups = ["dev", "test"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "--strict-markers --strict-config" [tool.ruff] line-length = 100 target-version = "py312" ``` **Production tip**: `[project.optional-dependencies]` (extras) are for *runtime* feature flags installed by end users (`pip install myproject[postgres]`). `[dependency-groups]` are for *development-time* tooling and are never installed alongside the published package - conflating the two bloats the installed footprint of your library for consumers. ### 3. Use `uv` Workspaces for Multi-Package Monorepos **Rationale**: A workspace lets several installable packages in one repository share a single lockfile and resolve inter-package dependencies from local source, without publishing intermediate versions to a package index during development. ```toml # Root pyproject.toml [project] name = "acme-workspace" version = "0.0.0" requires-python = ">=3.12" [tool.uv] package = false [tool.uv.workspace] members = ["packages/*"] [dependency-groups] dev = ["ruff", "pytest", "pyright"] ``` ```toml # packages/orders-service/pyproject.toml [project] name = "orders-service" version = "0.3.0" dependencies = ["acme-shared-models"] [tool.uv.sources] acme-shared-models = { workspace = true } ``` ```bash uv sync # Installs the whole workspace with one lockfile uv run --package orders-service pytest ``` **Why this works well in production**: Changes to a shared internal library (`acme-shared-models`) are immediately visible to dependent packages during local development and CI, without a publish-bump-reinstall cycle. Every command shown above runs as an unprivileged user, needs no OS-specific branching, and completes with no prompts - the same `uv sync --frozen` (or a `make setup` target that wraps it) is the single non-interactive bootstrap entry point for a new contributor, a CI runner, and an autonomous agent provisioning the workspace unattended. See [Autonomous, Least-Privilege, Portable Execution Environments](../../core/devops/autonomous_least_privilege_environments.md#8-ship-a-single-fully-automated-bootstrap-path-with-zero-manual-steps) for the full standard. ### 4. Choose Monorepo vs Polyrepo Based on Coupling, Not Team Size Alone | Dimension | Monorepo (uv workspace) | Polyrepo (separate repos + published packages) | Winner (context-dependent) | |----------------------------------|----------------------------------------|---------------------------------------------------|------------------------------| | Cross-package refactors | Atomic, single PR | Requires coordinated multi-repo releases | Monorepo | | Independent release cadence | Harder to isolate | Natural (each repo/package versions independently) | Polyrepo | | CI complexity | Needs path-based filtering to avoid rebuilding everything | Simple, one pipeline per repo | Polyrepo (initially) | | Onboarding / discoverability | One clone, one lockfile | Must clone N repos | Monorepo | | Access control granularity | Coarse (repo-wide) | Fine-grained per repo | Polyrepo | **Rule of thumb**: If packages change together frequently and are versioned/deployed together, a monorepo with `uv` workspaces reduces coordination overhead. If packages have genuinely independent release cycles, ownership boundaries, or access-control requirements, prefer separate repositories with published internal packages (private PyPI index or Git dependencies). ### 5. Pin the Build Backend Explicitly **Rationale**: `[build-system]` determines how `pip`/`uv` builds your package into a wheel; omitting it or relying on legacy `setuptools` defaults produces inconsistent behavior across environments. ```toml [build-system] requires = ["hatchling"] build-backend = "hatchling.build" ``` `hatchling` and `setuptools` (with `pyproject.toml`-native configuration) are both solid choices in 2026; avoid legacy `setup.py`-only builds for new projects. ## Common Pitfalls & Anti-Patterns ### Pitfall: Flat Layout Masking Import Errors **Problem**: `import myproject` succeeds locally because the interpreter finds the source directory on `sys.path` via the current working directory, even though the package was never actually installed - this passes in development and fails once deployed. **Recommended approach**: Use src-layout (Best Practice #1) so the package must be installed (even in editable mode) to be importable at all. ### Pitfall: Mixing `requirements.txt` and `pyproject.toml` **Problem**: Two sources of truth for dependencies drift apart silently; a version bumped in one file is forgotten in the other. **Recommended approach**: Declare all dependencies in `pyproject.toml` (`[project.dependencies]` and `[dependency-groups]`); generate a lockfile (`uv.lock`) for reproducibility instead of hand-maintained `requirements.txt` files. ### Pitfall: One Giant Monorepo Package with No Internal Boundaries **Problem**: A single top-level package with no `packages/` separation makes it impossible to enforce that, e.g., the `web` package cannot import from `internal_admin_tools` - dependency direction becomes unenforceable. **Recommended approach**: Even within one repository, split into multiple installable packages (via a workspace) so import boundaries are physically enforced by packaging, not just convention. ## Testing Strategies - Verify packaging correctness in CI by building the wheel (`uv build`) and installing it into a clean virtual environment before running the test suite against the *installed* artifact, not just the source tree. - For workspaces, run `uv run --package pytest` scoped per package in CI, combined with path-based change detection so unrelated packages are not needlessly retested. - Test that `py.typed` and package data files are actually included in the built wheel (`unzip -l dist/*.whl`) - a common regression when adding new non-Python files. ## Performance, Security & Scalability Considerations - **Lockfile reproducibility**: `uv.lock` (or `poetry.lock`) pins exact resolved versions including transitive dependencies; commit it for applications (not for libraries, which should keep dependency ranges loose to avoid version conflicts for consumers). - **CI cache efficiency**: `uv`'s content-addressed cache makes repeated `uv sync` calls in CI extremely fast; mount the cache directory as a persistent CI cache to avoid re-downloading wheels on every run. - **Supply-chain hygiene**: Use `uv.lock` hashes and prefer `--frozen` in CI (`uv sync --frozen`) to guarantee CI installs exactly what was locked, never a silently newer resolution. - **Workspace blast radius**: In a large monorepo, an unpinned or overly permissive shared dependency can break every package simultaneously; treat shared internal libraries with the same review rigor as external dependencies. ## Edge Cases & Advanced Usage - **Namespace packages**: For a company-wide namespace (`acme.orders`, `acme.billing`) spread across multiple distributions, use implicit namespace packages (no `__init__.py` at the namespace level) per PEP 420. - **Vendoring**: For air-gapped or highly regulated environments, `uv export --format requirements-txt` combined with a private wheel mirror allows fully offline, reproducible installs. - **Mixed-language monorepos**: `uv` workspaces coexist fine alongside non-Python packages (Node, Rust) in the same repository; scope tooling (Ruff, pyright) with `exclude`/`include` paths per language boundary. - **Editable installs across workspace members**: `uv sync` wires up editable installs automatically for workspace members referencing each other, so a change in a shared package is immediately visible without reinstalling. ## When *Not* to Use / Alternatives | Dimension | src-layout | flat-layout | Winner | |---------------------------|-------------------------------|--------------------------------|---------------| | Single-file scripts / notebooks | Unnecessary overhead | Simpler, appropriate | flat-layout | | Publishable libraries | Strongly recommended | Risk of import shadowing | src-layout | | Large applications | Strongly recommended | Prone to packaging bugs | src-layout | | Dimension | uv workspace (monorepo) | Polyrepo + private index | Winner (context-dependent) | |------------------------------|--------------------------------|---------------------------------|-------------------------------| | Small number of tightly coupled services | Better | Worse | Monorepo | | Many teams with independent release cadence | Worse | Better | Polyrepo | **Use flat-layout** for throwaway scripts, small internal tools, or teaching examples where the ceremony of src-layout adds no value. ## Related Documentation - [Python Language Overview & Ecosystem](README.md) - [Python General Best Practices](best_practices.md) - [Python Testing Guide](testing.md) - [FastAPI Best Practices](frameworks/fastapi/best_practices.md) - see its project structure section for FastAPI-specific layering - [Django Best Practices](frameworks/django/best_practices.md) - see its src-layout + apps structure - [Clean Architecture Principles](../../core/architecture/clean_architecture.md) - [Clean Code Principles](../../core/principles/clean_code.md) ## Glossary - **src-layout**: Project layout placing importable source code under `src//`, physically separated from the repository root. - **flat-layout**: Project layout placing the importable package directly at the repository root. - **Dependency group (PEP 735)**: A standardized `[dependency-groups]` table for non-published, development-time dependency sets. - **Extras (`[project.optional-dependencies]`)**: Published, optional runtime dependency sets installable via `pip install pkg[extra]`. - **Workspace**: A single repository containing multiple independently installable packages sharing one lockfile and resolving inter-package dependencies from local source. - **Lockfile**: A fully resolved, pinned dependency manifest (`uv.lock`) ensuring reproducible installs across machines and CI.