Building Python Monorepos: uv Workspaces
Managing a complex Python repository with a shared core, various plugins, and multiple applications used to be a headache involving brittle path dependencies or complex scripts. In 2025, the uv workspace has emerged as the definitive solution for these “monorepo” architectures.
What is a Workspace?
uv’s workspace concept is heavily inspired by Rust’s Cargo – a high performance tool for managing large codebases with multiple related packages. The most important concepts to know are:
- Workspace members: A workspace consists of many workspace members. Members are the individual local packages (applications or libraries) that make up the workspace. Each member has its own pyproject.toml to define its specific dependencies and configuration.
- Shared lockfile: The entire workspace shares a single uv.lock file located at the workspace root. This ensures that all members use consistent versions of shared dependencies, preventing version conflicts between different parts of your project.
- Workspace root: Workspace members are defined in the
[tool.uv.workspace]table of the workspace rootpyproject.tomlfile. This root is also considered a workspace member. - Unified Environment: By default, uv creates a single virtual environment (.venv) for the entire workspace, which includes all members and their dependencies. Members are installed into this environment as editable packages, meaning changes to one member are immediately reflected when used by another.
Workspace members are treated as local editable packages. They can reference each other without being published. The workspace root serves as a wrapper, or a “dev” environment, for its members. It is an orchestration layer to define shared context across the workspace members.
An example directory tree for a uv-monorepo below:
uv-monorepo-python/
├── .venv/ # The sole virtual environment
├── pyproject.toml # Workspace root configuration (define unified tools settings common to all workspace members)
├── uv.lock # The sole uv lock file for dependency resolution
├── packages/ # All Python packages/libraries
│ ├── package_a/ # package_a is a workspace member
│ │ ├── pyproject.toml
│ │ └── package_a/
│ │ ├── __init__.py
│ │ └── ...
│ ├── package_b/ # package_b is a workspace member
│ │ ├── pyproject.toml
│ │ └── package_b/
│ │ ├── __init__.py
│ │ └── ...
│ └── ...
├── services_or_apps/ # Applications/microservices
│ ├── service_x/
│ │ ├── pyproject.toml
│ │ └── service_x/
│ │ ├── __init__.py
│ │ └── ...
│ └── ...
│
├── scripts/ # Monorepo-level utility scripts
│ └── some_script.py
│
└── tests/ # Global or integration tests
└── test_monorepo.py
How to configure pyproject.toml?
Here is how to configure a repo with a shared core library and an application.
1. The Workspace Root (/pyproject.toml)
The root file defines the boundaries of your workspace and hosts shared dev tools.
[project]
name = "my-monorepo"
version = "0.1.0"
requires-python = ">=3.12"
[dependency-groups]
dev = [
"pytest>=8.0.0",
"ruff>=0.3.0",
]
[tool.uv.workspace]
# Include all subdirectories in packages/ and apps/ as members
members = ["packages/*", "apps/*"]
[tool.uv.sources]
# You may add workspace dependencies here if needed
core-lib = { workspace = true } # core-lib is a workspace member
[tool.uv]
package = false # Don't build the root directory as a package
The key root-level config is the [tool.uv.workspace] table. Here, you define where your workspace members are located. Note that starting the path with ./ e.g., ./packages/* is invalid.
Workspace members will appear in your uv.lock’s [manifest] table:
[manifest]
members = [
"core-lib",
...
]
2. The Core Package (/packages/core-lib/pyproject.toml)
For a library, ensure it is marked as a “package” so others can import it. uv will build the package when you run the sync command.
[project]
name = "core-lib" # This package name gets registered as a workspace member
version = "0.1.0"
dependencies = ["requests>=2.31"]
[tool.uv]
package = true # uv will build and install this member as a package; This makes core-lib importable by other members
3. The Application (/apps/main-app/pyproject.toml)
The application links to other workspace members (e.g. the core library) by using the [tool.uv.sources] table and setting workspace = true.
[project]
name = "main-app"
version = "0.1.0"
dependencies = [
"core-lib",
"external-lib",
"fastapi>=0.110",
]
[tool.uv]
package = false # Optimizes sync; in this example, this app isn't meant to be imported
[tool.uv.sources]
core-lib = { workspace = true } # core-lib is a workspace member
external-lib = { git = "https://github.com/code-owner/code-repo" } # declare private external dependencies explicitly
You can find a template for a uv-monorepo here
Key Configurations
[tool.uv.workspace]: Defines workspace memberstool.uv.package = true: Essential for any member you want to import elsewhere.tool.uv.sources: Every member that depends on another must explicitly declare{ workspace = true }i.e., “this dependency is another workspace member”.- Root Constraints: If you need to force a specific version of a sub-dependency (like a security patch), it must be defined in the root pyproject.toml, as uv ignores member-level constraints.
Essential Workflow (2026)
Once your structure is set, use these commands to keep your monorepo healthy:
uv lockfrom the root: generates manifest for your entire workspace.-
uv syncfrom the root: creates a virtual environment, install dependencies, build and install workspace packages.Note: You can run
uv sync --all-packagesfrom a workspace member directory to achieve the same effect as runninguv syncfrom the workspace root. - Isolated Testing: To run tests for just one plugin without other workspace noise:
# from the workspace root uv sync --package my-plugin --group test uv run --package my-plugin --directory packages/my-plugin/ pytest testsYou could also navigate into a workspace member’s directory:
cd packages/my-plugin uv sync --group test uv run pytest testsNote: make sure pytest is installed when you run
uv run pytest. If pytest is not installed as a direct dependency of the package you are in, uv falls back to a system-wide pytest or one from a different environment that doesn’t “know” about your local workspace members (see below for a deeper dive). - Add a new dependency from root: e.g., to add
httpxto only the core library:uv add --package core-lib httpx
Updating Dependencies
With an existing uv.lock file, uv will prefer the previously locked versions of packages when running uv sync and uv lock. Package versions will only change if the project’s dependency constraints exclude the previous, locked version (source).
Run uv lock --upgrade or uv lock -U to upgrade all packages.
Auto upgrade dependencies
uv works with Renovate and dependabot to automatically upgrade your pyproject.toml and uv.lock files (docs).
Testing and CI
Avoid installing pytest in every member’s pyproject.toml. Instead, define a shared dev or test group in the root pyproject.toml.
# root/pyproject.toml
[dependency-groups]
test = ["pytest", "pytest-cov"]
Local Testing
- Test Individual Packages (Isolated): To ensure a package isn’t accidentally relying on “ghost dependencies” from its siblings, sync specifically to that package before testing.
# Workspace root # Syncs ONLY my_pkg's deps + the root's test tools uv sync --package my_pkg --group test # Run tests for that specific package uv run --package my_pkg --directory packages/my_pkg/ pytest testsAlternatively, test within a workspace member’s directory
cd packages/my-plugin uv sync --group test uv run pytest tests - Testing across packages (Integration): For tests defined at the root that exercise multiple members, perform a full workspace sync.
uv sync --all-packages --group test uv run pytest tests/root_integration_tests
Github Actions
- Use a Matrix for Individual Packages: Run tests for each member in parallel jobs to speed up CI and isolate failures.
jobs: test-members: runs-on: ubuntu-latest strategy: matrix: package: [core-lib, app-a, plugin-b] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --package $ --group test - run: uv run --package $ pytest packages/$ - Dedicated Job for Integration Tests: Run root-level tests in a separate job that syncs the entire workspace.
test-integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --all-packages --group test - run: uv run pytest tests/root_integration_tests
Limitations
Third Party Development
A workspace setup is highly optimized for internal developers. What should we do if a 3rd party wants to contribute to a plugin? In this scenario, you’ll want to share the core library, but keep other components or apps hidden.
In this case, you have a few options:
- Publish your core library to a private packaging index
- Use
uv buildinside your core library. Any dependency you listed in core/pyproject.toml (like pydantic) will be listed as a requirement for the 3rd party automatically. - Plugin Templates: It is common practice in 2026 to provide 3rd parties with a template repository (
uvdoes this withuv init --type lib) that already has my-core-library added as a dependency.
Use Path Deps if Requirements Cannot Be Resolved
Use Workspaces if your packages are part of the same project lifecycle. Use Path Dependencies only if you have strictly conflicting version requirements (e.g., App A needs numpy 1.x and App B needs numpy 2.x) that cannot coexist in the same virtual environment.
You would set up your pyproject.toml file like:
[tool.uv.sources]
core-lib = { path = "../../core-lib" } # specify core-lib via its path
Workspace Setup vs Relative Paths:
| Feature | Workspace | Relative Path Dependencies |
|---|---|---|
| Lockfile | Single uv.lock at root | Individual uv.lock per project |
| Virtual Env | Shared .venv at root | Separate .venv per project |
| Conflicts | Not allowed (fails to lock) | Allowed (isolated) |
| Best For | Tightly coupled apps/libs | Microservices or standalone libs |
| IDE Sync | Easiest (one project root) | Harder (multiple project roots) |
Troubleshooting
uv appears to use a different, unexpected virtual environment
Expected behavior:
$ uv run --package my_pkg which pytest
/monorepo-root/.venv/bin/python3: No module named pytest
Observed behavior:
$ uv run --package my_pkg which pytest
/opt/anaconda/bin/pytest # or some other unexpected path
Why does this happen?
When you run a command like pytest, your shell looks for the executable in the directories listed in your PATH environment variable. uv run puts the virtual environment’s bin (or Scripts on Windows) directory earlier in the PATH. However, it does not fully replace the system PATH. This means if pytest is not installed in the current virtual environment, the command may fall back to a system or global Python environment where pytest is installed. In the case of uv run pytest, this can lead to unexpected behavior and environment inconsistencies.
i.e., if pytest is not in your current environment, uv run pytest looks for pytest in the following order:
- The Active Project Environment: The .venv associated with your current workspace member.
- The Parent Workspace Environment: The shared .venv at the monorepo root.
- The “Tool” Cache: If you’ve ever run uvx pytest (or uv tool run pytest), uv caches an isolated version of pytest in its global tool directory.
- The System PATH: Any pytest executable found in your global system path
To prevent this behavior, you could run pytest specifically as a module:
$ uv run --package my_pkg python -m pytest
/monorepo-root/.venv/bin/python3: No module named pytest
Conclusion
uv workspaces bring Cargo-style monorepo management to Python: one lockfile, one environment, and seamless cross-package imports. Start with uv init, define your [tool.uv.workspace] members, and let uv sync handle the rest.
For more details, see the official uv workspace documentation.
