Managing Python workspaces with uv
Table of Contents
What is uv #
uv is a Python project management tool that aims to replace many other tools in the Python ecosystem, such as pip, pip-tools, pipx, poetry, pyenv, twine, virtualenv, and more. It is backed by Astral, the same company behind the ruff linter.
Workspaces #
What made me look into uv is its capability to manage workspaces, a concept borrowed from Rust’s cargo.
A workspace is a collection of one or more workspace members managed together. Each member defines its own pyproject.toml, but there is a single uv.lock lockfile: this guarantees that there cannot be conflicting dependency requirements in the workspace. Each member can be either an application or a library: the difference between them is in their configuration.
Workspaces are a game-changer when you want to break a big Python project into smaller projects: this enables you to define clear boundaries between them and assign the required dependencies to each of them… and since a workspace is a single entity, workspace members can depend on each other locally!
I have created a uv-workspace-example repository with a minimalistic (but complete) example of uv workspace, including linting with ruff and an example of Dockerfile.
This example uses uv version 0.5.7, the latest at the moment of writing: uv is still being actively developed and has not yet reached version 1.x, so it’s important to use the same version.
Update 2025-10-27: Updated post to work with the latest version (0.9.5).
Layout #
This is what its layout looks like:
my-app/
|__ packages/
| |__ my_lib/
| |__ src/
| | |__ my_lib/
| | | |__ __init__.py
| |__ tests/
| | |__ __init__.py
| |__ pyproject.toml
|__ src/
| |__ my_app/
| |__ __init__.py
|__ tests/
| |__ __init__.py
|__ .dockerignore
|__ Dockerfile
|__ pyproject.toml
|__ uv.lock
This example follows the src layout, where Python code is stored inside a src directory. This aligns with uv’s packaged application layout.
Python tests are stored in a separate test directory, so that they can be easily excluded from published artifacts and/or Docker images. Python tests directories are arranged as packages, as per Pytest’s recommendation when using the default import process.
Definining a workspace #
This is what the root pyproject.toml looks like:
[project]
name = "my-app"
version = "0.1.0"
description = "An example of uv workspace"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [ # (3)
"my-lib",
]
[dependency-groups] # (4)
dev = [
"ruff >= 0.8.1",
"pytest >= 8.3.4",
]
[tool.uv.sources] # (1)
my-lib = { workspace = true }
[tool.uv.workspace] # (2)
members = [ "packages/*" ]
It defines that this is a workspace using the [tool.uv.sources] and [tool.uv.workspace] tables ((1) and (2), respectively): if those are absent, the repository is treated as a single Python project.
[tool.uv.sources] looks at the package name (my-lib) as defined in the package’s own pyproject.toml and not at the name of the packages/’s subdirectory (my_lib, note the snake case).
It also defines that my-app depends on my-lib ((3), note the absence of version constraints since it is a local dependency) and some dev dependencies ((4), only provided as an example of dependency groups).
With the exception of (1) and (2), it’s exactly what a typical application pyproject.toml file would look like.
A library pyproject.toml file needs to define the build system, as in the following example:
[project]
name = "my-lib"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = []
[dependency-groups]
dev = [
"ruff >= 0.8.1",
"pytest >= 8.3.4",
]
[build-system] # (1)
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] # (2)
packages = ["src/my_lib"]
(1) instructs uv to build using hatch as backend (other options are possible). (2) tells hatch which directories to include in the packaged artifact.
Dependency management #
Each workspace member defines its dependencies in its own pyproject.toml file: the root directory’s pyproject.toml file should only contain any dependencies that are needed by the root project and/or shared among all workspace members.
Cheatsheet #
Note: uv commands behave according to the pyproject.toml of the current directory, so running uv sync (to install dependencies) in the root directory gives you a different result than running it inside packages/my_lib.
The exception to the above is uv lock, which works at the workspace level.
For this reason, I find it useful to create a Makefile file in each workspace member’s directory, as well as one in the root directory to rule them all. You can see an example in the uv-example repository.
Adding packages to the workspace #
mkdir -p packages
uv init packages/another_app # create an application
uv init --package packages/another_app # create a packaged application
uv init --lib packages/another_lib # create a library
Installing dependencies #
uv sync # install current package's dependencies
uv sync --package my-lib # install dependencies of specified package
uv sync --all-packages # install dependencies of all packages
Running Python files #
uv run path/to/run.py
Building package(s) #
uv build # build current project
uv build --package my-lib # build a specific package
Build files are stored in the dist/ directory.
Publishing package(s) #
uv publish # publish all packages found in dist/
Working with the lockfile #
uv lock # update the lockfile (e.g. after manually changing pyproject.toml)
uv lock --check # check if the lockfile is still consistent with pyproject.toml
Linting #
Note: ruff must be listed among the dependencies or the following commands will not work.
# note: these will only report issues, without fixing them
uv run ruff check
uv run ruff format --check
# note: these will report and fix issues
uv run ruff check --fix
uv run ruff format
Running tests #
Note: pytest must be listed among the dependencies or the following command will not work.
uv run pytest tests