Skip to content

Instantly share code, notes, and snippets.

@digitalsignalperson
Created February 27, 2026 06:08
Show Gist options
  • Select an option

  • Save digitalsignalperson/4cb6d6fcb615efb03261458cd7fa9333 to your computer and use it in GitHub Desktop.

Select an option

Save digitalsignalperson/4cb6d6fcb615efb03261458cd7fa9333 to your computer and use it in GitHub Desktop.
how im using uv workspaces atm

Disclaimer: Written by claude to turn my local projects structure into a short guide.

I also pointed it to mention the discussion from this issue astral-sh/uv#17351, as I was looking for a way to have nested projects with their own uv.lock and be be able to independently uv sync --frozen in the inner projects, while still having the convenience of an outer workspace containing everything. Another solution that seems ok is to make a sibling meta-package that acts as the workspace, using path sources to include all the sibling packages in one place.

The result:

uv Workspaces for Multi-Package Python Repos

A uv workspace lets you develop multiple Python packages in one repo with a single shared environment. One uv sync installs everything as editable installs — edit any package and changes are immediately available without reinstalling.

Why use a workspace

  • Single uv sync sets up the entire project
  • Editable installs — changes to any package are immediately visible across the repo
  • One lock file (uv.lock) at the root guarantees all packages are mutually compatible
  • Clean packaging — each package stays independently publishable to PyPI
  • { workspace = true } sources are uv-only and invisible to pip/other tools; the standard [project.dependencies] entries remain valid PEP 508

Structure

repo/
├── pyproject.toml          ← workspace root (no package of its own)
├── uv.lock                 ← single lockfile for the whole repo
└── packages/
    ├── my_lib/             ← a shared utility library
    │   ├── pyproject.toml
    │   └── src/
    │       └── my_lib/
    │           ├── __init__.py
    │           └── utils.py
    ├── my_app/             ← an application that uses my_lib
    │   ├── pyproject.toml
    │   └── src/
    │       └── my_app/
    │           ├── __init__.py
    │           └── main.py
    └── my_ext/             ← a C extension (scikit-build-core instead of hatchling)
        ├── pyproject.toml
        ├── CMakeLists.txt
        └── src/
            └── my_ext/
                └── __init__.py

Workspace root pyproject.toml

The root has no [project] table — it only declares the workspace members. Dev dependencies here (ipython, pytest) are available across the whole workspace.

[tool.uv.workspace]
members = [
    "packages/my_lib",
    "packages/my_app",
    "packages/my_ext",
]

[tool.uv]
dev-dependencies = [
    "ipython>=8.0.0",
    "pytest>=7.0.0",
]

Package pyproject.toml — pure Python (hatchling)

uv has its own uv_build backend, but hatchling is a common choice for pure-Python packages because it supports plugins like hatch-vcs for git-based versioning. The [tool.uv.sources] block tells uv to use the local workspace copy instead of PyPI during development; it has no effect when the package is published.

[project]
name = "my-app"
dynamic = ["version"]           # version comes from git, not hardcoded
requires-python = ">=3.10"
dependencies = [
    "my-lib",           # standard PEP 508 — what gets published
    "polars>=1.0.0",
]

[project.scripts]
my-app = "my_app.main:main"

