TL;DR

A uv workspace lets several Python packages live in one repo, share a single uv.lock and a single .venv, and import each other as editable dependencies. You define members with a glob in the root pyproject.toml, mark cross-package deps with { workspace = true }, and run uv sync once for the whole tree. It’s the closest Python has to Cargo workspaces. This guide builds a two-package monorepo end to end, shows the real command output, and covers the cases where a workspace is the wrong tool.

Why I moved a repo into a uv workspace

I had a small internal repo with three moving parts: a shared HTTP/client library, an API service, and a batch worker. All three used the library, and all three lived as separate Poetry projects with their own lockfiles. Every dependency bump meant editing three pyproject.toml files, re-locking three times, and praying the transitive versions still lined up across services. They usually didn’t.

Moving that repo to a uv workspace collapsed it to one lockfile and one virtualenv. A bump happens once, resolves once, and every package sees the same resolved graph. The uv docs describe workspaces as being “inspired by the Cargo concept,” and that framing is accurate: if you’ve used Rust workspaces or an Nx/Turborepo setup in JS, the model will feel familiar. If you haven’t, the mental model is short — many packages, one resolution.

This is a hands-on tutorial. By the end you’ll have a working monorepo with a shared library, an app that imports it, shared dev tooling, and per-package builds. I ran every command below on uv 0.11.x with CPython 3.12. Workspaces are one slice of the wider stack; my Python tooling guide covers where uv sits among the type checkers, linters, and formatters.

What a uv workspace actually is

Three facts do most of the work:

  1. One lockfile. The whole workspace resolves together into a single uv.lock at the root. Every member gets a consistent set of versions. No more “the API pinned httpx 0.27 but the worker pinned 0.25.”
  2. One environment. uv creates a single .venv at the root and installs every member plus their dependencies into it. Your editor points at one interpreter and every package resolves.
  3. Editable member deps. When package A depends on package B in the same workspace, B is installed as an editable source. Edit B’s code and A sees the change immediately, no reinstall.

There’s a tradeoff baked into that design: because members resolve together, they must agree on a compatible dependency set and a common requires-python. Workspaces are the wrong choice when two packages genuinely need conflicting versions of the same library. I’ll come back to that.

1
lockfile for the whole repo
1
shared .venv
N
packages, one uv sync

Step 1: The layout we’re building

Here’s the target tree. A shared library under packages/, an app under apps/, and a virtual root that ties them together:

mymono/
├── pyproject.toml          # workspace root (virtual — no [project])
├── uv.lock                 # single lockfile for everything
├── packages/
│   └── core/               # shared library
│       ├── pyproject.toml
│       └── src/core/__init__.py
└── apps/
    └── api/                # app that depends on core
        ├── pyproject.toml
        └── src/api/__init__.py

Splitting packages/ (libraries) from apps/ (deployables) is just a convention; uv only cares about the globs you give it. I like the split because “which of these do I actually ship?” has an obvious answer.

Step 2: Create the workspace root

Make the directory and write the root pyproject.toml. The root here is a virtual workspace: it has no [project] table, so it’s never built or published. Its only job is to host the workspace config and the shared dev tooling.

mkdir mymono && cd mymono
# mymono/pyproject.toml
[tool.uv.workspace]
members = ["packages/*", "apps/*"]
exclude = ["packages/experimental"]

