How to set up a Python monorepo with uv workspaces
A uv workspace lets multiple Python packages share a single repository, a single lockfile, and a single virtual environment. This is the standard approach for monorepos where packages depend on each other and need to stay in sync.
Create the workspace root
Initialize a new project to serve as the workspace root:
uv init monorepo
cd monorepoThis creates a pyproject.toml with standard project metadata. The root project acts as the workspace container; it does not need to contain any application code itself.
If the root will never be imported or run as its own package, make it a virtual workspace by removing the [project] table entirely and keeping only the workspace configuration:
[tool.uv.workspace]
members = [
"packages/*",
]A virtual workspace root cannot have its own dependencies or be published. All dependencies live in the members. This keeps the root clean when it exists only to organize other packages.
Add workspace members
Create packages inside a packages/ directory. Use --lib for libraries (packages meant to be imported) and --app for applications (packages meant to be run):
uv init --lib packages/shared-lib
uv init --app packages/worker-app
uv init --app packages/api-serviceWhen uv init detects a pyproject.toml in a parent directory, it automatically adds the new package as a workspace member. If no [tool.uv.workspace] table exists yet, uv creates one. After running these commands, the root pyproject.toml will contain:
[tool.uv.workspace]
members = [
"packages/shared-lib",
"packages/worker-app",
"packages/api-service",
]Use glob patterns for members
Listing each member individually becomes tedious as the workspace grows. Replace the explicit list with a glob:
[tool.uv.workspace]
members = [
"packages/*",
]Any directory under packages/ that contains a pyproject.toml will be treated as a workspace member. New packages added later are picked up automatically.
Tip
To exclude a directory from an otherwise broad glob, use the exclude key:
[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/legacy-app"]Wire up cross-package dependencies
To make worker-app depend on shared-lib, use uv add with the --package flag:
uv add --package worker-app shared-libThis adds two entries to packages/worker-app/pyproject.toml:
[project]
dependencies = [
"shared-lib",
]
[tool.uv.sources]
shared-lib = { workspace = true }The workspace = true source directive tells uv to resolve shared-lib from the local workspace instead of PyPI. The library is installed in editable mode, so changes to shared-lib are immediately visible to worker-app without reinstalling.
Share dependency sources across all members
Any [tool.uv.sources] entries in the workspace root pyproject.toml apply to every member by default. This is useful for pinning a fork or private registry across the entire workspace:
# Root pyproject.toml
[tool.uv.sources]
my-internal-lib = { url = "https://artifacts.example.com/my-internal-lib-1.0.tar.gz" }Every member that depends on my-internal-lib will use this source without repeating the configuration. A member can override a root source by defining its own [tool.uv.sources] entry for the same package.
Lock and sync the workspace
A workspace uses a single uv.lock at the root. Running uv lock resolves dependencies for all members together:
uv lockThe lockfile records every member and its resolved dependencies. This guarantees that all packages in the workspace use the same version of every shared dependency.
To install everything into the workspace’s shared virtual environment:
uv syncThe .venv directory lives at the workspace root, not inside individual members.
To install only a specific member and its dependencies:
uv sync --package worker-appThis is useful in CI where each job only needs one application’s dependencies installed.
Run commands in specific packages
Use --package to target a specific member:
uv run --package worker-app python -c "from shared_lib import hello; print(hello())"This ensures the command runs with worker-app and its dependencies available. The same flag works with other uv commands like uv add and uv remove.
To run tests for a single member:
uv run --package worker-app pytest packages/worker-app/testsTo run tests across all members, call pytest from the workspace root and let it discover tests in each package:
uv run pytestThis works because all members share one .venv, so every package is importable. Configure pytest in the root pyproject.toml to find tests in all packages:
[tool.pytest.ini_options]
testpaths = ["packages"]Realistic example
Consider a monorepo with a shared library used by two applications:
- pyproject.toml
- uv.lock
- pyproject.toml
- init.py
- test_shared_lib.py
- pyproject.toml
- main.py
- test_worker.py
- pyproject.toml
- main.py
- test_api.py
The root pyproject.toml defines the workspace:
[tool.uv.workspace]
members = [
"packages/*",
]
[tool.pytest.ini_options]
testpaths = ["packages"]Both worker-app and api-service depend on shared-lib:
# packages/worker-app/pyproject.toml
[project]
name = "worker-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"shared-lib",
]
[tool.uv.sources]
shared-lib = { workspace = true }Running uv lock produces a single lockfile that resolves shared-lib along with any external dependencies from both applications.
Handle Python version constraints
uv computes the intersection of all members’ requires-python values to determine the workspace’s supported Python range. If shared-lib requires >=3.10 and worker-app requires >=3.12, the workspace resolves against >=3.12.
This means adding a member with a narrow Python requirement tightens the constraint for the entire workspace. If that becomes a problem, the package with the incompatible requirement may belong outside the workspace.
Know when not to use workspaces
Workspaces work well when all members can share a single virtual environment and a single lockfile. They are the wrong tool when:
- Members need conflicting dependency versions. All workspace members resolve together. If
worker-appneedsrequests==2.28andapi-serviceneedsrequests==2.31, the resolver will fail. In this case, keep the projects in separate repositories or use separatepyproject.tomlfiles outside the workspace. - Members need isolated virtual environments. A workspace has one
.venvat the root. If packages must run in separate environments (for example, because they target different Python versions), workspaces cannot provide that isolation. - Members must guarantee strict import boundaries. Python has no dependency isolation at runtime. A workspace member can import any package installed in the shared
.venv, including dependencies declared only by another member. If enforcing that each package uses only its declared dependencies is critical, use path dependencies with separate virtual environments instead of a workspace.
Get Python tooling updates
Subscribe to the newsletter