TL;DR:

  • uv workspaces give Python monorepos a single shared virtual environment with coordinated dependency resolution across all packages
  • Cross-package local dependencies work with a simple path = "../package-name" declaration — no local pip installs or PYTHONPATH hacks
  • Workspace-aware commands like uv run --package <name> and uv sync --package <name> let you scope operations to specific members

Python monorepos used to be awkward. You’d either manage separate virtual environments per package (complex, slow, inconsistent) or do an elaborate dance with pip install -e across packages, PYTHONPATH manipulation, or Pants/Bazel (powerful, but heavyweight). Poetry added workspace support, but it was experimental and slow.

uv’s workspace feature, stabilised in v0.4, brings this solidly into the mainstream. If you’re running a Python codebase with multiple related packages — a shared library, a set of services, or an application split into components — workspaces are the right tool. Here’s how to set them up.

What a Workspace Is

A uv workspace is a directory with a root pyproject.toml that declares one or more member packages. Each member has its own pyproject.toml with its own dependencies. uv resolves all dependencies across all members into a single virtual environment at the workspace root, with a single lockfile (uv.lock) that covers all packages.

my-project/
├── pyproject.toml          # workspace root
├── uv.lock                 # single shared lockfile
├── .venv/                  # single shared virtual environment
├── packages/
│   ├── core/
│   │   └── pyproject.toml  # workspace member
│   ├── api/
│   │   └── pyproject.toml  # workspace member
│   └── cli/
│       └── pyproject.toml  # workspace member

The root pyproject.toml declares the workspace:

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

That’s it. All subdirectories matching packages/* with their own pyproject.toml become workspace members automatically.

Declaring Cross-Package Dependencies

The main reason to use workspaces: you want api to depend on core, or cli to depend on both. In a workspace, local cross-package dependencies use path references:

# packages/api/pyproject.toml
[project]
name = "api"
version = "0.1.0"
dependencies = [
    "core",
    "fastapi>=0.110",
    "uvicorn>=0.29",
]

[tool.uv.sources]
core = { workspace = true }

The workspace = true declaration tells uv that core is a workspace member, not a PyPI package. uv installs it as an editable package from its local path — no pip install -e incantations, no PYTHONPATH manipulation. It just works, and it’s reflected in the lockfile so everyone on your team gets the same setup.

Installing and Running

From the workspace root:

# Install all workspace members and their dependencies
uv sync

# Install only a specific member and its dependencies
uv sync --package api

# Run a command in the context of a specific member
uv run --package api python -m api.main

# Run a script that imports from multiple workspace packages
uv run python scripts/data_pipeline.py

All commands run against the shared virtual environment. No activation required, no context switching between package directories.

Adding Dependencies

When adding a dependency to a specific package:

# Add pydantic to the api package
uv add --package api pydantic>=2.0

# Add a dev dependency to the cli package
uv add --package cli --dev pytest

uv updates the relevant pyproject.toml and re-resolves the full workspace dependency graph, updating uv.lock. The lockfile captures the resolved version for every package in the workspace, so uv sync is deterministic across machines and CI.

Workspace Root Dependencies

Sometimes you want dev tools that aren’t specific to any member — formatters, linters, testing utilities that operate across the whole workspace. Declare these at the workspace root:

# pyproject.toml (workspace root)
[project]
name = "my-project-workspace"
version = "0.1.0"

[tool.uv]
dev-dependencies = [
    "ruff>=0.4",
    "pyright>=1.1.370",
    "pytest>=8.0",
]

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

Root-level dev dependencies are available across the whole workspace. uv run ruff check packages/ works because ruff is installed into the shared environment.

CI Integration

In CI, the workflow is:

# GitHub Actions
- uses: astral-sh/setup-uv@v4
  with:
    enable-cache: true

- name: Install workspace
  run: uv sync --frozen

- name: Lint all packages
  run: uv run ruff check packages/

- name: Type check
  run: uv run pyright packages/

- name: Test api package
  run: uv run --package api pytest packages/api/tests/

- name: Test cli package
  run: uv run --package cli pytest packages/cli/tests/

The --frozen flag ensures CI uses the lockfile exactly as checked in, failing if there’s a lockfile inconsistency. enable-cache: true caches the uv download cache, making subsequent runs fast.

Excluding Members Selectively

If some packages in your directory tree shouldn’t be workspace members:

[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/experimental"]

Excluded directories are treated as external packages, not workspace members.

Comparing to the Alternatives

Separate virtual environments per package: Full isolation, but no shared lockfile, no cross-package local dependencies without explicit installs, and slow to set up. Best for truly independent packages that happen to live in the same repo.

Poetry workspaces: Similar concept but slower resolution, and Poetry’s workspace support is less mature. If you’re already using Poetry, the migration path to uv is straightforward.

Pants / Bazel: Much more powerful build systems with fine-grained caching, but require significant configuration investment. Appropriate for very large codebases with complex build graphs. uv workspaces are the right choice up to hundreds of packages; Pants becomes useful beyond that.

pip install -e in a single venv: Works but has no lockfile discipline, cross-package dependency resolution is manual, and the install order can matter in non-obvious ways. uv workspaces formalise this pattern and make it reliable.

When Workspaces Make Sense

Use workspaces when:

  • You have multiple Python packages that share dependencies or depend on each other
  • You want a single lockfile and a single virtual environment across the repository
  • You want uv run and uv sync to work from any directory in the repo
  • You’re tired of the manual editable install ceremony

You probably don’t need workspaces when:

  • You have one Python package per repository
  • Your packages are fully independent and share no dependencies
  • You’re targeting an environment that requires fully isolated package sets

For teams managing a FastAPI service alongside a shared data model package and a CLI tool, uv workspaces are the correct tool and the setup takes about ten minutes.