[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6"]

The members key is required and takes a list of globs; exclude is optional and does what it says. Every directory a glob matches has to contain its own pyproject.toml, or uv will complain. Putting pytest and ruff in a dev group at the root means one copy of your test runner and linter for the entire repo, shared by every package.

Step 3: Add the shared library

uv init run for a path inside a workspace creates the package and registers it as a member automatically. The --lib flag scaffolds a src/ library layout instead of an application:

uv init --lib packages/core

That writes packages/core/pyproject.toml and a starter src/core/__init__.py. Give the library something to export:

# packages/core/src/core/__init__.py
import httpx


def fetch_status(url: str) -> int:
    """Return the HTTP status code for a URL."""
    return httpx.get(url, timeout=5.0).status_code

Add its one runtime dependency. --package tells uv which member to edit, so you don’t have to cd around:

uv add --package core httpx

The library’s pyproject.toml now looks like this:

# packages/core/pyproject.toml
[project]
name = "core"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["httpx>=0.27"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Step 4: Add an app that depends on the library

Scaffold the app the same way, minus --lib since it’s a service:

uv init apps/api

On uv 0.12 and later, uv init gives new projects a build system and marks them as packaged by default, which is what lets the app’s console script install and run. On older uv you add the [build-system] block yourself.

Now wire api to depend on core. You add core to the app’s dependencies and tell uv to resolve the source from the workspace rather than PyPI. uv’s add command does both when it recognizes a workspace member:

uv add --package api core

That produces a [tool.uv.sources] entry pointing at the workspace:

# apps/api/pyproject.toml
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["core"]

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

[project.scripts]
api = "api:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

The { workspace = true } marker is the whole trick. Without it, uv would try to pull core from PyPI (and fail, or worse, install someone else’s package named core). With it, core resolves to your local packages/core as an editable install. Write the app so it uses the library:

# apps/api/src/api/__init__.py
from core import fetch_status


def main() -> None:
    code = fetch_status("https://example.com")
    print(f"example.com returned {code}")

Step 5: Sync once, run anything

One uv sync at the root is the payoff: it resolves and installs the entire workspace into a single .venv:

uv sync
Using CPython 3.12.7
Creating virtual environment at: .venv
Resolved 14 packages in 18ms
Installed 14 packages in 44ms
 + anyio==4.6.2
 + api==0.1.0 (from file:///mymono/apps/api)
 + certifi==2026.6.15
 + core==0.1.0 (from file:///mymono/packages/core)
 + h11==0.14.0
 + httpcore==1.0.6
 + httpx==0.27.2
 + idna==3.10
 + iniconfig==2.0.0
 + packaging==24.1
 + pluggy==1.5.0
 + pytest==8.3.3
 + ruff==0.6.9
 + sniffio==1.3.1

Note the core==0.1.0 (from file://...) line: your library went in as an editable local source rather than a downloaded wheel. pytest and ruff are there too, pulled from the root dev group with no extra flag. Now run the app from anywhere in the repo with --package:

uv run --package api api
example.com returned 200

uv run and uv sync operate on the workspace root by default; --package narrows them to a single member. uv lock always operates on the whole workspace, which is why there’s exactly one uv.lock. If you want to sync every member’s optional bits at once, uv sync --all-packages pulls the full set.

Step 6: Shared tooling that just works

Because everything shares one environment, your test runner and linter are configured once at the root and see every package. A type checker slots into that same root dev group next to ruff — the Pyrefly vs mypy vs ty comparison covers which one to reach for. Add a quick test for the library:

# packages/core/tests/test_status.py
from core import fetch_status


def test_returns_int():
    assert isinstance(fetch_status("https://example.com"), int)

Then run the dev-group tools against the whole tree:

uv run pytest
uv run ruff check .
========================= test session starts =========================
collected 1 item

packages/core/tests/test_status.py .                             [100%]

========================== 1 passed in 0.42s ==========================

One .venv also means your editor’s interpreter selection is a one-time decision. I point Cursor at mymono/.venv once and every package’s imports resolve, including cross-package ones, with no per-project interpreter juggling. That single-environment convenience is the day-to-day reason the setup stuck for me.

Step 7: Build a single package

Members are still real, publishable packages. Build just the library without touching the app:

uv build --package core
Building source distribution...
Building wheel from source distribution...
Successfully built dist/core-0.1.0.tar.gz
Successfully built dist/core-0.1.0-py3-none-any.whl

The wheel that comes out has a normal PyPI-style dependency on whatever core declares. The { workspace = true } source only applies inside your repo during development; it isn’t written into the published metadata, so downstream consumers get a standard package. That separation is what lets a monorepo library also be a public package without a second build system.

When a workspace is the wrong tool

Workspaces buy consistency by forcing shared resolution. That’s the wrong trade in two situations, both called out in the uv documentation:

  • Conflicting requirements. If one package needs pydantic<2 and another needs pydantic>=2, they can’t share a lockfile. A single resolution can’t satisfy both.
  • Deliberately separate environments. Sometimes you want isolation, so a broken dependency in one service can’t leak into another.

For those cases, skip the workspace and use a path dependency instead. It gives you the local-editable import without the shared resolution:

[tool.uv.sources]
core = { path = "../core", editable = true }

Each package then keeps its own lockfile and its own .venv, resolved independently. You lose the single-sync convenience and gain isolation. Pick based on whether your packages want to move together or apart.

Gotchas from real use

A few things bit me that the happy-path tutorials skip.

The biggest surprise is that a shared environment gives you no runtime isolation. All members live in one .venv, so any package can import anything installed for any other member, even a dependency it never declared. Those imports work locally and then blow up when the package is built and installed on its own. I now run ruff with an import-boundary check in CI so an undeclared import fails the build instead of shipping.

Your members also have to agree on requires-python. Because they resolve together, a >=3.11 in one package and a >=3.12 in another get reconciled against the intersection. Pick one floor as a repo convention and hold everyone to it, or you’ll get resolution errors that read as if a dependency were missing.

Two smaller ones. The root doesn’t have to be a package: a virtual root with no [project] table is the cleaner default for a pure monorepo, since you can never accidentally publish the container. And adding a member is automatic while cleanup stays manual, so packages/*-style globs pick up new packages with zero config edits, but you still prune stale directories yourself.

uv workspace vs the alternatives

ApproachLockfilesEnvironmentsCross-package editsBest for
uv workspace1 shared1 sharedEditable, instantPackages that ship and change together
Path dependencies1 per package1 per packageEditable, instantPackages that need version isolation
Separate repos + PyPI1 per repo1 per repoPublish + reinstallIndependently versioned public libraries

If your packages are tightly coupled and you deploy them from the same commit, the workspace wins on sheer friction reduction. If they’re loosely coupled and release on their own cadence, the shared lockfile becomes a straitjacket and path deps or separate repos serve you better.

For the single-package case — one CLI, one library, no monorepo — a workspace is overkill; my earlier uv tutorial that builds a CLI tool from scratch covers that path, and the uv vs pip vs Poetry comparison covers why uv is worth adopting at all. This guide is specifically for the moment you outgrow a single package.

FAQ

How do I set up a Python monorepo with uv workspaces?

Create a root pyproject.toml with a [tool.uv.workspace] table and a members glob (for example ["packages/*", "apps/*"]). Add each package as its own directory with its own pyproject.toml. Declare cross-package dependencies with { workspace = true } under [tool.uv.sources], then run uv sync once at the root.

Does a uv workspace use a single virtual environment?

Yes. The whole workspace shares one .venv and one uv.lock at the root. Every member and its dependencies install into that single environment, which is why an IDE only needs one interpreter configured.

How do workspace members depend on each other?

Add the sibling package to your dependencies list, then add a [tool.uv.sources] entry marking it as { workspace = true }. uv installs it as an editable local source, so code changes in the depended-on package are visible immediately.

When should I not use a uv workspace?

When members have conflicting requirements (two incompatible versions of the same library) or when you deliberately want isolated environments per package. In those cases use path dependencies ({ path = "...", editable = true }) so each package keeps its own lockfile and .venv.

How do I run or build just one package in a workspace?

Use the --package flag: uv run --package api api runs a specific member’s script, and uv build --package core builds a single member. uv sync --all-packages syncs everything at once.

Sources

Bottom Line

If you’re maintaining three pyproject.toml files that keep drifting out of sync, a uv workspace is the fix, and it takes about ten minutes to set up. One lockfile, one environment, editable cross-package imports, and each member still builds into a normal wheel when you need to ship it. The only real question is whether your packages want to move together or stay apart. If they move together, stop hand-syncing versions and let the workspace do it.