How uv and Nx split the work in a Python monorepo
A Python monorepo built on uv and Nx has two tools with separate jobs. uv owns the Python side. It resolves dependencies, writes a single lockfile for the whole workspace, creates the virtual environment, and installs workspace members so that one package can import another. When orders-service depends on a shared billing-core library, uv wires that link and pins every third-party version around it.
Nx owns orchestration. It holds a project graph and a task graph, runs targets such as test, lint and typecheck in dependency order, and hashes each task's inputs to decide whether to replay a cached result. It uses the same graph to work out which projects a change affects. Nx does not need to understand Python to do this. It runs commands, usually uv run ..., and records their outputs.
There is one important constraint. Nx cannot read your imports or your pyproject.toml dependency lists by default, so it does not know that orders-service depends on billing-core. It also does not know that a test result depends on the lockfile, the Python version or an environment variable. You have to declare these yourself. If you leave a dependency out, Nx will report stale cache hits and skip affected tests, and it will not show an error. Most of the work in the rest of this tutorial goes into keeping those declarations accurate.
This setup suits backend and platform teams with several interdependent Python packages whose CI runs every test on every change and has become slow enough to block merges.
+--------------------------------------------------------------------------+
| Nx (task graph, caching, affected detection) |
| |
| project graph --> task graph --> hash inputs --> cache hit? |
| ^ | | | |
| | v yes v v no |
| | affected projects replay output run target |
| | | |
+--------+----------------------------------------------------+------------+
| | uv run <cmd>
| you declare (Nx cannot infer): |
| - orders-service -> billing-core |
| - uv.lock, Python version, env vars |
| as task inputs |
| v
+--------+----------------------------------------------------+------------+
| uv (lockfile, environment, workspace dependencies) |
| |
| pyproject.toml files --> uv.lock --> .venv (members installed) |
| |
| +----------------+ depends on +--------------+ |
| | orders-service |--------------->| billing-core | |
| +----------------+ +--------------+ |
+--------------------------------------------------------------------------+
uv resolves, locks and installs the workspace packages, Nx schedules and caches tasks that run through uv run, and the dependency edges and cache inputs between the two layers must be declared by hand because Nx cannot infer them from Python code.
Laying out the repo as a uv workspace
Start with a root pyproject.toml that declares the workspace under [tool.uv.workspace], with members = ["apps/*", "libs/*"]. The globs let you add a package by creating a directory with its own pyproject.toml, without editing the root file. Deployable services go under apps/, and shared code goes under libs/. Nx will later use the same split for tagging and boundary rules.
Each member has its own pyproject.toml with a [project] table: name, requires-python, and runtime dependencies. Give each member a [build-system] as well. Without one, uv treats the member as a non-package and does not install it, so imports from other members fail.
When apps/orders depends on libs/billing, declare it in two places:
- Add
billingto the app's[project].dependencies, as you would any package. - Add
billing = { workspace = true }under[tool.uv.sources], which tells uv to resolve it from the workspace instead of a package index.
Because both steps are explicit, the dependency is something Nx can read, not something it has to guess from import statements.
The workspace has one uv.lock at the root. Every member resolves against the same versions, so two services cannot drift onto incompatible releases of a shared library. That single file also becomes an important cache input later.
uv sync installs workspace members in editable mode. Edits in libs/billing are visible to apps/orders immediately, with no rebuild or reinstall. By default it syncs the root project, so use --all-packages to sync every member, or --package for one.
Pin the interpreter with a .python-version file at the root, and keep requires-python consistent across members. uv computes the workspace's supported range from all members' requires-python values. If one member is out of line, it narrows or breaks resolution for everyone.
# --- pyproject.toml (repo root) ---
[project]
name = "acme-monorepo"
version = "0.1.0"
requires-python = ">=3.12"
[tool.uv.workspace]
# Any directory under apps/ or libs/ with a pyproject.toml becomes a member
members = ["apps/*", "libs/*"]
# --- libs/billing/pyproject.toml ---
[project]
name = "billing"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2"]
# Without a build system, uv treats the member as a non-package and does not install it
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# --- apps/orders/pyproject.toml ---
[project]
name = "orders"
version = "0.1.0"
requires-python = ">=3.12"
# Step 1: declare billing like any other dependency
dependencies = ["billing", "fastapi"]
# Step 2: resolve billing from the workspace, not a package index
[tool.uv.sources]
billing = { workspace = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# --- .python-version (repo root) contains: 3.12 ---
# Then run `uv sync --all-packages` to lock once (root uv.lock) and install all members editable
A root workspace definition and two member pyproject.toml files, in which apps/orders depends on libs/billing through [project].dependencies plus a workspace source.
Registering Python packages as Nx projects and syncing the dependency graph
Each package gets a project.json whose targets are thin nx:run-commands wrappers around uv:
testcallsuv run --package <name> pytest.lintcallsuv run ruff check <path>.typecheckruns the type checker throughuv run --package <name>.buildcallsuv build --package <name>.
Set the cwd option on every target explicitly. Two things depend on where a command starts:
- Config discovery. pytest's rootdir, Ruff's config lookup and relative output paths all resolve from the working directory. A target that works locally can collect different tests or apply different lint rules in CI if the directory is left implicit.
- Environment selection.
--packagemakes uv sync and activate only that member's dependencies, but it still uses the shared lockfile. That keeps each target's environment narrow without creating a second source of truth for versions.
Nx doesn't read pyproject.toml by default, so it has no idea that api imports core. Without that edge, a change to core won't mark api as affected. You can close the gap in two ways:
| Approach | How edges are created | Strengths | Risks |
|---|---|---|---|
Hand-written implicitDependencies |
You list them in each project.json |
No extra dependency; fully explicit | Drifts silently when someone adds a workspace dependency in pyproject.toml and forgets the Nx side |
| Community Nx Python plugin | Inferred from pyproject.toml |
One source of truth; new packages register automatically | Must actually support uv workspaces; can lag behind uv and Nx releases |
Before adopting a plugin, confirm it understands uv workspace members and workspace sources, not just Poetry-style layouts. Pin its version alongside Nx.
Whichever approach you choose, run nx graph and check that every app has an edge to each lib it uses. A missing edge produces no error. It just makes affected runs skip tests that should have run.
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "packages/api/src",
"implicitDependencies": ["core"],
"targets": {
"test": {
"executor": "nx:run-commands",
"options": {
"command": "uv run --package api pytest tests",
"cwd": "packages/api"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "uv run ruff check src tests",
"cwd": "packages/api"
}
},
"typecheck": {
"executor": "nx:run-commands",
"options": {
"command": "uv run --package api mypy src",
"cwd": "packages/api"
}
},
"build": {
"executor": "nx:run-commands",
"outputs": ["{projectRoot}/dist"],
"options": {
"command": "uv build --package api --out-dir dist",
"cwd": "packages/api"
}
}
}
}
A project.json for the api package that wraps uv in nx:run-commands targets with an explicit cwd and declares its edge to core through implicitDependencies.
Configuring Nx caching for Python correctly
With the graph correct, caching determines whether Nx can skip work safely. Set cache: true in targetDefaults for test, lint and build so every Python project inherits it. Correctness then depends on the inputs. If a file that affects the result is missing from the hash, Nx replays a stale result and reports success.
Define a named input, for example pythonSource, that covers {projectRoot}/**/* (which includes the project's pyproject.toml), {workspaceRoot}/uv.lock, and root tool config such as {workspaceRoot}/ruff.toml. Also include whichever file holds pytest settings, such as the root pyproject.toml, pytest.ini or a shared conftest.py. The lockfile matters because a transitive dependency bump changes behavior without touching any project file.
Add a runtime input such as uv run python --version, so moving from one minor version to another invalidates the cache. If you share a remote cache across macOS and Linux runners, or across architectures, add the platform as well. For example, print platform.machine() and sys.platform from the same interpreter. Compiled wheels and path handling differ between platforms.
Declare outputs such as {projectRoot}/dist and the coverage report path. Nx restores only the outputs you declare, so anything undeclared is missing on a cache hit.
Keep __pycache__, .pytest_cache, .ruff_cache, .mypy_cache and .venv out of the hash. Listing them in .gitignore is usually enough. Otherwise, add negated patterns to the input. These directories change on every run and would cause constant cache misses.
Turn caching off (cache: false) for integration tests that depend on databases, network calls, environment variables or the current time. Their results depend on state that Nx does not hash.
{
"namedInputs": {
"pythonSource": [
"{projectRoot}/**/*",
"!{projectRoot}/**/__pycache__/**",
"!{projectRoot}/.pytest_cache/**",
"!{projectRoot}/.ruff_cache/**",
"!{projectRoot}/.mypy_cache/**",
"!{projectRoot}/.venv/**",
"{workspaceRoot}/uv.lock",
"{workspaceRoot}/ruff.toml",
"{workspaceRoot}/pyproject.toml",
"{workspaceRoot}/conftest.py",
{ "runtime": "uv run python --version" },
{ "runtime": "uv run python -c \"import platform, sys; print(platform.machine(), sys.platform)\"" }
]
},
"targetDefaults": {
"test": {
"cache": true,
"inputs": ["pythonSource", "^pythonSource"],
"outputs": ["{projectRoot}/coverage"]
},
"lint": {
"cache": true,
"inputs": ["pythonSource"]
},
"build": {
"cache": true,
"inputs": ["pythonSource", "^pythonSource"],
"outputs": ["{projectRoot}/dist"]
},
"integration-test": {
"cache": false
}
}
}
An nx.json that hashes project source, uv.lock, and root ruff and pytest config, plus the Python version and platform. It excludes cache directories and declares dist and coverage outputs, and it turns off caching for integration tests.
Running affected Python tests in CI
In CI, order matters. Run a single uv sync --frozen (or --locked) at the repository root before Nx starts. --locked fails if uv.lock is out of date with pyproject.toml, which catches a forgotten lock update. --frozen installs from the lockfile without checking it. Syncing once builds one shared virtual environment that every task reads. Syncing per task repeats the same resolution and linking work for each project. It can also race: when Nx runs targets in parallel, several uv sync processes may write to the same .venv at once.
Cache uv's download directory (set via UV_CACHE_DIR) under its own CI cache key, derived from uv.lock. Keep it out of the Nx task cache. The uv cache holds wheels and source distributions. The Nx cache holds task outputs. If you mix them, one invalidates the other for no reason.
Fetch enough git history for the merge-base between your branch and the target branch to exist. A depth-1 clone usually lacks it, so the affected calculation fails or falls back to wrong results. Then run nx affected -t lint test typecheck, with --base set to the merge-base commit and --head set to the current commit.
| Change | Projects that run |
|---|---|
| Leaf lib (no dependents) | That lib only |
| Shared lib | The lib plus every project that depends on it |
Root uv.lock |
Every Python project, if uv.lock is in your shared inputs |
Root config (nx.json, shared lint/type config) |
Every project whose inputs include that file. A change to nx.json affects all projects |
Verifying cache correctness, not just cache hits
Treat cache verification as a test of the graph, not of Nx. Run this sequence on a clean checkout:
- Repeat run. Run a project's
testtarget twice. The second run should be a cache hit. If it misses, an input is changing between runs. Common causes are a timestamped file, a.pycor.pytest_cachedirectory inside the inputs, or an environment variable you declared as an input. - Dependency change. Edit a function in a shared library. Confirm that
nx affected -t testincludes every app that imports it, and that those apps' tests actually execute rather than replaying cached output. - Docs-only change. Edit only a README. Nothing should rerun. If something does, your inputs include docs that cannot affect test results.
When a result surprises you, compare what Nx thinks a task depends on with what changed. nx show project <name> lists the resolved targets and inputs, and the task graph view in nx graph shows which files and dependent projects feed each hash. Read the input list, not just whether it hit or missed.
The dangerous failure is silent: the Nx graph says A does not depend on B, but A imports B. Then affected skips A's tests, and a stale cache hit hides the regression. Add a CI job, or at least a scheduled review, that derives imports from source and diffs them against Nx's project edges, whether those edges come from implicitDependencies or plugin inference.
Static import scanning will not catch these cases, so declare them explicitly:
importlibor string-based dynamic imports- packages discovered through entry points
- tests that read fixtures or config outside the project directory
When to avoid it, alternatives, and impact on cost and engineering time
Each of those declarations is graph maintenance someone has to own, and that is the main ongoing cost. uv derives the package graph from pyproject.toml. Nx builds the task graph from inference plus your overrides. The two drift apart unless CI checks that they agree.
A single shared uv.lock adds a second cost. If every project hashes it as an input, bumping one dev dependency invalidates the whole cache. Native extensions are a third trap: a .so built for one OS, CPU architecture or Python ABI is useless on another, so the interpreter and platform must be part of the hash. uv run also checks that the environment is in sync on each task, and that overhead adds up across hundreds of small targets. Finally, a Python team now maintains Node.js and JSON config.
| Approach | Dependency graph | Caching | Affected detection | Extra toolchain |
|---|---|---|---|---|
| Nx + uv workspace | Two graphs, kept in sync | Local and remote | Built in | Node.js |
| uv workspace + script | One (uv) | None | Hand-rolled git diff mapping |
None |
| Python-native task runner | One (uv) | Usually none | Usually manual | None |
Nx saves time by skipping unaffected work and reusing cached results. It costs time in setup and in keeping the graph accurate.
Illustrative estimate (assumed numbers, not a benchmark)
Monthly runner time saved = runs × (full suite − average affected run) × hit-adjusted factor
Assumed inputs:
- 300 runs per month
- 30-minute full suite
- 8-minute average affected run
- Factor of 0.9 to cover cache misses after lockfile bumps
300 × 22 × 0.9 ≈ 5,940 runner-minutes, or about 99 hours. Weigh that against roughly 40 engineer-hours of setup and 4 hours per month of maintenance. If your suite is already fast or your repo has only a few packages, a script over the uv workspace is cheaper.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx python monorepo uv, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.