A uv workspace lets several related Python packages live in one repository, share a single lockfile and virtual environment, and depend on each other by name without publishing to a registry. This tutorial builds a two-package workspace: a greetings library and a cli application that imports it. A root Makefile drives sync, run, test, and inspection across every member with one command each.
By the end you will have a reproducible layout where editing the library is immediately visible to the application, and make test runs every package’s tests in one pass.
Prerequisites
- Python 3.12 or later
- uv 0.11 or later (
curl -LsSf https://astral.sh/uv/install.sh | sh) - A Unix-like terminal (macOS, Linux, or WSL on Windows)
makeinstalled (pre-installed on macOS and most Linux distributions)
A quick vocabulary note before the steps: the workspace root is the top-level project that owns the shared lockfile and lists the members. A member is a package inside the workspace (here, greetings and cli). A workspace source is a dependency that resolves to a sibling member instead of a package downloaded from PyPI.
Step 1: Initialize the workspace root
Create the file
uv init uv-workspaces
cd uv-workspaces
rm main.py README.md
Detailed breakdown
uv init uv-workspacesscaffolds a project with apyproject.toml, a samplemain.py, aREADME.md, and a.python-versionfile.rm main.py README.mdremoves the placeholders. The root of this workspace coordinates members rather than shipping its own code, so it needs no entry point.- The generated
pyproject.tomlstarts as an ordinary single project. Step 4 converts it into a workspace coordinator once the members exist.
Step 2: Add the project .gitignore
Create the file
touch .gitignore
Add the code: .gitignore
__pycache__/
*.pyc
.venv/
.pytest_cache/
.DS_Store
*.log
tmp/
dist/
Detailed breakdown
__pycache__/and*.pycexclude Python bytecode generated at runtime..venv/excludes the single virtual environment uv creates at the workspace root and shares across every member..pytest_cache/anddist/exclude test caches and build artifacts..DS_Store,*.log, andtmp/cover common OS and scratch files.- Add the ignore file before generating the rest of the project so caches and the virtual environment are never committed.
Step 3: Create the two member packages
Create the file
uv init --lib packages/greetings
uv init --package packages/cli
Detailed breakdown
uv init --lib packages/greetingscreates a library member with asrc/greetings/layout and a[build-system]using uv’s own build backend. Libraries are meant to be imported, so the--liblayout is import-first.uv init --package packages/clicreates an application member that also gets a[project.scripts]entry, soclibecomes a runnable console command.- Running
uv initinside an existing project registers each new package in the root’s[tool.uv.workspace]members list automatically. After both commands the rootpyproject.tomllistspackages/greetingsandpackages/cliexplicitly; Step 4 replaces that with a glob. - Both members share the root’s
.python-version, so every package resolves against the same interpreter.
Step 4: Turn the root into a workspace coordinator
Create the file
The root pyproject.toml already exists. Replace its contents with the coordinator configuration below.
Add the code: pyproject.toml
[project]
name = "uv-workspaces"
version = "0.1.0"
description = "Workspace root that coordinates the greetings and cli members"
requires-python = ">=3.12"
dependencies = []
[tool.uv.workspace]
members = ["packages/*"]
[dependency-groups]
dev = ["pytest>=8.0"]
Detailed breakdown
[tool.uv.workspace]is what makes this a workspace.members = ["packages/*"]matches every directory underpackages/with a glob, so a new package added later is picked up without editing this list. This replaces the two explicit entries uv wrote in Step 3.- The root has a
[project]table but no[build-system]. uv treats a project without a build system as a virtual root: its dependencies are installed into the shared environment, but the root itself is never built or packaged. [dependency-groups]withdev = ["pytest>=8.0"]installs pytest once at the root. Every member’s tests run against that single shared install, so there is no need to add pytest to each package.dependencies = []stays empty because the root pulls in members through the workspace table and the dev group, not through runtime dependencies.
Step 5: Write the library member
Create the file
uv init --lib already created packages/greetings/src/greetings/__init__.py. Replace its contents.
Add the code: packages/greetings/src/greetings/__init__.py
"""Reusable greeting logic shared across the workspace."""
def greet(name: str = "World") -> str:
"""Return a greeting for the given name.
Args:
name: The name to greet. Defaults to "World".
Returns:
A formatted greeting string.
"""
return f"Hello, {name}!"
Detailed breakdown
greetreturns a string instead of printing, so callers and tests can assert on the return value without capturing stdout.- The function lives in the package’s
__init__.py, sofrom greetings import greetworks directly. Theclimember imports it exactly that way in the next step. - Keeping the shared logic in its own member is the point of the workspace: any other package can depend on
greetingswithout copying code.
Step 6: Write the application member and wire the dependency
Create the file
Replace the generated entry point, then declare the dependency on the library.
Add the code: packages/cli/src/cli/__init__.py
"""Command-line entry point that consumes the greetings package."""
import argparse
from greetings import greet
def main(argv: list[str] | None = None) -> None:
"""Parse arguments and print a greeting from the greetings package.
Args:
argv: Optional argument list. Defaults to ``sys.argv[1:]`` when None,
which is what the ``cli`` console script passes at runtime. Tests
pass an explicit list so pytest's own arguments are never parsed.
"""
parser = argparse.ArgumentParser(description="Print a greeting.")
parser.add_argument(
"--name",
default="World",
help="Name to greet (default: World)",
)
args = parser.parse_args(argv)
print(greet(args.name))
Now add the workspace dependency:
uv add greetings --package cli
Detailed breakdown
from greetings import greetimports across member boundaries. This resolves because the next command tells uv thatgreetingsis a sibling member.uv add greetings --package cliaddsgreetingsto theclimember’sdependenciesand writes[tool.uv.sources]withgreetings = { workspace = true }. Theworkspace = truesource is the key line: it points the dependency at the local member instead of PyPI, and installs it as an editable link so library edits are visible to the app immediately.mainaccepts an optionalargv. At runtime thecliconsole script passes nothing, soparse_args(None)readssys.argv[1:]as usual. Tests pass an explicit list. Without this parameter, a test that callsmain()under pytest would parse pytest’s own flags (such as-q) and exit with an argparse error.
After this step, packages/cli/pyproject.toml contains:
dependencies = [
"greetings",
]
[project.scripts]
cli = "cli:main"
[tool.uv.sources]
greetings = { workspace = true }
Step 7: Add tests to each member
Tests live inside the member they cover, so each package is self-contained. pytest discovers both directories from the root.
Create the file
mkdir -p packages/greetings/tests packages/cli/tests
touch packages/greetings/tests/test_greet.py
touch packages/cli/tests/test_cli.py
Add the code: packages/greetings/tests/test_greet.py
"""Tests for the greetings package."""
from greetings import greet
def test_default_greeting() -> None:
assert greet() == "Hello, World!"
def test_custom_name() -> None:
assert greet("uv") == "Hello, uv!"
Add the code: packages/cli/tests/test_cli.py
"""Tests for the cli package."""
from cli import main
def test_cli_default_greeting(capsys) -> None:
main([])
captured = capsys.readouterr()
assert captured.out.strip() == "Hello, World!"
def test_cli_custom_name(capsys) -> None:
main(["--name", "Workspace"])
captured = capsys.readouterr()
assert captured.out.strip() == "Hello, Workspace!"
Detailed breakdown
- The
greetingstests import and assert on the return value directly. - The
clitests callmainwith an explicit argument list and use pytest’scapsysfixture to capture stdout. Passing[]and["--name", "Workspace"]exercises both the default and the custom-name paths without touchingsys.argv. - Because pytest is a root dev dependency and the members install into the shared environment, one
pytestrun collects tests from every member. There is no per-package test runner to configure.
Step 8: Create the Makefile
The Makefile gives every workspace-wide operation a single command. Running make with no arguments prints a help screen.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help sync run test tree lock clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
sync: ## Install every workspace member and dev tools into one shared .venv
uv sync --all-packages
run: ## Run the cli member (use NAME= to set a custom name)
uv run --package cli cli $(if $(NAME),--name "$(NAME)",)
test: ## Run the test suite across all workspace members
uv run --all-packages pytest -v
tree: ## Show the resolved dependency graph for the workspace
uv tree
lock: ## Update the single workspace lockfile (uv.lock)
uv lock
clean: ## Remove bytecode and cache files
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true
find . -name '*.pyc' -delete 2>/dev/null || true
Detailed breakdown
.DEFAULT_GOAL := helpmakes plainmakeprint the help screen instead of running a build target. Thehelprecipe extracts every target annotated with a##comment and formats it into a list.syncusesuv sync --all-packages. The--all-packagesflag installs every member into the shared environment. A plainuv syncwould only install the root and its dev group, leaving the members’ code out of the environment.runinvokes thecliconsole script throughuv run --package cli. The--packageflag selects which member’s environment and scripts to use. The optionalNAMEvariable maps to--name, somake run NAME=Workspacepasses it through.testuses--all-packagesfor the same reason assync: pytest can only import a member’s code if that member is installed.treerunsuv tree, which prints the resolved dependency graph including the workspace edges.lockrefreshes the singleuv.lockthat covers the whole workspace.cleanremoves bytecode and pytest caches. The|| truesuffix keepsmakefrom failing when there is nothing to delete.
Step 9: Run and validate
Run these commands from the uv-workspaces/ project root.
Verify the help screen
make
Expected output:
help Show this help screen
sync Install every workspace member and dev tools into one shared .venv
run Run the cli member (use NAME= to set a custom name)
test Run the test suite across all workspace members
tree Show the resolved dependency graph for the workspace
lock Update the single workspace lockfile (uv.lock)
clean Remove bytecode and cache files
Sync the workspace
make sync
This creates one .venv at the root and installs greetings, cli, and pytest into it, and writes a single uv.lock.
Run the application
make run
Expected output:
Hello, World!
Run with a custom name
make run NAME=Workspace
Expected output:
Hello, Workspace!
Run the tests
make test
Expected output:
collecting ... collected 4 items
packages/cli/tests/test_cli.py::test_cli_default_greeting PASSED [ 25%]
packages/cli/tests/test_cli.py::test_cli_custom_name PASSED [ 50%]
packages/greetings/tests/test_greet.py::test_default_greeting PASSED [ 75%]
packages/greetings/tests/test_greet.py::test_custom_name PASSED [100%]
============================== 4 passed in 0.00s ===============================
Inspect the dependency graph
make tree
Expected output:
uv-workspaces v0.1.0
└── pytest v9.1.1 (group: dev)
├── iniconfig v2.3.0
├── packaging v26.2
├── pluggy v1.6.0
└── pygments v2.20.0
greetings v0.1.0
cli v0.1.0
└── greetings v0.1.0
The last three lines show the workspace members. The edge from cli to greetings is the workspace source declared in Step 6. Exact pytest and transitive versions depend on when you run make sync.
Confirm live edits propagate
Because greetings installs as an editable workspace source, changing the library is visible to the app without reinstalling. Edit the return value in packages/greetings/src/greetings/__init__.py to f"Hi, {name}!", run make run, and the output changes immediately. Restore the original before moving on.
Troubleshooting
ModuleNotFoundError: No module named 'greetings': The environment is missing a member. Runmake sync(which usesuv sync --all-packages); a plainuv syncdoes not install members that the root does not depend on.error: Failed to parse: pyproject.toml/ member not found: Confirm each member directory sits underpackages/so themembers = ["packages/*"]glob matches it, and that each member has its ownpyproject.toml.- CLI tests fail with
SystemExit: 2: A test calledmain()with no argument, so argparse parsed pytest’s own flags. Pass an explicit list such asmain([]), as shown in Step 7. make: *** ... missing separator: Makefile recipes must be indented with tabs, not spaces. Many editors convert tabs to spaces by default.uv: command not found: Install uv withcurl -LsSf https://astral.sh/uv/install.sh | shand restart your shell.
Recap
This tutorial built a uv workspace with:
- A virtual workspace root (
[project]without[build-system]) that owns one lockfile and one.venv - A
greetingslibrary member and acliapplication member underpackages/ - A workspace source (
greetings = { workspace = true }) so the app depends on the local library and sees edits live - Per-member tests collected in a single
pytestrun - A Makefile whose default target prints help and whose targets drive sync, run, test, tree, lock, and clean across every member
Next improvements could include adding a third member and letting the packages/* glob pick it up automatically, adding ruff and mypy as root dev tools that lint every member, or splitting members into packages/ and apps/ directories with two glob entries in the workspace table.