uv workspace: effective management of Python apps
Understanding uv Workspaces
The official uv website explains workspaces very clearly:
Inspired by Cargo, a uv workspace is a collection of one or more Python packages (workspace members) managed together in a single repo. Each package has its own
pyproject.toml, but the workspace shares onelockfile, keeping dependencies consistent across apps and libraries. Commands likeuv lockoperate on the whole workspace, whileuv runanduv syncdefault to the workspace root but can target a specific member via--package1.
In practice, a uv workspace is just a directory that contains multiple Python projects (apps and/or libraries) that are managed together with one top-level configuration.
Workspaces are especially useful when:
- You have multiple related apps or services (e.g. API, worker, CLI)
- You maintain one or more internal libraries used across those apps
- You want shared dependencies and a single lockfile for consistent environments
I’ve previously written a general introduction to uv as a Python package manager, which you can read here: uv package manager tutorial.
A typical real-world setup might look like this:
llm-platform/
pyproject.toml # workspace root
api/
pyproject.toml # FastAPI app
worker/
pyproject.toml # queue consumers, tasks
common/
pyproject.toml # shared utilities, modelsHere, llm-platform/ is the workspace root, and api, worker, and common are individual workspace members. The root workspace configuration and lockfile ensure that all of these projects share a consistent set of dependencies.
Basic structures:
Root pyproject.toml
At the root, you don’t define a Python package (you can, but usually it’s just a config container). Most important is the [tool.uv.workspace] section:
[tool.uv.workspace]
members = [
"api",
"worker",
"common",
]- This tells uv: “these subdirs are workspace members”.
Members
Inside apl/pyproject.toml:
[project]
name = "llm-api"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi",
"uvicorn[standard]"
]
[project.optional-dependencies]
dev = [
"pytest",
"httpx",
]Inside common/pyproject.toml:
[project]
name = "llm-common"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"pydantic",
]Now api can depend on common as a normal package:
[project]
name = "llm-api"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi",
"uvicorn[standard]",
"llm-common", # just use the name
]- uv will notice that
llm-commonis another workspace member.
Typical commands:
To run a command inside a specific member:
# Run a FastAPI server defined in api/
uv run --package api uvicorn llm_api.main:app --reloadTo add a dependency to a member, cd into the member package, then:
# Add 'redis' to worker/ only
uv add redisYou can lock dependencies for whole workspace by
uv lockYou can create a member project like this:
mkdir api common
cd api
uv init --package llm_api
cd ../common
uv init --package llm_common
cd ..uv sources
A workspace is about project structure:
- It says: “These directories are all part of one big project/monorepo.”
- Defined in
[tool.uv.workspace]in the rootpyproject.toml. - All members share:
- One
uv.lock - One virtualenv (by default)
- Shared
tool.uvconfig
- One
[project]
name = "my-app"
version = "0.1.0"
dependencies = ["hello-common"]
[tool.uv.workspace]
members = ["libs/*"]my-appis the workspace root.- Everything under
libs/(likelibs/hello_common) is a workspace member. - uv will install all members as editable when you sync the workspace.
On the other hand, [tool.uv.sources] is about where a dependency comes from:
- It enriches
project.dependencieswith alternative sources:
[project]
name = "my-app"
version = "0.1.0"
dependencies = ["hello-common"]
[tool.uv.workspace]
members = ["libs/*"]
[tool.uv.sources]
hello-common = { workspace = true }- It says When you see dependency
hello-common, don’t fetch from PyPI — resolve it from my workspace member instead. - You can think of sources as:
- Given a dependency name, where do we install it from?
This is especially useful in:
- Company environments with internal PyPI mirrors
- Air-gapped / proxy-heavy setups
- When you want a local wheel dir for speed
For example, you want my-internal-lib to come from your company index, not public PyPI.
[tool.uv.sources]
my-internal-lib = { index = "https://pypi.mycompany.com/simple" }Then in [project.dependencies] you just write:
[project]
name = "my-app"
version = "0.1.0"
dependencies = [
"my-internal-lib",
]In your uv config file (not pyproject.toml), you can set a global index (e.g. corporate mirror):
[package-index]
default = "https://pypi.mycompany.com/simple"Hands-on Example
Let’s use a minimal setup, starting from what uv init gives you.
Starting point
You run:
uv init uv-ws-practice
cd uv-ws-practiceYou get:
uv-ws-practice/
├── README.md
├── main.py
└── pyproject.tomlmain.pyis a simple script.
Now let’s add a library project hello_common under libs/.
Create the library project
From uv-ws-practice:
mkdir -p libs/hello_common
cd libs/hello_common
uv init --package .or identically
mkdir libs
uv init --package hello_commonNow, the project directory looks like this
uv-ws-practice/
├── README.md
├── main.py
├── pyproject.toml # root
└── libs/
└── hello_common/ # library project
├── README.md
├── pyproject.toml
└── src/
└── hello_common/
└── __init__.pyEdit libs/hello_common/src/hello_common/__init__.py:
def greet(name: str) -> str:
return f"Hello, {name} from hello_common!"
def main() -> None:
# For a console script
print(greet("world"))Edit libs/hello_common/pyproject.toml so it would look like:
[project]
name = "hello-common" # distribution name
version = "0.1.0"
description = "A small shared library for uv workspace practice."
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
hello-common = "hello_common:main" # console script name
[build-system]
...Now we have:
- Project/distribution:
hello-common - Python package:
hello_common(undersrc/hello_common) - Script:
hello-commonrunninghello_common.main().
Turn the root into a workspace and declare the dependency
Open the root pyproject.toml and make it:
[project]
name = "uv-ws-practice"
version = "0.1.0"
description = "Root of a uv workspace to practice workspace and sources."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"hello-common", # ✅ root project depends on the library
]
[build-system]
...
# 1) Workspace members
[tool.uv.workspace]
members = [
"libs/hello_common",
]
# 2) Source override: "hello-common" comes from *this* workspace
[tool.uv.sources]
hello-common = { workspace = true }- Under
[project]we say:- “This root project needs
hello-commonto run.”
- “This root project needs
- Under
[tool.uv.workspace]we say:- “The project at
libs/hello_commonis part of this workspace.”
- “The project at
- Under
[tool.uv.sources]we say:- “When resolving the dependency
hello-common, use the workspace project instead of PyPI.”
- “When resolving the dependency
Use the library in main.py
In the root main.py:
from hello_common import greet
def main() -> None:
print(greet("Workspace"))
if __name__ == "__main__":
main()Now main.py imports the hello_common package.
From uv-ws-practice:
uv syncThis command
- Reads the root
pyproject.toml - Sees
dependencies = ["hello-common"] - Sees in
[tool.uv.sources]thathello-commonis from the workspace - Looks in
[tool.uv.workspace].membersfor a matching project (libs/hello_common) - Resolves dependencies and sets up an environment containing:
- the root project
- the
hello-commonlibrary
uv run python main.pyYou see:
Hello, Workspace from hello_common!If you had forgotten to add "hello-common" to dependencies, you’d get:
ModuleNotFoundError: No module named 'hello_common'Even though the library exists in libs/hello_common.
That’s the important lesson: workspace + sources does not automatically make everything importable; you still must depend on it.
Now try:
uv run hello-commonBecause:
- The root env was synced
- Root depends on
hello-common hello-commonexposes a script calledhello-common
…uv will run the console script from the root project’s environment:
Hello, world from hello_common!Alternatively, inside libs/hello_common:
cd libs/hello_common
uv run hello-commonNow uv uses the library project itself as the current project and still runs the same script.