languages/python/project_structure.md

Python Project Structure: Layouts, pyproject.toml, and Workspaces (2026)

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.

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 for the broader tooling context and framework-specific project layout in FastAPI Best Practices and Django Best Practices.

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/<package_name>/, 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.

[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.

# 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"]
# 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 }
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 for the full standard.

4. Choose Monorepo vs Polyrepo Based on Coupling, Not Team Size Alone

DimensionMonorepo (uv workspace)Polyrepo (separate repos + published packages)Winner (context-dependent)
Cross-package refactorsAtomic, single PRRequires coordinated multi-repo releasesMonorepo
Independent release cadenceHarder to isolateNatural (each repo/package versions independently)Polyrepo
CI complexityNeeds path-based filtering to avoid rebuilding everythingSimple, one pipeline per repoPolyrepo (initially)
Onboarding / discoverabilityOne clone, one lockfileMust clone N reposMonorepo
Access control granularityCoarse (repo-wide)Fine-grained per repoPolyrepo

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.

[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 internaladmintools - 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 <name> 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

Dimensionsrc-layoutflat-layoutWinner
Single-file scripts / notebooksUnnecessary overheadSimpler, appropriateflat-layout
Publishable librariesStrongly recommendedRisk of import shadowingsrc-layout
Large applicationsStrongly recommendedProne to packaging bugssrc-layout
Dimensionuv workspace (monorepo)Polyrepo + private indexWinner (context-dependent)
Small number of tightly coupled servicesBetterWorseMonorepo
Many teams with independent release cadenceWorseBetterPolyrepo

Use flat-layout for throwaway scripts, small internal tools, or teaching examples where the ceremony of src-layout adds no value.

Glossary

  • src-layout: Project layout placing importable source code under src/<package>/, 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.