[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[tool.hatch.version]
source = "vcs"

[tool.hatch.version.raw-options]
version_scheme = "no-guess-dev"     # always emit a version, never guess
search_parent_directories = true    # find .git even if it's above the package dir

[tool.uv.sources]
my-lib = { workspace = true }   # uv-only: use local package instead of PyPI

If the distribution name differs from the package directory name, tell hatchling explicitly:

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

Dynamic versioning with hatch-vcs

hatch-vcs reads the version from git tags at build time — no manual version bumping. The version in installed metadata reflects the exact repo state:

0.0.post1.dev42+gabc1234       # 42 commits after last tag, clean
0.0.post1.dev42+gabc1234.d20260227  # same but with uncommitted changes
1.2.0                          # exactly on a tag

no-guess-dev means: if there's no tag, report commits since the initial commit rather than guessing a future version. search_parent_directories = true is needed when the package lives in a subdirectory of the git repo (the common monorepo case).

Expose the version in __init__.py so my_app.__version__ works:

from importlib.metadata import version as _version
__version__ = _version("my-app")   # use the distribution name (may differ from import name)

Note: importlib.metadata.version() takes the distribution name (hyphenated, as in pyproject.toml), not the import name. For a package named my-app, imported as my_app, use _version("my-app").

Package pyproject.toml — C extension (scikit-build-core)

[project]
name = "my-ext"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["numpy>=1.20"]

[build-system]
requires = ["scikit-build-core"]
build-backend = "scikit_build_core.build"

[tool.scikit-build]
wheel.packages = ["src/my_ext"]
cmake.build-type = "Release"

Daily workflow

# First time / after adding a new package:
uv sync

# Run anything in the workspace environment:
uv run my-app
uv run python -c "import my_lib"
uv run pytest

# Add a dependency to a specific package:
uv add --package my-app requests

Building individual packages

To build a standalone wheel for deployment (e.g. Docker), build from the workspace root:

uv build --package my-ext        # builds packages/my-ext into dist/
uv build --package my-app

The wheel is self-contained and installable with pip in any environment — no workspace needed.

Workspace discovery and isolation

uv discovers the workspace root by walking up the directory tree. This means running uv sync from inside any member directory uses the workspace root's .venv and uv.lock — not any local .venv that may exist in that subdirectory.

If a package needs a fully isolated environment (e.g. it ships its own Docker image with a pinned uv.lock), remove it from the workspace members list. It can still live in the same repo directory; uv will no longer discover it as part of the workspace, and uv sync inside that directory will use its own local uv.lock.

# In the workspace root — to isolate my_ext from the workspace:
[tool.uv.workspace]
members = [
    "packages/my_lib",
    "packages/my_app",
    # "packages/my_ext",   ← removed; now standalone
]

Alternative: path sources instead of workspace membership

If you need each package to keep its own uv.lock — e.g. for per-package CI pipelines that lock and test independently — you can use { path = ..., editable = true } sources instead of a workspace. The package stays out of the workspace members list and manages its own environment, but other packages can still reference it locally:

# In my-app/pyproject.toml
[project]
dependencies = ["my-lib"]

[tool.uv.sources]
my-lib = { path = "../my-lib", editable = true }

Each package can then be locked and synced independently:

cd packages/my-lib && uv lock && uv sync
cd packages/my-app && uv lock && uv sync   # resolves my-lib from path, gets its own lock

Trade-offs vs workspace:

Workspace Path sources
Lock file Single shared uv.lock One per package
uv sync One command from root Must cd into each package
Compatibility guarantee Enforced by shared resolution Not enforced — packages may drift
Per-package CI lock Not supported Natural — each package locks itself
Editable cross-package changes Yes Yes (with editable = true)

This is currently a known limitation of uv workspaces: there is no way to get both the convenience of workspace-style development and independent per-member lock files. The path-source approach is the practical workaround. See uv#17351 for discussion, including this summary of the problem from a commenter:

  1. We have many Python packages with a network of dependencies.
  2. They each live alone in GitLab with CI configuration per package that expects a lock file.
  3. It is very convenient to work with them in a uv workspace.
  4. But I still need to create a lock file for a particular member for CI purposes.

At the moment I'm crudely approximating a workspace by using source deps instead. Then locking each member is business as usual (cd member-dir; uv lock) but some workspace conveniences are lost.

Versioning in a monorepo

All packages in the same git repo share the same version derived from git state — the version reflects the repo as a whole, not individual packages. This is fine for monorepos where everything ships together.

For per-package versioning via tag prefixes (e.g. my-lib-v0.2.0 vs my-app-v1.0.0):

[tool.hatch.version.raw-options]
version_scheme = "no-guess-dev"
search_parent_directories = true
tag_regex = "^my-lib-v(?P<version>.+)$"   # only tags prefixed with "my-lib-v" count
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment