monorepo.
monorepo12 min read

uv workspace source

uv tool.uv.sources for local package overrides — when sources beat dev-deps, lock semantics, registry fallback.

You add a second package to a Python monorepo, point the first one at it, run uv sync, and watch the resolver hit PyPI for a package name that only exists on your laptop. The install fails. You stare at the traceback, then at your colleague who insists they did this on Tuesday and it worked fine. They did. They have a [tool.uv.sources] block in their pyproject.toml and you do not. That block is the single configuration knob that turns "package name on PyPI" into "package right here on disk" without changing what the package's project.dependencies says.

This article walks four commits that demonstrate the pattern. We build a two-package uv workspace where app consumes lib, then watch the same dependency declaration resolve three different ways: to the in-tree workspace member, to a sibling fork checked out beside the workspace, and to a pinned git revision on GitHub. The registry-shaped name on project.dependencies never changes. Only the source override moves. That separation is what makes uv workspaces composable: every package stays publishable to a real index, while the workspace head decides where each name resolves locally.

If you are coming from Poetry's [tool.poetry.dependencies] with {path = "../lib"} or from npm workspaces' implicit member resolution, the shape here is different in a useful way. Dependencies and sources are two separate tables. Dependencies are what your package promises to consumers. Sources are how the current workspace materializes those promises. The split looks redundant at first; by the end of lesson 4 you will see why it isn't.

Lesson 1: Define the workspace (626a7ec)

A uv workspace is a directory with a top-level pyproject.toml that lists its members under [tool.uv.workspace]. The members are sibling Python projects, each with its own pyproject.toml, its own version, its own test suite. The root project is otherwise a normal package. It can have its own dependencies, its own version, and its own optional metadata. What makes it a workspace is the members list and the way uv resolves the listed projects as a single resolution unit.

Here is the root pyproject.toml in this lesson:

[project]
name = "uv-workspace-source-overrides-root"
version = "0.0.0"
description = "Workspace root for the uv source-override walkthrough."
requires-python = ">=3.12"
dependencies = ["app", "lib"]

[tool.uv.workspace]
members = ["packages/app", "packages/lib"]

[tool.uv.sources]
app = { workspace = true }
lib = { workspace = true }

[tool.uv]
dev-dependencies = ["pytest>=8.0"]

Notice the redundancy that seems unnecessary: every member of the workspace appears in project.dependencies AND in [tool.uv.sources] with workspace = true. The reason is that project.dependencies describes what this package consumes by name only. PyPI does not know that app and lib live in the same repo on your laptop. [tool.uv.sources] is the local override layer that says: when you see this name, do not resolve it from the index. Resolve it to a workspace member of the same name. Drop the sources block and uv sync will dutifully hit PyPI looking for app and lib, then fail.

Each member is a normal Python project. packages/lib/pyproject.toml declares its name, version, and build backend. Nothing workspace-aware. packages/app/pyproject.toml is the same shape. At this lesson, neither member depends on the other; the workspace is a flat container.

Run uv sync and uv builds both members into editable installs in .venv/. Run uv run pytest -q and you should see 3 passes, one per member's smoke test. Every subsequent lesson keeps the test count climbing, so the green bar is the cheapest way to know the source rewiring worked.

Lesson 2: Wire the consumer through tool.uv.sources (6dced3e)

Now app actually uses lib. The relevant change is two edits to packages/app/pyproject.toml:

[project]
name = "app"
version = "0.2.0"
description = "Consumer package, depends on lib via workspace source."
requires-python = ">=3.12"
dependencies = ["lib>=0.1.0"]

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

The dependencies = ["lib>=0.1.0"] line is what a published consumer sees. If someone runs pip install app from a real index in a fresh environment, pip reads that line, looks up lib on the configured index, and resolves to whatever's published there. The version constraint applies. The dependency relationship is recorded in app.dist-info/METADATA in standard PEP 508 form. Nothing workspace-specific leaks out.

The [tool.uv.sources] block is what the current uv workspace sees. When you run uv sync from the workspace root, uv reads app's dependencies, sees lib, then checks the sources tables for a same-name override. Both the root and the consumer can declare a source; uv coalesces at the workspace head, with the root winning on conflicts. With lib = { workspace = true } in app's pyproject, uv resolves lib to the workspace member at packages/lib, regardless of what PyPI has under that name.

The code in packages/app/src/app/__init__.py is now a thin wrapper:

from lib import greet


def run(name: str) -> str:
    return f"[app] {greet(name)}"

Running uv run pytest -q shows 4 passes. The new test_run_delegates_to_lib confirms that the import resolved across packages and that lib.greet's payload is what landed inside app.run's wrapper. If you were to comment out the source block and re-sync, uv would attempt to fetch lib from PyPI and the resolve would fail because the public lib package on PyPI is a different project entirely and does not export greet.

That distinction, dependencies for the world versus sources for here, is the whole reason [tool.uv.sources] is a separate table. Editable path entries inside project.dependencies would leak into the package metadata and break for downstream consumers. Sources keep your published metadata clean.

Lesson 3: Override with a sibling path source (ad6530f)

You are prototyping a change to lib and you do not want to touch the in-tree workspace member. You have a sibling checkout at lib-fork/, maybe a different git branch, maybe a colleague's experimental rewrite, and you want app to consume it for now. The minimal change is at the workspace root:

[project]
dependencies = ["app"]

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

[tool.uv.sources]
app = { workspace = true }
lib = { path = "lib-fork", editable = true }

Two things changed. First, packages/lib is no longer in the workspace members list. uv refuses to accept a path source for a name that is also a workspace member, because that conflict is exactly the kind of ambiguity workspaces are supposed to eliminate. Second, lib is now a path source pointing at lib-fork/, with editable = true so the fork's src/lib/__init__.py is wired into the venv and changes show up without a reinstall.

The fork ships a deliberately distinguishable greeting:

# lib-fork/src/lib/__init__.py
def greet(name: str) -> str:
    return f"Hello from lib (FORK), {name}!"

Run uv sync and watch the resolver swap lib==0.1.0 (from packages/lib) for lib==0.9.0 (from lib-fork). Run uv run pytest -q and the Hello from lib prefix assertion still passes because the fork's payload starts the same way. The new "(FORK)" suffix is now in every greeting at runtime. The whole switch costs roughly 1 second of resolve time on a warm cache; cold, it is closer to 3 seconds because uv has to build the fork's wheel.

Two things to notice in the lockfile. The lib entry now records the resolved path (not the workspace member), and the recorded version is 0.9.0, the fork's declared version. uv hashes the source contents, so a coworker who pulls and runs uv sync gets the same path resolution. If they don't have lib-fork/ on disk, the resolve fails loudly. Path sources are intentionally low-magic.

The in-tree packages/lib/ directory is still present. It is just dormant. uv no longer treats it as a workspace member or installs it. You can flip back by restoring the original members list and the { workspace = true } source, and the workspace returns to lesson 2's wiring with one uv sync.

Lesson 4: Pin a git revision (b5883f4)

Path sources are great for local prototyping but break the moment your CI runner doesn't have lib-fork/ checked out. The fix is a git source. uv clones the repo, checks out the revision you pinned, and installs the package from a subdirectory if you tell it where:

[tool.uv.sources]
app = { workspace = true }
lib = { git = "https://github.com/vytharion/uv-workspace-source-overrides", rev = "626a7ec", subdirectory = "packages/lib" }

rev accepts any git reference uv can resolve: short SHA, full SHA, branch name, tag. Lockfile reproducibility comes from the fact that uv resolves the symbolic ref to a full commit hash at lock time and pins that hash in uv.lock. A coworker who runs uv sync against the committed lockfile gets the exact same tree, even if you tagged a branch and the branch later moves.

subdirectory is the critical bit for monorepos. Without it, uv would try to install the repo's root pyproject.toml, which here is the workspace root, not a publishable lib package. With it, uv navigates to packages/lib/, treats that as the project root, and installs the standalone lib package defined there.

The first uv sync after the change pulls a fresh clone into uv's cache (~/.cache/uv/git-v0/...). Subsequent syncs reuse the cache. Bumping rev is the only thing that triggers a refetch. Production CI runners with a shared cache mount get this for free.

Registry fallback semantics: if the git URL becomes unreachable, the resolve fails. uv does NOT silently fall back to PyPI for the same name. This is by design. Silent fallback would mean a typo in your git URL leads to installing a totally unrelated lib package, with no signal that anything is wrong. Loud failure is the right default for source overrides. If you genuinely want a "try this, else fall back" behavior, layer multiple environments via uv's environment markers or split the dependency across optional groups.

