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)
  • make installed (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-workspaces scaffolds a project with a pyproject.toml, a sample main.py, a README.md, and a .python-version file.
  • rm main.py README.md removes the placeholders. The root of this workspace coordinates members rather than shipping its own code, so it needs no entry point.
  • The generated pyproject.toml starts 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 *.pyc exclude Python bytecode generated at runtime.
  • .venv/ excludes the single virtual environment uv creates at the workspace root and shares across every member.
  • .pytest_cache/ and dist/ exclude test caches and build artifacts.
  • .DS_Store, *.log, and tmp/ 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/greetings creates a library member with a src/greetings/ layout and a [build-system] using uv’s own build backend. Libraries are meant to be imported, so the --lib layout is import-first.
  • uv init --package packages/cli creates an application member that also gets a [project.scripts] entry, so cli becomes a runnable console command.
  • Running uv init inside an existing project registers each new package in the root’s [tool.uv.workspace] members list automatically. After both commands the root pyproject.toml lists packages/greetings and packages/cli explicitly; 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 under packages/ 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] with dev = ["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

  • greet returns 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, so from greetings import greet works directly. The cli member 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 greetings without 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 greet imports across member boundaries. This resolves because the next command tells uv that greetings is a sibling member.
  • uv add greetings --package cli adds greetings to the cli member’s dependencies and writes [tool.uv.sources] with greetings = { workspace = true }. The workspace = true source 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.
  • main accepts an optional argv. At runtime the cli console script passes nothing, so parse_args(None) reads sys.argv[1:] as usual. Tests pass an explicit list. Without this parameter, a test that calls main() 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 greetings tests import and assert on the return value directly.
  • The cli tests call main with an explicit argument list and use pytest’s capsys fixture to capture stdout. Passing [] and ["--name", "Workspace"] exercises both the default and the custom-name paths without touching sys.argv.
  • Because pytest is a root dev dependency and the members install into the shared environment, one pytest run 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 := help makes plain make print the help screen instead of running a build target. The help recipe extracts every target annotated with a ## comment and formats it into a list.
  • sync uses uv sync --all-packages. The --all-packages flag installs every member into the shared environment. A plain uv sync would only install the root and its dev group, leaving the members’ code out of the environment.
  • run invokes the cli console script through uv run --package cli. The --package flag selects which member’s environment and scripts to use. The optional NAME variable maps to --name, so make run NAME=Workspace passes it through.
  • test uses --all-packages for the same reason as sync: pytest can only import a member’s code if that member is installed.
  • tree runs uv tree, which prints the resolved dependency graph including the workspace edges. lock refreshes the single uv.lock that covers the whole workspace.
  • clean removes bytecode and pytest caches. The || true suffix keeps make from 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. Run make sync (which uses uv sync --all-packages); a plain uv sync does not install members that the root does not depend on.
  • error: Failed to parse: pyproject.toml / member not found: Confirm each member directory sits under packages/ so the members = ["packages/*"] glob matches it, and that each member has its own pyproject.toml.
  • CLI tests fail with SystemExit: 2: A test called main() with no argument, so argparse parsed pytest’s own flags. Pass an explicit list such as main([]), 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 with curl -LsSf https://astral.sh/uv/install.sh | sh and restart your shell.

Recap

This tutorial built a uv workspace with:

  1. A virtual workspace root ([project] without [build-system]) that owns one lockfile and one .venv
  2. A greetings library member and a cli application member under packages/
  3. A workspace source (greetings = { workspace = true }) so the app depends on the local library and sees edits live
  4. Per-member tests collected in a single pytest run
  5. 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.