What an Nx Python monorepo with uv looks like and who it suits
In this setup, two tools split the work. uv handles everything about Python packages: it resolves dependencies for the whole repository into a single uv.lock, installs them into one shared .venv at the root, and installs internal libraries as editable packages. Nx handles everything about tasks: it decides which test, lint and build targets run, in what order, whether a cached result can be replayed, and which projects a change affects. Nx never resolves a Python dependency, and uv never schedules a task.
repo/
├── pyproject.toml ← uv: declares the workspace and its members
├── uv.lock ← uv: one resolution for every member
├── .venv/ ← uv: shared environment, internal libs editable
├── nx.json ← Nx: target defaults, cache inputs, named inputs
├── packages/
│ └── core/
│ ├── pyproject.toml ← uv: package metadata and dependencies
│ └── project.json ← Nx: targets and implicit dependencies
└── apps/
└── billing-api/
├── pyproject.toml ← depends on "core" as a workspace source
└── project.json
Teams adopt this setup for three reasons:
- One lockfile prevents services from drifting onto different versions of the same library.
- Editable internal libraries mean a change in
corereachesbilling-apiimmediately, without publishing a package. - Affected runs mean CI only runs targets for projects that a change can actually reach.
The setup fits backend and platform teams that run several Python services on top of shared libraries. The key condition is that those teams can agree on compatible versions. A single lockfile is a constraint, not just a convenience. If one service needs a version of a library that another service cannot accept, the workspace cannot resolve.
+-------------------------------- repo/ ---------------------------------+
| pyproject.toml [uv] workspace: members = packages/*, apps/* |
| uv.lock [uv] one resolution for every member |
| .venv/ [uv] shared env, internal libs editable |
| nx.json [Nx] target defaults, cache inputs, named inputs |
| |
| packages/core/ apps/billing-api/ |
| pyproject.toml [uv] <--------- pyproject.toml [uv] |
| project.json [Nx] workspace project.json [Nx] |
| source |
+------------------------------------------------------------------------+
| |
v [uv] files v [Nx] files
+----------------------------------+ +----------------------------------+
| uv: PACKAGES | | Nx: TASKS |
| resolve deps -> uv.lock | | pick targets: test, lint, build |
| sync -> .venv (editable libs) | | order by project graph |
| | | replay cached results |
| | | affected: only changed projects |
| never schedules a task | | never resolves a dependency |
+----------------------------------+ +----------------------------------+
| |
| provides | runs commands in
v v
+----------------------------------------------------------+
| shared root .venv: uv builds it, Nx targets run in it |
+----------------------------------------------------------+
Each file in the repository belongs to either uv (package resolution, uv.lock, the editable shared .venv) or Nx (targets, caching, affected detection), and the root .venv is where uv's output becomes the environment Nx runs tasks in.
Setting up the uv workspace and internal dependencies
The root pyproject.toml declares the workspace. Under [tool.uv.workspace], a members list of globs such as packages/* and services/* tells uv which directories are members. An optional exclude list skips directories that match a glob but should stay out, like a scratch prototype. The root can be a project itself or only a container for the workspace table.
Each member has its own pyproject.toml with a normal [project] table: name, requires-python, and its runtime dependencies. To depend on a sibling, a service lists the library by name in dependencies and then adds an entry under [tool.uv.sources] mapping that name to { workspace = true }. That tells uv to resolve the package from the workspace instead of an index. Workspace members are installed in editable mode, so a change to the library is visible to the service without a rebuild or reinstall.
This matters for Nx because the dependency graph already exists in these files. Nx can read the same declarations, so the graph stays in one place and is not maintained twice.
By default, uv sync builds one .venv at the workspace root, governed by a single uv.lock. Running uv sync --package <name> narrows what gets installed to one member and its dependencies, but resolution still covers the whole workspace. One environment is fast and consistent. It also means every member must resolve to compatible versions, including an overlapping requires-python. When two services genuinely need conflicting versions, that conflict is the signal to split them into separate workspaces.
# --- pyproject.toml (workspace root) ---
[project]
name = "acme-monorepo"
version = "0.1.0"
requires-python = ">=3.11"
[tool.uv.workspace]
members = ["packages/*", "services/*"] # every matching dir is a member
exclude = ["packages/scratch-prototype"] # matches the glob but stays out
# --- packages/core-lib/pyproject.toml ---
[project]
name = "core-lib"
version = "0.1.0"
requires-python = ">=3.11" # must overlap with every other member
dependencies = ["pydantic>=2"]
[build-system] # buildable, so uv can install it editable
requires = ["hatchling"]
build-backend = "hatchling.build"
# --- services/api/pyproject.toml ---
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"core-lib", # sibling listed by name, like any dependency
"fastapi>=0.110",
]
[tool.uv.sources]
core-lib = { workspace = true } # resolve from the workspace, not an index
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
A root pyproject.toml that declares workspace members by glob, a library member, and a service that depends on the library through [tool.uv.sources] with workspace = true, so a single uv.lock and root .venv install core-lib in editable mode.
Registering Python projects and targets in Nx
Each package gets a project.json beside its pyproject.toml. Define lint, typecheck, test and build targets with the nx:run-commands executor, each calling uv run ruff check, uv run mypy (or another type checker), uv run pytest or uv build. Set cwd to the package directory. The tool then picks up that package's configuration and test paths rather than the repository root's, and uv run resolves against the shared workspace environment.
Nx does not parse pyproject.toml, so it cannot see that billing depends on shared-models. The simplest fix is implicitDependencies: list every internal package that appears in the project's [tool.uv.sources] with workspace = true. The cost is drift. If someone adds a workspace source and forgets the project.json edge, nx affected will not schedule that consumer's tests when the dependency changes. Nothing fails, so the gap goes unnoticed. Add a CI check that compares the two lists.
| Approach | Edge source | Maintenance | Main risk |
|---|---|---|---|
Manual implicitDependencies |
Hand-edited JSON | Every dependency change | Silent drift |
| Community plugin | Plugin's own parsing | Plugin upgrades | Behaviour you must verify yourself |
| Custom graph plugin | Reads workspace members and sources | Code you own | Bugs in your parser |
There are now two entry points. Engineers will still run uv run pytest directly when debugging, and IDE test runners bypass Nx entirely. Keep the Nx targets as thin wrappers with no extra flags, so a local run behaves the same as a CI run.
{
"name": "billing",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "packages/billing/src",
"implicitDependencies": ["shared-models"],
"targets": {
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "uv run ruff check .",
"cwd": "packages/billing"
}
},
"typecheck": {
"executor": "nx:run-commands",
"options": {
"command": "uv run mypy src",
"cwd": "packages/billing"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"command": "uv run pytest",
"cwd": "packages/billing"
}
},
"build": {
"executor": "nx:run-commands",
"options": {
"command": "uv build",
"cwd": "packages/billing"
}
}
}
}
A packages/billing/project.json with thin uv-backed targets run from the package directory and a hand-maintained implicitDependencies entry that must mirror the workspace sources in pyproject.toml.
Configuring inputs and outputs for trustworthy python monorepo caching
Nx computes a cache key only from the inputs you declare. If a factor that changes test results is missing from that list, Nx will replay a stale success.
Declaring the inputs
Define a python named input that covers {projectRoot}/**/*.py and the member's {projectRoot}/pyproject.toml. Add a shared global input for {workspaceRoot}/uv.lock and {workspaceRoot}/pyproject.toml. In targetDefaults, have test and build consume python plus ^python. The caret form pulls in the sources of internal workspace dependencies, so editing a shared library invalidates every consumer.
Declare outputs so a cache hit restores files as well as logs. Use {workspaceRoot}/coverage/{projectRoot} for coverage reports and {projectRoot}/dist for wheels and sdists.
What the key does not see
Several factors stay outside the hash unless you add them:
- The interpreter version
- The OS and CPU architecture
- Which native wheel was resolved
- Environment variables your tests read
Add a runtime input that runs python --version, and add env inputs for variables that change behaviour, such as feature flags. Separate CI cache scopes per platform.
Coarse versus narrow lockfile input
Treating uv.lock as one global input is correct but blunt. Bumping any dependency for one service invalidates every project's cache. Narrowing it means hashing only the resolved packages each member actually uses. That keeps more cache hits, but you have to maintain the narrowing logic, and a mistake there produces false hits. Start coarse. Narrow only once lockfile churn measurably hurts CI time.
When to turn caching off
Set cache: false for test targets that depend on things the hash cannot capture: network calls, the current time, or a shared database. Their results reflect external state, so a cached pass proves nothing.
{
"namedInputs": {
"sharedGlobals": [
"{workspaceRoot}/uv.lock",
"{workspaceRoot}/pyproject.toml",
{ "runtime": "python --version" },
{ "env": "FEATURE_FLAGS" }
],
"python": [
"{projectRoot}/**/*.py",
"{projectRoot}/pyproject.toml",
"sharedGlobals"
]
},
"targetDefaults": {
"test": {
"cache": true,
"inputs": ["python", "^python"],
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"]
},
"build": {
"cache": true,
"inputs": ["python", "^python"],
"outputs": ["{projectRoot}/dist"]
},
"integration-test": {
"cache": false
}
}
}
An nx.json that hashes member sources, the root lockfile and pyproject.toml, the interpreter version and a feature-flag variable, declares coverage and dist outputs, and disables caching for integration tests that use the network or shared databases.
Running nx affected for Python locally and in CI
nx affected -t test decides what to run by diffing two git refs. It takes the merge base of --base (default defaultBase in nx.json, usually main) and --head, lists the touched files, maps each file to the project that owns it, and then adds every project that depends on those projects through the graph. uv.lock is a single file at the workspace root that covers every package, and it sits in each Python project's shared inputs. Nx cannot tell which package a lockfile edit concerns, so a change to uv.lock counts as touching all of them. A one-line dependency bump in one service will therefore run the tests of every dependent project. That cost is intentional, because a resolver change can shift transitive versions anywhere in the workspace.
The CI job
The job runs these steps in order:
- Check out with enough history to contain the merge base. Full history is the simplest option.
- Install uv.
- Run
uv sync --lockedonce at the root.--lockedfails if the lockfile is stale.--frozenskips that check. - Run
nx affected -t lint test build.
On pull requests, set base to the target branch. On pushes to main, set base to the last commit that passed CI, not HEAD~1. Otherwise a failed run's changes are never retested. Pass the refs with --base/--head or the NX_BASE/NX_HEAD environment variables.
Where it goes wrong
A shallow clone often lacks the merge base. Depending on your setup, that either errors or collapses into "everything affected." A base that is too recent, such as the previous commit on a multi-commit push, silently misses changes.
Remote cache hits are only valid between machines with the same Python minor version, OS and CPU architecture. Compiled wheels and bytecode differ across them, and Nx does not hash any of these by default. Add a runtime input such as python --version and the platform to the target's inputs so the hash captures them.
Building artifacts and verifying the dependency graph
Give the build target an explicit destination so Nx knows what to cache and restore. Have it run uv build --package <name> --out-dir dist/<name> from the workspace root, and declare {workspaceRoot}/dist/<name> as the target's output. Without --out-dir, every package writes into one shared dist/. That makes per-project outputs ambiguous, and a cache restore for one project can overwrite another project's artifacts. Make build depend on ^build so internal libraries are packaged before the projects that consume them.
Inspect the wheel's METADATA file once to see how internal dependencies come out. The workspace = true entry under tool.uv.sources is resolution-time information only, so uv drops it at build time. What remains is the plain Requires-Dist line taken from project.dependencies. If that line has no version constraint, the published wheel will accept any version of your internal library. Add bounds where that matters, and make sure the internal libraries are published to the same index as their consumers, because a wheel that depends on an unpublished name cannot be installed.
The graph Nx infers is only as good as your declared dependencies, so verify it deliberately:
- On a branch, make a trivial change to a leaf library and commit it.
- Open
nx graphand confirm the edges from that library to its consumers exist. - Run
nx show projects --affected --base=mainand check that the list contains exactly the leaf library and its transitive dependents.
An unrelated project in that list usually means the inputs are too broad. A missing dependent means an edge is missing.
Run this check regularly, for example as a CI job that compares the affected list for a fixture change against a committed expected list. A missing edge produces no error. Its only symptom is a dependent project that silently skips its tests, and the check catches that before it ships.
When to avoid this setup, alternatives, and impact on cost and engineering time
When to avoid it
Skip this setup if two services need incompatible versions of the same dependency. A uv workspace resolves every member into one lockfile, so a conflict there blocks resolution and has no workaround inside the workspace. Skip it for repos with two or three packages, where a full test run already takes about as long as Nx spends computing the graph. Skip it if tests depend on network calls, wall-clock time or random ordering. Caching a flaky pass replays that pass and hides the regression.
| Option | Fits when | Main cost |
|---|---|---|
| uv workspace + scripts | Few packages, one team | You write change detection by hand |
| Separate uv projects + Nx | Services need conflicting dependencies | One lockfile per service to upgrade |
| Community Nx Python plugin | You want inferred targets | Depends on the plugin being maintained |
| Polyrepo | Teams release on their own schedules | Cross-repo changes need coordinated PRs |
Cost versus time saved
The costs are config boilerplate per project, graph edges you must keep correct, two entry points (uv and nx) for developers to learn, and a remote cache to run in CI. The savings are skipped CI jobs, reused cache entries and one lockfile to upgrade.
Illustrative estimate (assumed numbers, not a benchmark)
Formula: monthly CI minutes saved = PRs per month × (projects − affected projects per PR) × minutes per project job
Assumed inputs: 200 PRs, 8 projects, 2 affected per PR, 4 minutes per job.
Result: 200 × (8 − 2) × 4 = 4,800 CI minutes per month.
Weigh that against your own setup time and the monthly hours spent maintaining graph edges.
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, 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.