python.
python6 min read

uv Workspace Setup for a Multi-Package Python Monorepo

A working uv workspace layout for multi-package Python monorepos: root pyproject.toml, member pyproject.toml, local path dependencies, single lockfile strategy, and per-member commands.

uv Workspace Setup for a Multi-Package Python Monorepo

Most Python monorepo guides still default to Poetry path-deps or a chain of pip install -e calls. uv ships a first-class workspace concept that resolves all members against a single lockfile in one pass. Once the layout clicks, day-to-day work feels closer to a Cargo workspace than a 2022-era Python repo: one uv sync, one uv.lock, per-package commands via --package.

This article walks through a layout that holds up for 3-12 internal packages sharing FastAPI services, Pydantic schemas, and CLI tools. The same pattern scales smaller (2 packages) without ceremony.

Why a workspace, not a flat repo

Three options compete when you have multiple Python packages in one repo:

  1. One pyproject.toml at the root, every module under src/. Cheapest, but you can't publish or version packages independently and import boundaries blur.
  2. Independent packages each with their own venv, glued together by pip install -e ../sibling. Works, but every developer rebuilds the dependency graph manually and lockfiles drift between members.
  3. A uv workspace \u2014 one root pyproject.toml declares members, every member has its own pyproject.toml, one shared uv.lock resolves everything together.

Option 3 wins when packages are independent enough to test and version on their own but tight enough that you want a single dependency resolution. The break-even is around 2 internal packages with shared third-party deps; below that, option 1 is fine.

uv resolves the entire workspace in one solve. Adding a dependency to one member triggers a full re-resolve, so version drift across members is impossible by construction. That property alone justifies the migration from pip install -e chains.

Repository layout

your_project/
  pyproject.toml          # workspace root, no source
  uv.lock                 # single lockfile for all members
  packages/
    shared-schemas/
      pyproject.toml
      src/shared_schemas/
        __init__.py
        models.py
    api-gateway/
      pyproject.toml
      src/api_gateway/
        __init__.py
        main.py
    worker/
      pyproject.toml
      src/worker/
        __init__.py
        run.py

The root has no source code \u2014 only the workspace declaration and dev-only tooling like ruff and mypy configuration. Each member uses the src/ layout, which keeps tests honest (you import the installed package, not a sibling directory shadowed by sys.path).

Root pyproject.toml

[project]
name = "your-project-workspace"
version = "0"
requires-python = ">=3.12"

# Root is not published \u2014 keep dependencies empty here.
# Workspace members each declare their own.
dependencies = []

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

[tool.uv.sources]
shared-schemas = { workspace = true }

[dependency-groups]
dev = [
  "pytest>=8.0",
  "ruff>=0.5",
  "mypy>=1.10",
]

Two things matter here. [tool.uv.workspace] lists members by glob \u2014 packages/* picks up every directory under packages/ with a pyproject.toml. [tool.uv.sources] tells uv that any reference to shared-schemas from any member resolves to the workspace member, not PyPI. Without that block, a member declaring shared-schemas>=0.1 would try to install from the index and fail.

The dev group lives at the root because tools like ruff and mypy run across every member from the workspace root.

Member pyproject.toml (consumer)

[project]
name = "api-gateway"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "fastapi>=0.115",
  "uvicorn>=0.30",
  "shared-schemas",
]

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

[tool.hatch.build.targets.wheel]
packages = ["src/api_gateway"]

Note the dependency is "shared-schemas" with no version pin. The pin lives in the root [tool.uv.sources] block \u2014 workspace = true means "resolve to whatever version the workspace member is currently at." Pinning the version here would actively break things: a bumped sibling version would force a republish.

The hatchling build backend is uv's default recommendation. setuptools works too but generates more friction with src/ layouts.

Member pyproject.toml (provider)

[project]
name = "shared-schemas"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "pydantic>=2.7",
]

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

No special config needed on the provider side. The fact that this package is consumed via workspace = true is a property of the consumer, not the provider. That symmetry matters: the same package can be published to a private index later without changing this file.

Lockfile strategy: one root lock

uv writes a single uv.lock at the workspace root. It contains pinned versions for every member's transitive graph and is the only file you commit. Per-member lockfiles are not generated and would defeat the resolver guarantee.

When you run uv sync from anywhere inside the workspace, uv:

  1. Finds the workspace root by walking up for [tool.uv.workspace].
  2. Resolves all members against the existing lock.
  3. Installs the resolved set into a single .venv/ at the workspace root.
  4. Installs every member as editable into that venv.

That last step is the payoff. Edit packages/shared-schemas/src/shared_schemas/models.py, save, restart the FastAPI server in api-gateway \u2014 the change is live with no rebuild. uv handles editable installs transparently for workspace members.

Per-member commands with --package

# Run pytest in one member
uv run --package api-gateway pytest

# Add a dependency to one member
uv add --package worker httpx

# Build wheels for one member
uv build --package shared-schemas

# Sync the whole workspace
uv sync

--package scopes the operation. Without it, uv add httpx adds to the root, which is almost never what you want. Make a habit of it: every dependency change should answer "which member needs this?"

Migration from pip install -e

If you're moving an existing flat-multi-package repo:

  1. Add [tool.uv.workspace] members = ["packages/*"] to the root pyproject.toml.
  2. Add a [tool.uv.sources] entry for each internal package: name = { workspace = true }.
  3. Change every dependencies = [..., "../sibling"] style entry to a plain name reference ("sibling-name").
  4. Delete every per-member .venv/ and pip install -e script.
  5. Run uv sync once at the root.

A 7-package conversion runs in roughly 10 minutes if the layout already uses src/. Most of the time goes into auditing dependency declarations \u2014 the resolver is fast enough that re-syncing 200+ dependencies finishes in around 4-6 seconds on a warm cache, vs the 30+ seconds a pip install -e cascade typically took.

When uv workspace is not the right answer

Three cases push you back to separate repos or independent venvs:

  • Members ship to mutually-incompatible Python versions (e.g. one needs 3.10, another 3.13). uv enforces one requires-python floor across the workspace.
  • Members have sharply diverging dependency philosophies \u2014 a research notebook member that wants the latest of everything next to a production service that pins aggressively. The single-lock guarantee becomes a liability.
  • One member is open-source and the rest are private. Splitting the OSS member to its own repo is cleaner than reasoning about which files to publish.

For everything else \u2014 internal services, shared schemas, CLI utilities, sibling FastAPI apps \u2014 the workspace is the right call.

References