# How to set up a Python monorepo with uv workspaces


A [uv](https://pydevtools.com/handbook/reference/uv.md) workspace lets multiple Python packages share a single repository, a single [lockfile](https://pydevtools.com/handbook/explanation/what-is-a-lock-file.md), 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:

```bash
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:

```toml
[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):

```bash
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:

```toml
[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:

```toml
[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:
> ```toml
> [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:

```bash
uv add --package worker-app shared-lib
```

This adds two entries to `packages/worker-app/pyproject.toml`:

```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:

```toml
# 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:

```bash
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:

```bash
uv sync
```

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

To install only a specific member and its dependencies:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```toml
[tool.pytest.ini_options]
testpaths = ["packages"]
```

## Realistic example

Consider a monorepo with a shared library used by two applications:

{{< /filetree/folder >}}
    {{< /filetree/folder >}}
        {{< /filetree/folder >}}
        {{< /filetree/folder >}}
      {{< /filetree/folder >}}
      {{< /filetree/folder >}}
      {{< /filetree/folder >}}
      {{< /filetree/folder >}}
      {{< /filetree/folder >}}
    {{< /filetree/folder >}}
  {{< /filetree/folder >}}
{{< /filetree/container >}}

The root `pyproject.toml` defines the workspace:

```toml
[tool.uv.workspace]
members = [
    "packages/*",
]

[tool.pytest.ini_options]
testpaths = ["packages"]
```

Both `worker-app` and `api-service` depend on `shared-lib`:

```toml
# 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 ([share dependencies between them with `[tool.uv.sources]`](https://pydevtools.com/handbook/how-to/how-to-manage-cross-repo-python-dependencies-with-uv.md)) 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.

## Learn more

- [uv: A Complete Guide](https://pydevtools.com/handbook/explanation/uv-complete-guide.md) covers what uv does, how fast it is, the core workflows, and recent releases.
