Skip to content

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 monorepo

This 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-service

When 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-lib

This 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 lock

The 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 sync

The .venv directory lives at the workspace root, not inside individual members.

To install only a specific member and its dependencies:

uv sync --package worker-app

This 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/tests

To run tests across all members, call pytest from the workspace root and let it discover tests in each package:

uv run pytest

This 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-app needs requests==2.28 and api-service needs requests==2.31, the resolver will fail. In this case, keep the projects in separate repositories or use separate pyproject.toml files outside the workspace.
  • Members need isolated virtual environments. A workspace has one .venv at 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
Last updated on

Please submit corrections and feedback...