When sources beat dev-deps

A common Poetry-shaped instinct is to push every local dependency into a dev-dependency group and call it a day. uv lets you do the same with [tool.uv] dev-dependencies or PEP 735's [dependency-groups]. But dev-deps and sources solve different problems. The table below maps the two axes:

Concernproject.dependencies + tool.uv.sourcesdev-dependencies / dependency-groups
Published package metadataClean. Only the registry name appears in METADATAClean. Dev-deps don't propagate to consumers either
Cross-package import in the same workspaceFirst-class. from lib import greet works at every commitOnly if the dev-dep also resolves the source override
Pin a git rev for reproducibilityFirst-class via { git = ..., rev = ... }Possible via a dev-dep URL, but applies only to dev installs
Override one consumer's resolution without touching anotherPer-pyproject sources tables stack with root sourcesDev-deps are package-wide
Production install behaviorSource layer doesn't apply outside the workspace; index name winsDev-deps are skipped at production install. No effect

The short version: dev-deps are for tooling (pytest, mypy, ruff), and sources are for routing real cross-package dependencies. Mixing the two by putting a workspace member into a dev-dependency group works in some cases but couples your install behavior to whether someone passed --dev to uv sync. Sources are the right primitive when the dependency relationship is real and should always be present.

For the upstream definition of the workspace + sources model, see the uv docs at https://docs.astral.sh/uv/concepts/projects/workspaces/ and the dependency-types reference at https://docs.astral.sh/uv/concepts/projects/dependencies/. PEP 735 (dependency groups) is the official spec for the dev-deps story uv now honors: https://peps.python.org/pep-0735/.

Lockfile semantics and what happens outside the workspace

Every source override gets a corresponding entry in uv.lock. The lockfile records the resolved source kind, version, and (for path or git sources) a content hash. A pinned git source looks roughly like this in the lock:

[[package]]
name = "lib"
version = "0.1.0"
source = { git = "https://github.com/vytharion/uv-workspace-source-overrides", subdirectory = "packages/lib", rev = "626a7eccdb5f8cd554a791fa0e0bc23867d4c16f" }

Notice the short rev you wrote in pyproject.toml (rev = "626a7ec") is expanded to a full 40-character commit hash. That expansion is what makes the lock reproducible. A teammate who clones the repo a year from now sees the exact same commit, even if you also moved a branch. Check uv.lock in. Do not edit it by hand.

The flip side: nothing in [tool.uv.sources] reaches a downstream consumer. If you publish app to a real index and a stranger runs pip install app, they see dependencies = ["lib>=0.1.0"] and pip looks for lib on the index. Source overrides are workspace-local always. That is why you can keep cross-package wiring clean in the monorepo without making your published packages depend on internal infrastructure. It is also why you cannot ship a path-source lib to PyPI and have it Just Work for users. The name has to exist somewhere they can install from.

A pragmatic pattern for monorepos that publish: keep project.dependencies written for the published case, keep [tool.uv.sources] written for the local case, and use CI to publish each member separately with a release script that maps the source override back to a stable version constraint on the real index. The upstream uv project follows this pattern itself, which makes the source code at https://github.com/astral-sh/uv worth skimming when you need a real-world reference for how the workspace declarations look at scale.

Repository

Full source at https://github.com/vytharion/uv-workspace-source-overrides.

  • Lesson 1 → 626a7ec. Define the workspace skeleton with two members.
  • Lesson 2 → 6dced3e. App depends on lib via workspace source.
  • Lesson 3 → ad6530f. Path source for a sibling checkout.
  • Lesson 4 → b5883f4. Git source pinned to a revision.

Clone, uv sync, and uv run pytest -q at every checkout. Every commit leaves the workspace runnable.

Closing

[tool.uv.sources] is one of the smallest configuration knobs uv exposes and one of the most useful in a polyglot monorepo. The discipline is straightforward. Keep project.dependencies honest about what your package promises consumers, and let the sources table handle the local plumbing. Workspace members for the common case, path sources for sibling prototyping, git sources for reproducible cross-repo pins. Switching between them is a single edit in one file. The lockfile records each choice; CI sees the same resolved tree your laptop did. For anyone coming from npm workspaces or Cargo's path dependencies, the redundancy in declaring both a dependency and a source pays for itself the first time you publish.