What an Nx Python monorepo with mixed tools is, and who it suits
An Nx Python monorepo with mixed tools splits responsibility across two layers. Nx owns the workspace: it builds the project graph, runs tasks such as test, lint and build, caches their outputs, and works out which projects a change affects. Below that, each Python project keeps its own package manager. A Poetry project has its pyproject.toml, poetry.lock and virtual environment. A uv project has its pyproject.toml, uv.lock and .venv. Nx does not resolve Python dependencies. It invokes whichever tool the project already uses.
┌──────────────────────────────────────────┐
│ Nx: project graph · tasks · cache · │
│ affected detection │
└──────────────────────────────────────────┘
│ │ │
┌──────────┴───┐ ┌──────┴───────┐ ┌───┴───────────┐
│ apps/billing │ │ apps/search │ │ libs/auth │
│ Poetry │ │ uv │ │ uv │
│ poetry.lock │ │ uv.lock │ │ uv.lock │
│ .venv │ │ .venv │ │ .venv │
└──────┬───────┘ └──────┬───────┘ └───────────────┘
│ │ ▲
└──── depends on ──┴───────────────────┘
Mixed repos are rarely planned. Three causes are common:
- Incremental migration. A team moving from Poetry to uv converts one service at a time.
- Team autonomy. Teams pick their own tooling.
- Inherited services. Acquisitions or consolidated repositories bring in projects that arrive with their own tooling.
Forcing a single tool before adopting Nx would stall the consolidation.
This setup suits platform and backend teams that run several Python services and shared libraries and cannot migrate them all at once. They still want one task runner, shared caching, and CI that only rebuilds what changed.
┌────────────────────────────────────────────────────────────────────────┐
│ Nx: project graph · tasks · cache · affected detection │
│ Python deps resolved per project by Poetry or uv │
└──────┬─────────────────┬─────────────────┬─────────────────┬───────────┘
│ │ │ │
invokes poetry invokes uv invokes uv invokes poetry
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ apps/billing │ │ apps/search │ │ libs/auth │ │ libs/models │
├──────────────┤ ├──────────────┤ ├──────────────┤ ├──────────────┤
│ Poetry │ │ uv │ │ uv │ │ Poetry │
│ poetry.lock │ │ uv.lock │ │ uv.lock │ │ poetry.lock │
│ own venv │ │ .venv │ │ .venv │ │ own venv │
└──────┬───────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
│ │ ▲ ▲
│ └─ depends on ────┘ │
└──────────────────── depends on ─────────────────────┘
▲ dependency edge in the Nx project graph, used for affected detection
Nx sits above the workspace and runs tasks for each project, while each app and library keeps its own Poetry or uv lockfile and environment, and dependency edges link apps to the libraries they use.
How Nx orchestrates Poetry and uv projects
Nx core has no built-in understanding of pyproject.toml, poetry.lock, or uv.lock. To Nx, a Python project is a directory with a project.json whose targets wrap shell commands, usually through the nx:run-commands executor. A test target in a Poetry project calls poetry run pytest, and the same target in a uv project calls uv run pytest. Because Nx only sees target names, the two tools can coexist. Every project exposes lint, test, and build, and Nx runs them the same way.
The project graph does not come from Python imports or path dependencies. You either declare edges by hand with implicitDependencies, or you add a project-graph plugin that reads each pyproject.toml and infers the edges. Everything else Nx does is built on top of that graph.
- Caching hashes the files and environment values listed in a target's
inputs, along with the inputs of its dependencies. If the hashes match a previous run, Nx replays the stored terminal output and restores the declaredoutputsinstead of running the command again. dependsOncontrols ordering. Setting"^build"on a service'sbuildtarget makes thebuildtargets of its dependencies run first.nx affectedmaps changed files to the projects that own them, then follows the graph to every project that depends on those projects.
All three features rely on an accurate graph and complete inputs. Suppose a service imports a shared library but has no edge to it. A change to that library will not mark the service as affected, so its tests are skipped. If the library's sources are missing from the service's inputs, the cache returns a passing result produced against the old code. Nx does not warn you about a missing edge, so the build stays green while testing the wrong code.
Implementing the workspace: layout, targets, and cross-project dependencies
Put deployable services under apps/ and shared code under libs/. Give each project its own pyproject.toml and its own project.json in the same directory, so the project boundaries that Nx sees match the packages that Python sees.
Targets per tool
| Target | Poetry project | uv project |
|---|---|---|
| install | poetry install |
uv sync |
| test | poetry run pytest |
uv run pytest |
| build | poetry build |
uv build |
| lockfile input | poetry.lock |
uv.lock |
Lint wraps the same linter through poetry run or uv run.
Declaring dependencies
Poetry references a local library as a path dependency with develop = true. uv uses a path entry under [tool.uv.sources], or workspace = true if the library is a workspace member. Uv workspace members share one lockfile and one .venv at the workspace root, so choose this deliberately. Nx does not reliably infer these edges from pyproject.toml. List each local library in implicitDependencies.
Inputs, outputs, ordering
Each project's inputs should cover:
- its
pyproject.toml - its lockfile
- its sources
^-prefixed inputs, so dependency sources also invalidate the cache
Declare {projectRoot}/dist and the coverage directory as outputs. Set dependsOn: ["^build"] on test, so each library's wheel exists before any dependent runs its tests.
Isolation
Keep one .venv per project. Poetry needs virtualenvs.in-project set to true in poetry.toml to do this; uv creates a project-local .venv by default. Commit a .python-version file to pin the interpreter so that every environment resolves the same Python.
When a library crosses tools, have the consumer install the built wheel rather than an editable install. An editable install depends on the other tool's build backend and path handling. A wheel is a fixed artifact with declared metadata, and it is exactly what production will install.
{
"name": "api",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/api/src",
"implicitDependencies": ["core"],
"targets": {
"install": {
"executor": "nx:run-commands",
"options": { "command": "uv sync", "cwd": "apps/api" },
"inputs": ["{projectRoot}/pyproject.toml", "{projectRoot}/uv.lock", "{projectRoot}/.python-version"]
},
"lint": {
"executor": "nx:run-commands",
"dependsOn": ["install"],
"options": { "command": "uv run ruff check src tests", "cwd": "apps/api" },
"inputs": ["{projectRoot}/pyproject.toml", "{projectRoot}/src/**/*", "{projectRoot}/tests/**/*"]
},
"test": {
"executor": "nx:run-commands",
"dependsOn": ["^build", "install"],
"options": { "command": "uv run pytest --cov=src --cov-report=xml:coverage/coverage.xml", "cwd": "apps/api" },
"inputs": ["{projectRoot}/pyproject.toml", "{projectRoot}/uv.lock", "{projectRoot}/src/**/*", "{projectRoot}/tests/**/*", "^default"],
"outputs": ["{projectRoot}/coverage"]
},
"build": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": { "command": "uv build", "cwd": "apps/api" },
"inputs": ["{projectRoot}/pyproject.toml", "{projectRoot}/uv.lock", "{projectRoot}/src/**/*", "^default"],
"outputs": ["{projectRoot}/dist"]
}
}
}
A project.json for a uv app that depends on a Poetry library named core. It declares that library in implicitDependencies, wraps uv commands, and uses ^build so the library's wheel exists before tests run. It also lists lockfile and source inputs plus dist and coverage outputs.
CI setup and a gradual migration from Poetry to uv
Pipeline order
The runner image needs Node.js for Nx, plus Poetry and uv. Pin each tool's version in the image or setup step so a tool upgrade shows up as a reviewed change, not a surprise in a lockfile.
Cache three things under separate keys: the Poetry cache, the uv cache (uv cache dir prints its location), and the Nx cache. Key each tool cache on its own lockfiles. If you combine them, a change to one uv.lock invalidates Poetry's downloads too, and you can't tell which cache is stale when a restore looks wrong.
Check lockfiles before any build work. Run poetry check --lock in each Poetry project and uv lock --check in each uv project. A lockfile that has drifted from its pyproject.toml should fail in seconds, not halfway through a test run. After that, nx affected -t lint test build runs only what the diff touched.
Converting one project at a time
Migrate a single project per pull request. Rewrite its pyproject.toml for uv, generate uv.lock, and change only the commands behind its lint, test and build targets. Keep the target names the same. CI invocations, developer muscle memory and dependsOn chains then stay valid, and the Nx cache treats the change as an ordinary input change.
Who owns the boundary
A uv workspace with one shared lockfile forces a single resolution across all members. It is consistent, but one dependency bump touches every member's inputs. Per-project lockfiles let Nx own the boundary. Each project resolves independently, and affected detection stays precise. During migration, use per-project lockfiles. Consider a shared workspace only after Poetry is gone.
#!/usr/bin/env bash
# CI entrypoint: assumes the runner image already provides Node.js, Poetry and uv
# at versions pinned in the image definition (bumps are reviewed changes).
set -euo pipefail
# Print each tool's cache location so the CI system can save and restore them
# under separate keys: Poetry keyed on poetry.lock files, uv on uv.lock files,
# Nx on its own inputs. Mixing them makes stale restores hard to diagnose.
echo "poetry cache: $(poetry config cache-dir)"
echo "uv cache: $(uv cache dir)"
echo "nx cache: .nx/cache"
npm ci # installs Nx from the workspace's package-lock.json
# Fail fast on lockfile drift before any lint/test/build work starts.
# The lockfile present in each project decides which tool owns it, so a project
# migrated from Poetry to uv is picked up here without editing this script.
for pyproject in $(git ls-files '*/pyproject.toml'); do
dir=$(dirname "$pyproject")
if [[ -f "$dir/uv.lock" ]]; then
echo "uv lock check: $dir"
(cd "$dir" && uv lock --check)
elif [[ -f "$dir/poetry.lock" ]]; then
echo "poetry lock check: $dir"
(cd "$dir" && poetry check --lock)
else
echo "no lockfile in $dir" >&2
exit 1
fi
done
# Target names stay lint/test/build for both Poetry and uv projects, so this
# line never changes during the migration. Only the affected projects run.
npx nx affected -t lint test build --base=origin/main --head=HEAD
A tool-agnostic CI script that exposes separate cache directories for Poetry, uv and Nx, runs each project's lockfile check based on which lockfile it contains, and then runs nx affected with stable target names.
Generating project configuration: hand-written, custom plugin, or community plugin
Once the dependency model is settled, someone still has to tell Nx what each project's targets are. There are three realistic ways to produce that configuration.
Hand-written project.json. Every target, command, input, and output is written out per project. It is verbose, and the duplication grows with each project you add. The upside is that nothing is hidden. When a cache hit looks wrong, you read one file and see exactly what Nx thinks the task is. This works for a handful of projects, or while you are still deciding on conventions.
A small custom plugin. Nx supports plugins that infer projects and targets from files on disk. A plugin that reads each pyproject.toml can detect the build backend (Poetry or uv) and emit the matching commands. It can also turn path dependencies into graph edges. You own perhaps a few hundred lines of TypeScript, but your conventions live in one place. Switching a project from Poetry to uv then becomes a change to its pyproject.toml alone.
Community Python plugins. These come with generators and inferred targets, so you write the least boilerplate. The trade-off is timing: support for a given tool, or for a new Nx release, arrives when the maintainers ship it. Before adopting one, confirm that it currently supports both Poetry and uv, and the Nx version you run.
| Approach | Boilerplate | Transparency | Upgrade risk |
|---|---|---|---|
Hand-written project.json |
High | Full | Low |
| Custom inference plugin | Low | High (you own it) | Medium |
| Community plugin | Lowest | Depends on docs | Tied to maintainers |
When to avoid this setup, and the alternatives
Running two package managers means two lockfile formats, two resolvers and two cache directories. A shared library can resolve to one version in a Poetry project and a different version in a uv project. Nx won't catch this, because it reads its project graph and file inputs, not the resolved dependency trees. The conflict shows up at runtime or in integration tests.
Cache correctness also needs ongoing attention. Each new config file, environment variable or generated artifact that affects a build has to be added to a target's inputs, or Nx will restore stale results. Editable installs add another source of drift: a path dependency that resolves live from source on a laptop may be built and installed as a wheel in CI, so import behaviour can differ between the two.
For a Python-only team, Nx also brings a Node.js toolchain to install, pin and patch. If you converge on uv, its workspaces already model local package dependencies. At that point Nx projects duplicate that structure, and the two definitions have to be kept in sync.
| Option | Fits when |
|---|---|
| uv workspaces only | All projects can move to uv, and caching between tasks isn't a bottleneck |
| Poetry only | The team is settled on Poetry and has no migration planned |
| Make or just | You need shared commands but not affected-project detection or caching |
| Pants or Bazel | You need fine-grained, hermetic builds and can staff the build-system work |
| Separate repositories | Projects release independently and rarely change together |
| Nx with Poetry and uv | A migration is in progress and CI time across many projects matters |
Impact on development cost and engineering time
The costs are mostly ongoing rather than one-time. Engineers have to learn how Nx targets, inputs and the project graph work. Every project needs target configuration, and that configuration has to stay in step with each tool's commands. CI images must carry both Poetry and uv, with separate lockfile formats and cache keys. The largest hidden cost is keeping the graph accurate. If a dependency between two projects is missing, affected detection skips tests that should have run. That produces green builds that are wrong, which is worse than slow builds.
The savings come from skipping work. Affected-only runs avoid testing untouched projects. Cache hits avoid re-running unchanged targets, both locally and in CI. Shared commands mean nobody needs to remember which tool a project uses. You can also migrate one project at a time instead of freezing feature work for a big-bang switch.
Illustrative estimate (assumed numbers, not a benchmark)
Assumed inputs:
- 30 projects
- 4 minutes of test and lint per project
- 40 pull requests per week
- Each change affects 5 projects on average
Weekly CI minutes saved = PRs × (total projects − affected projects) × minutes per project
40 × (30 − 5) × 4 = 4,000 compute-minutes, or roughly 67 hours per week.
This figure ignores cache hits, which add further savings. It also ignores parallelism, which changes wall-clock time but not compute cost. Substitute your own counts. If most changes touch most projects, the savings shrink quickly.
A mixed setup pays off as a bridge. If nobody plans to converge on one tool, you keep paying for dual images, dual lockfiles and dual target definitions indefinitely. Those costs can outweigh what affected detection saves.
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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.