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.tomlas 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
| 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.
[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> pytestscoped per package in CI, combined with path-based change detection so unrelated packages are not needlessly retested. - Test that
py.typedand 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(orpoetry.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 repeateduv synccalls 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.lockhashes and prefer--frozenin 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__.pyat the namespace level) per PEP 420. - Vendoring: For air-gapped or highly regulated environments,
uv export --format requirements-txtcombined with a private wheel mirror allows fully offline, reproducible installs. - Mixed-language monorepos:
uvworkspaces coexist fine alongside non-Python packages (Node, Rust) in the same repository; scope tooling (Ruff, pyright) withexclude/includepaths per language boundary. - Editable installs across workspace members:
uv syncwires 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
- Python General Best Practices
- Python Testing Guide
- FastAPI Best Practices - see its project structure section for FastAPI-specific layering
- Django Best Practices - see its src-layout + apps structure
- Clean Architecture Principles
- Clean Code Principles
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 viapip 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.