New in 2026: Master Python for AI, Data Science

Programming

uv Workspaces & Monorepo Support for Larger Python Projects: The Complete 2026 Guide

uv Workspaces & Monorepo Support for Larger Python Projects: The Complete 2026 Guide

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.toml and 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

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.lock at 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 core pins requests>=2.28 and api pins requests>=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 sync installs only the main dependency group for each package. Use uv sync --all-groups to include dev dependencies, or uv sync --all-packages --all-groups to 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 connectors
  • packages/api — FastAPI HTTP service that exposes data to clients
  • packages/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 a Plugin protocol and registers plugins via entry points
  • packages/plugin-jupyter — a Jupyter integration plugin
  • packages/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 --check as a CI step to fail builds when uv.lock is 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.

Featureuv WorkspacesPoetry Workspacesnpm Workspaces
Configuration location[tool.uv.workspace] in root pyproject.toml[tool.poetry.workspace] in root pyproject.toml"workspaces" in package.json
Shared lockfileYes — uv.lock at rootYes — poetry.lock at rootYes — single package-lock.json
Intra-package referencesuv add --workspacePoetry discovers from glob, uses path dependenciesUses workspace protocol: "workspace:*"
Virtual environmentShared .venv/ at root by defaultShared or per-package via configShared node_modules/
Native tool speedExtremely fast (Rust)Moderate (Python)Fast (Node.js native)
Lockfile formatTOML (uv.lock)Custom TOML (poetry.lock)JSON (package-lock.json)
CI --frozen equivalentuv sync --frozenpoetry install --no-updatenpm 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 --frozen in CI to guarantee reproducible builds
  • Keep all requires-python ranges 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.

Related posts
ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

ProgrammingPython

Replace pip with uv for Faster Python Development

Leave a Reply