You have a core library that three separate services depend on. Every time you update that library, you have to manually bump versions in three different pyproject.toml files, then hope your CI pipeline is using the same dependency snapshot across all of them. You have been managing this with a shared requirements file, but it keeps falling out of sync. There is a better way — one lockfile for your entire project, automatic intra-package resolution, and a single uv sync command that keeps every package consistent.
In this tutorial, you will learn to:
- Understand what a uv workspace is and why it solves monorepo-style Python project management
- Configure a workspace with a root
pyproject.tomland member packages - Use shared lockfile resolution and intra-workspace dependencies correctly
- Apply workspaces to microservices, plugin architectures, and CI/CD pipelines
- Compare uv workspaces with Poetry and npm workspaces, and understand current limitations
Page Contents
What Is a uv Workspace?
A uv workspace is a monorepo-style structure managed by uv where multiple Python packages live under a single root, share one uv.lock file, and resolve dependencies consistently across all members. Instead of each package managing its own virtual environment and lockfile, a workspace centralises configuration so that all packages are installed together and stay in sync.
The key properties of a workspace are:
- Shared lockfile: A single
uv.lockat the workspace root tracks every package across all members. There is no per-package lock drift. - Unified resolution: All packages in the workspace resolve against the same dependency graph. If
corepinsrequests>=2.28andapipinsrequests>=2.31, uv picks a version that satisfies both. - Intra-workspace dependencies: Member packages can reference each other as dependencies using
uv add --workspace, with no need to publish to PyPI first. - Single
.venv: By default, the entire workspace shares one virtual environment at the root, keeping disk usage minimal.
Workspaces are ideal when you are building a system composed of multiple inter-dependent Python packages — a shared core library consumed by an api service and a worker service, for example — and you want version consistency without the overhead of a full Python package index infrastructure.
Prerequisites
This tutorial assumes you have completed the uv getting started guide and understand uv’s core commands (uv init, uv add, uv sync, uv run). Familiarity with pyproject.toml structure is also expected.
Setting Up a Workspace
A workspace begins with a root pyproject.toml that declares its members. There are two ways to define a workspace: explicitly via the [tool.uv.workspace] table, or implicitly by letting uv discover member packages from the filesystem.
The Root pyproject.toml
Create the workspace root directory and a root pyproject.toml:
mkdir my-workspace && cd my-workspace
uv init --no-readme
Open the generated pyproject.toml and add the workspace table:
[project]
name = "my-workspace"
version = "0.1.0"
requires-python = ">=3.12"
[tool.uv.workspace]
members = ["packages/*"]
The members field accepts glob patterns relative to the workspace root. "packages/*" tells uv to treat every direct subdirectory of packages/ as a workspace member. You can also use explicit paths:
[tool.uv.workspace]
members = ["packages/core", "packages/api", "packages/worker"]
Creating Member Packages
Create the member package directories inside packages/:
mkdir -p packages/core packages/api packages/worker
Each member needs its own pyproject.toml. Create them with uv init inside each directory:
cd packages/core && uv init --no-readme
cd ../api && uv init --no-readme
cd ../worker && uv init --no-readme
Each member’s pyproject.toml is a normal package definition. For example, packages/core/pyproject.toml looks like this:
[project]
name = "core"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The workspace root’s pyproject.toml does not need a [project] table — it can be a pure workspace manifest with only [tool.uv.workspace]. However, if you want the root itself to also be an installable package, you can add one.
Workspace Directory Structure
After setup, your workspace looks like this:
my-workspace/
├── pyproject.toml # root workspace manifest
├── uv.lock # shared lockfile (created by uv sync)
├── .venv/ # shared virtual environment
└── packages/
├── core/
│ ├── pyproject.toml
│ └── src/
│ └── core/
│ └── __init__.py
├── api/
│ ├── pyproject.toml
│ └── src/
│ └── api/
│ └── __init__.py
└── worker/
├── pyproject.toml
└── src/
└── worker/
└── __init__.py
Notice that uv.lock lives at the workspace root, shared by all three packages. No lockfile exists inside individual packages/* directories.
Workspace Dependency Resolution
When you run uv sync at the workspace root, uv reads all member pyproject.toml files and produces a single resolved dependency graph stored in uv.lock. Every package in the workspace is installed into the shared .venv.
Adding External Dependencies
Add dependencies to any member using uv add from the workspace root. You can target a specific package with the --package flag:
uv add requests --package core
uv add fastapi uvicorn --package api
uv add celery redis --package worker
You can also cd into a package directory and run uv add there — uv automatically finds the workspace root and applies the change to the correct pyproject.toml.
Shared Version Consistency
Imagine your core package and your api package both depend on pydantic. With a workspace, you never end up with core using pydantic v2.4 and api using v2.5 — uv resolves a single version that satisfies all constraints and pins it in the shared uv.lock. Run uv lock to refresh the lockfile or uv sync to install.
# Always run from the workspace root
uv lock # update uv.lock
uv sync # install all packages matching uv.lock
uv sync --all-packages # include dev and optional groups too
Note: By default,
uv syncinstalls only themaindependency group for each package. Useuv sync --all-groupsto include dev dependencies, oruv sync --all-packages --all-groupsto be thorough.
Intra-Workspace Dependencies
The most powerful feature of workspaces is referencing sibling packages without publishing them to PyPI. This is done with the --workspace flag when adding a dependency.
Referring to Sibling Packages
Suppose your api package needs to import from core. From the workspace root:
uv add core --package api --workspace
This adds core as a dependency of api in packages/api/pyproject.toml:
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["core"] # workspace reference
When uv resolves the workspace, it reads packages/core‘s pyproject.toml directly — no PyPI publication step is needed. This makes local multi-package development seamless: you can make a change in core, and api immediately sees the new version in the same .venv.
How Intra-Workspace Dependencies Work Under the Hood
Internally, uv maps the workspace member name (e.g., "core") to its source directory (packages/core). When the workspace is locked, uv records the exact local path in uv.lock alongside resolved external dependencies. This means uv.lock is fully self-contained — it encodes both the local package path and the external package versions.
After adding an intra-workspace dependency, inspect the uv.lock entry for your workspace member — the source field will show a local path rather than a PyPI URL, confirming the intra-workspace resolution:
[[package]]
name = "core"
source = { registry = { url = "file:///home/user/my-workspace/packages/core" } }
version = "0.1.0"
Use Case: Microservices and Plugin Architectures
Workspaces shine for two common real-world patterns: microservices-style architectures and plugin or extension systems.
Microservices: Shared Core + Multiple Services
Imagine a data pipeline with three components:
packages/core— shared data models, validation utilities, database connectorspackages/api— FastAPI HTTP service that exposes data to clientspackages/worker— Celery background job processor that consumes from a queue
Both api and worker depend on core. With a workspace, you model this cleanly:
# Add core as a workspace dependency to both api and worker
uv add core --package api --workspace
uv add core --package worker --workspace
# packages/api/pyproject.toml
[project]
name = "api"
version = "0.1.0"
dependencies = ["core"] # intra-workspace
# packages/worker/pyproject.toml
[project]
name = "worker"
version = "0.1.0"
dependencies = ["core"] # intra-workspace
Now run uv sync once at the workspace root, and both services get core installed in the exact same version. When you update core‘s data models, a single uv lock && uv sync propagates the change to both services atomically.
Plugin Architecture: Core Package + Extensions
For extensible applications, workspaces let you build a core package that defines plugin interfaces, with extension packages that implement them. For example:
packages/core— defines aPluginprotocol and registers plugins via entry pointspackages/plugin-jupyter— a Jupyter integration pluginpackages/plugin-cli— a CLI plugin
Each plugin lists core as a workspace dependency. The application at packages/core discovers plugins via entry_points defined in its own pyproject.toml. Because all packages are in the same .venv, entry point discovery works without any additional path configuration.
# packages/core/pyproject.toml
[project]
name = "core"
version = "0.1.0"
[project.entry-points."myapp.plugins"]
jupyter = "plugin_jupyter:register"
cli = "plugin_cli:register"
uv Workspaces with Docker and CI
One of the strongest arguments for workspaces in production is reproducible builds. A workspace with a frozen lockfile is a hermetic build artifact — the same dependencies every time, regardless of what is published on PyPI since your last commit.
Using uv sync –frozen for CI
In a CI environment, you typically want to verify that uv.lock matches your pyproject.toml files exactly — without uv attempting to update the lockfile. Use --frozen to enforce this:
# Dockerfile
FROM python:3.12-slim
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Copy only the lockfile and manifests — no need to copy the whole project
COPY uv.lock pyproject.toml ./
COPY packages/ ./packages/
# Install dependencies without updating the lockfile
# --frozen guarantees an exact match with uv.lock
RUN uv sync --frozen --no-install-project
# Copy source code separately for better layer caching
COPY . .
# Install the workspace (all packages)
RUN uv sync --frozen
The two-step uv sync pattern is intentional: the first --no-install-project installs only the lockfile dependencies (external packages), and the second installs your local workspace packages. This maximises Docker layer cache hits — changes to your Python source code do not trigger re-downloading all pip packages.
CI Pipeline Example (GitHub Actions)
name: Test workspace packages
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Sync dependencies (frozen)
run: uv sync --frozen
- name: Run tests for all packages
run: uv run pytest --tb=short
Tip: Add
uv lock --checkas a CI step to fail builds whenuv.lockis stale. This prevents lockfile drift from sneaking into your main branch.
Comparison with Poetry Workspaces and npm Workspaces
If you are coming from Node.js or have used Poetry before, uv workspaces have direct analogues — but with uv’s characteristic speed and simplicity.
| Feature | uv Workspaces | Poetry Workspaces | npm Workspaces |
|---|---|---|---|
| Configuration location | [tool.uv.workspace] in root pyproject.toml | [tool.poetry.workspace] in root pyproject.toml | "workspaces" in package.json |
| Shared lockfile | Yes — uv.lock at root | Yes — poetry.lock at root | Yes — single package-lock.json |
| Intra-package references | uv add --workspace | Poetry discovers from glob, uses path dependencies | Uses workspace protocol: "workspace:*" |
| Virtual environment | Shared .venv/ at root by default | Shared or per-package via config | Shared node_modules/ |
| Native tool speed | Extremely fast (Rust) | Moderate (Python) | Fast (Node.js native) |
| Lockfile format | TOML (uv.lock) | Custom TOML (poetry.lock) | JSON (package-lock.json) |
CI --frozen equivalent | uv sync --frozen | poetry install --no-update | npm ci |
Key Practical Differences
Speed: uv resolves and installs packages orders of magnitude faster than Poetry. For a workspace with 10+ members and dozens of transitive dependencies, uv sync typically completes in under a second after the first install.
Python version pinning: uv requires all workspace members to share a compatible requires-python range. If core requires ">=3.10" and api requires ">=3.12", uv will error at lock time. This is a strictness that catches genuine compatibility issues early.
Monorepo ergonomics: uv’s --package flag makes it easy to target a specific member from the workspace root without cd-ing around. Poetry requires more navigation or explicit package path references.
Limitations and Gotchas
uv workspaces are powerful but come with rough edges worth knowing before you commit to a monorepo structure.
No Nested Workspaces
uv does not support nested workspaces — a workspace member cannot itself be a workspace root. If you try to put a workspace inside a workspace, uv treats the inner workspace’s packages as regular directory packages, and the outer workspace’s lockfile may not capture dependencies correctly. This is a known limitation tracked in GitHub issue #16640.
Workaround: flatten your package structure. If you need logical grouping, use subdirectories within workspace members rather than nested workspaces.
package = false Is Not “Skip Me Entirely”
Setting package = false in a member’s pyproject.toml tells uv not to install that package as a dependency — it is used for non-Python project directories (documentation, scripts, config). It does not exclude the directory from the workspace. If a directory exists inside a workspace glob pattern and is a valid Python project, it is a workspace member regardless of package = false.
# packages/docs/pyproject.toml
[project]
name = "docs"
package = false # not installed — but still a workspace member
# This directory is still a workspace member.
# It just won't be installed as a dependency of other packages.
All Members Must Share a Compatible Python Version
uv requires the intersection of all members’ requires-python ranges to be non-empty. If one package requires Python 3.10+ and another requires 3.12+, uv cannot produce a single compatible lockfile. You will see an error like:
error: Unable to find a compatible version for package "core"
requires 3.12+
workspace requires 3.10+
The solution is to align requires-python across all workspace members before locking.
Per-Package Execution with uv run
When you run uv run inside a workspace, it uses the workspace’s shared .venv and installs all workspace members into it. This is usually what you want. However, if you need to run a script with only a specific package’s dependencies available, use uv run --package <name> from the workspace root:
# Run a script using only api's dependencies
uv run --package api python -c "import fastapi; print(fastapi.__version__)"
Publishing Individual Packages
uv workspaces are primarily for local monorepo development. If you need to publish individual packages to PyPI, you still do so manually using uv build and uv publish from each member directory. The workspace lockfile does not handle PyPI publishing — it is a development-time artifact only.
Practical Example: Building the Microservice Monorepo
Let us put everything together with a complete minimal example. We will create a three-package workspace with a shared core library and two services.
Step 1: Initialise the Workspace
mkdir microservice-monorepo && cd microservice-monorepo
uv init --no-readme
# Overwrite the generated pyproject.toml with workspace config
cat > pyproject.toml <=3.12"
[tool.uv.workspace]
members = ["packages/*"]
EOF
Step 2: Create the Packages
# Create sibling package directories under packages/
mkdir -p packages/core packages/api packages/worker
# Initialise core as a library package
cd packages/core && uv init --no-readme --lib
# Initialise api and worker as application packages
cd ../api && uv init --no-readme
cd ../worker && uv init --no-readme
# Back at workspace root
cd ../..
Step 3: Add Intra-Workspace Dependencies
uv add pydantic --package core
uv add core --package api --workspace
uv add core --package worker --workspace
uv add fastapi uvicorn --package api
uv add celery redis --package worker
# Lock and sync
uv lock && uv sync
Step 4: Verify the Setup
Create a verification script at the workspace root:
# verify.py — run with: uv run python verify.py
import core, api, worker
print("core:", core.__version__ if hasattr(core, "__version__") else "OK")
print("api: OK")
print("worker: OK")
Run it with uv run python verify.py. If this runs without ModuleNotFoundError, your workspace is set up correctly. Both api and worker are importing core directly from the workspace’s shared .venv.
Summary and Next Steps
uv workspaces bring monorepo-level dependency management to Python projects without the overhead of managing separate virtual environments and per-package lockfiles. The shared uv.lock, intra-workspace --workspace references, and single .venv make multi-package Python projects as easy to manage as a single-package project — while keeping packages cleanly separated in the filesystem.
- If you are starting a new multi-package project, begin with a workspace from day one rather than splitting into separate repositories later
- Use
uv sync --frozenin CI to guarantee reproducible builds - Keep all
requires-pythonranges aligned across members to avoid lock-time errors - For production, consider pairing workspaces with a private PyPI index for publishing — the workspace is a development tool, not a distribution mechanism
To go deeper, see the official uv workspaces documentation on Astral Docs, and explore the uv Python Package Manager guide for setting up uv itself.
Frequently Asked Questions
Can a uv workspace member have its own dev dependencies that are not shared?
Yes. Each member’s [dependency-groups] (PEP 735) define dev dependencies scoped to that package. Run uv sync --all-packages --all-groups from the workspace root to install all groups, or target a specific package with uv sync --package <name> --all-groups.
How do I add a new package to an existing workspace?
Create the directory, add a pyproject.toml, and ensure its parent path matches a glob in the workspace root’s [tool.uv.workspace]. Then run uv sync — uv automatically discovers the new member.
Can I use uv workspaces with pre-commit hooks?
Yes. Add uv lock --check as a pre-commit step to ensure the lockfile is always up-to-date before a commit is made. This prevents uv.lock drift from sneaking into your repository.

