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:
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.
- Single
uv syncsets 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
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
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",
]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 PyPIIf the distribution name differs from the package directory name, tell hatchling explicitly:
[tool.hatch.build.targets.wheel]
packages = ["src/my_app"]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").
[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"# 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 requestsTo 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-appThe wheel is self-contained and installable with pip in any environment — no workspace needed.
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
]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 lockTrade-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:
- We have many Python packages with a network of dependencies.
- They each live alone in GitLab with CI configuration per package that expects a lock file.
- It is very convenient to work with them in a uv workspace.
- 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.
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