How nx migrate works and why it runs in two phases
nx migrate is the Nx command for moving a workspace from one Nx version to another. It upgrades the dependency versions and rewrites the code and configuration that depend on them. Each Nx plugin can ship migrations: small, versioned code transforms that update config files, project settings, or source patterns when an API changes. The command finds the migrations that apply between your current version and the target version and runs them in order.
The work is split into two phases. In the first phase, nx migrate latest (or nx migrate with a specific version) resolves the target versions of Nx and its related packages, updates package.json, and writes a migrations.json file listing every migration it plans to run. It does not modify source code. You then install the new dependencies with your package manager. In the second phase, nx migrate --run-migrations reads migrations.json and applies the transforms to your files.
The split exists so that each kind of change can be reviewed on its own. A version bump in package.json and a set of rewrites across hundreds of files are different kinds of change. Because the plan is a file, you can read it, remove an entry, or rerun it before anything touches your code. You can also commit the dependency change and the code changes separately, so a reviewer sees what moved and why.
The backend and platform engineers who maintain the monorepo should own this process. They understand the build graph, the CI pipeline, and the shared configuration that migrations rewrite. Spreading the upgrade across feature teams usually produces partial, inconsistent upgrades.
How does it work?
Each plugin declares its migrations in its own package metadata. A plugin's package.json points to a migrations file through an nx-migrations entry. That file has two kinds of content:
- Package updates: version changes for related packages that should move together. For example, an Nx plugin may also require a matching version of the tool it wraps.
- Code migrations: individual transforms. Each one is tagged with the version that introduced it.
When you run the first phase, Nx resolves the target version of each Nx-related package. It reads the migration metadata those packages publish and selects the migrations whose version is newer than the version you have installed and no newer than the target. The package updates are written into your package.json. The code migrations are written into migrations.json in the order they will run.
In the second phase, Nx loads each listed migration from the newly installed packages and runs it against the workspace through the devkit's virtual file tree. A migration is ordinary code. It can read nx.json and project configuration, parse source files, and write changes. Once all entries finish, Nx flushes the changes to disk. This is why you must install dependencies between the two phases: the migration code ships inside the new package versions, so Nx cannot run it until those versions are installed.
npx nx migrate latest
git diff package.json
cat migrations.json
npm install
git add package.json package-lock.json migrations.json
git commit -m "chore: bump Nx and plan migrations"
npx nx migrate --run-migrations
git status
git add -A
git commit -m "chore: apply Nx migrations"
git rm migrations.json
git commit -m "chore: remove applied migrations.json"
A two-phase Nx upgrade: nx migrate updates package.json and writes migrations.json for review, then nx migrate --run-migrations applies the code transforms, with each phase committed separately.
When should you use nx migrate?
Use nx migrate whenever you change the version of nx or any @nx/* package in a workspace. This applies to minor releases as well as majors. Minor releases can also ship migrations, such as deprecation cleanups or option renames, and a manual bump of package.json skips them without any warning.
It is the right tool in these situations:
- Crossing a major version. Majors are where executors are renamed, options are removed and configuration formats change. These are the changes migrations are written to handle.
- Catching up after falling behind. If the workspace is several releases behind,
nx migratewith an explicit target version lets you step through them in order, as described below. - Keeping plugins aligned with Nx. Official plugins are versioned alongside Nx. Running the migration updates them together and applies their transforms in the same pass.
- Adopting new defaults deliberately. Some migrations move the workspace to a new convention, such as inferred tasks. Because the plan is reviewable, you can choose to accept or defer each one instead of discovering the change later.
It is not a fix for everything. It does nothing for code that Nx plugin authors cannot see, such as custom executors, local plugins, CI scripts and toolchain behavior. Those are covered later in this article. It is also not a reason to upgrade on its own. If you are on a supported version and nothing you need is blocked, a scheduled upgrade is better than an ad hoc one.
Preparing the workspace and reading migrations.json
Start from a known-good baseline
Create a dedicated branch from a clean git tree. Before touching any versions, run install, lint, test and build across the whole workspace on the current Nx version, and fix anything already broken. This matters because migrations touch many projects at once. If a test was failing before you started, you cannot later tell whether the upgrade broke it.
Next, inventory your plugins. List every @nx/* package and every third-party plugin in package.json. Official plugins ship migrations that stay in step with Nx releases. Community plugins may lag behind, skip versions or ship no migrations at all, so these are where your manual work will concentrate.
Reading migrations.json
The first phase updates package.json and writes migrations.json without changing any source files. Each entry has four fields you should read:
package: the plugin that owns the migrationname: its identifierversion: the release that introduced itdescription: a short summary of what it rewrites
Use the description to predict the blast radius. A migration that updates executor options will touch project.json files. One that renames an import will touch application code. Map those files to their owners, through CODEOWNERS if you have it, so the right teams know a review is coming.
Then commit package.json and migrations.json on their own, before running anything. This gives reviewers a clear record of what was planned and gives you a clean point to reset to if a later step goes wrong.
#!/usr/bin/env bash
set -euo pipefail
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is not clean; commit or stash first." >&2
exit 1
fi
git switch -c chore/nx-upgrade
npm ci
npx nx run-many -t lint test build
jq -r '(.dependencies // {}) + (.devDependencies // {}) | to_entries[]
| select(.key | test("^@nx/|^nx$|nx-|/nx"))
| "\(.key)\t\(.value)"' package.json
npx nx migrate latest
jq -r '.migrations[] | "\(.package)\t\(.version)\t\(.name)\n \(.description)"' migrations.json
git add package.json migrations.json
git commit -m "chore: plan Nx upgrade (package.json + migrations.json)"
A shell script that checks for a clean tree, records a passing baseline, inventories Nx plugins, generates migrations.json with nx migrate latest, lists each entry's package, version, name and description, and commits the plan before any migration runs.
Stepping through major versions and applying migrations in reviewable commits
Rather than targeting the newest release directly, move one major version at a time. Pass an explicit version to nx migrate for the next major, apply it, and only then generate the plan for the one after. Each migration is written against the workspace shape produced by the release before it. If you skip several majors, a later migration may look for a config key or file layout that an earlier migration was supposed to create. When that happens, it can fail outright or, worse, silently do nothing.
Within each step, run nx migrate --run-migrations and read the resulting diff before committing anything. The output lists each migration as it runs, so you can map changed files back to the migration that touched them. Commit in logical chunks, ideally one migration or one closely related group per commit. Put any manual fixes in a separate commit. This lets a reviewer check an automated rewrite in isolation and lets you revert a single bad change without unwinding the whole step. Run builds, tests and lint before generating the next plan.
Direct reviewers to the changes most likely to alter behavior:
- Project configuration: target definitions, options and dependencies in
project.jsonorpackage.json. - Workspace configuration:
nx.json, particularly target defaults, named inputs and plugin entries. - Executors: renamed executors or changed option schemas.
- Inferred tasks: plugin-driven targets that replace explicit ones and may change which commands actually run.
Stepping incrementally adds overhead. A team that is only a minor version behind gets little benefit from it, so a single migration pass is usually enough.
#!/usr/bin/env bash
set -euo pipefail
NEXT="${1:?pass a version from the NEXT major only, not the newest release}"
git diff --quiet && git diff --cached --quiet || { echo "commit or stash first"; exit 1; }
npx nx migrate "$NEXT"
npm install
git add package.json package-lock.json
git commit -m "chore(nx): bump Nx packages to $NEXT"
npx nx migrate --run-migrations | tee "nx-migrate-$NEXT.log"
git status --short
git diff --stat
echo "Stage with 'git add -p' and commit per migration, then press Enter."
read -r
npx nx run-many -t build test lint
rm -f migrations.json "nx-migrate-$NEXT.log"
A per-major upgrade script that bumps Nx to one explicit version, runs that version's migrations, pauses for per-migration commits, and runs build, test and lint before you move on to the next major.
Breaking changes that migrations do not cover
Migrations are written by plugin authors, so they only rewrite code those authors can predict: project configuration, nx.json, and imports from their own packages. Anything your team built on top of Nx is invisible to them.
That gap usually includes:
- Custom executors and generators. These often import from
@nx/devkitor deeper paths, and those signatures can change between majors. - Local workspace plugins. They depend on graph and project-inference APIs that are regularly reshaped.
- Scripts that call Nx internals. Anything that imports from undocumented paths or parses CLI output carries no compatibility promise.
- CI assumptions. Pipelines that hard-code target names, output directories or cache locations break silently when inferred targets or new defaults change them.
- Toolchain behavior. Migrations bump TypeScript, bundler and test runner versions, but they do not adjust your code for stricter type checks, changed module resolution or different test environment defaults.
To find these before they fail, read the release notes for each major you cross and list every deprecated or removed API. Then search the whole repository for those symbols, including CI definitions and tooling directories that are not Nx projects, because the project graph will not surface them.
Community plugins often fall behind Nx releases, and an incompatible plugin can block the entire upgrade. You have three options:
| Option | When it fits | Cost |
|---|---|---|
| Pin | Upgrade is expected soon | Holds back the rest of the workspace |
| Fork | Plugin is small and critical | You own its maintenance |
| Remove | Plugin wraps a simple command | Rewrite targets with built-in executors |
#!/usr/bin/env bash
set -euo pipefail
REMOVED_APIS=(
"someRemovedDevkitHelper"
"legacyProjectGraphFn"
"nx/src/" # deep imports into Nx internals carry no compatibility promise
)
echo "== Removed or deprecated APIs =="
for sym in "${REMOVED_APIS[@]}"; do
# git grep searches every tracked file, including tools/ and scripts/
# directories that are not Nx projects and never appear in the graph.
git grep -n -I -F -e "$sym" -- . ':!*.md' || true
done
echo "== CI assumptions =="
git grep -n -I -E -e 'dist/' -e 'nx run ' -e '\.nx/cache' -e 'node_modules/\.cache' \
-- '.github/' '.gitlab-ci.yml' 'ci/' 2>/dev/null || true
echo "== Nx-related dependencies in package.json =="
grep -n -E '"(@nx/|nx-|@nrwl/)[^"]*"' package.json || true
A shell script that searches the whole repository, including CI definitions and non-project tooling directories, for APIs and paths that release notes mark as changed, then lists Nx-related dependencies so you can decide whether to pin, fork or remove each community plugin.
Verifying behavior beyond a passing build
A green nx build on one project shows that one project compiles. It does not show that the workspace behaves as it did before the upgrade. Run every target across all projects with nx run-many -t lint test build e2e, not nx affected, because the upgrade touched shared configuration and every project is effectively changed. Lint and e2e targets break most often. Executor and preset changes reach them first, and they run least often in local development.
Compare outputs, not just exit codes
Build the main branch and the upgrade branch into separate directories and diff the results. Look for:
- Output paths that moved. Deployment scripts, Dockerfiles and CDN uploads often hardcode paths like
dist/apps/api. - Entry file names that changed.
- Assets or generated files that disappeared.
- Unexpected size changes. A large shift in bundle size can point to new defaults for minification or tree-shaking.
A build that succeeds while writing to a different directory fails only in deployment.
Check the cache and the graph
Upgrades often change namedInputs, targetDefaults and plugin-inferred targets. These control both caching and the affected calculation. Run the same target twice with no changes and confirm the second run is a cache hit. Then edit a single source file and confirm only the expected tasks miss. If inputs are too broad, every run misses and CI gets slower. If they are too narrow, Nx can replay a stale cached artifact.
Open nx graph on both branches and compare project dependencies. Then pick a recent commit and compare which projects nx affected selects before and after the upgrade. If CI would run noticeably more or fewer tasks for the same commit, the project graph has changed. Find out why before you merge.
Recovering from failed or partial migrations
When nx migrate --run-migrations stops partway through, the workspace is in a state no migration author tested: some files are rewritten, others are not, and package.json may not match either the old or the new version. Hand-patching from there means guessing which edits already happened. Reset to the pre-migration commit instead (discard tracked changes and remove any generated files), reinstall dependencies, and start again from a state you understand.
Isolate the failing migration
Each migration has its own entry in migrations.json, so you can narrow down the failure directly. Move the failing entry, or anything that runs after it, to a separate file. Run the remaining entries and commit the result. Then fix the blocker. Often that is an unusual config shape or a custom executor the migration does not recognize. Once it is fixed, restore the entry and run it again. Keep the original order wherever you can, because migrations are listed in the sequence their authors expected.
Skipping is a decision, not a shortcut
A later migration may assume an earlier one already rewrote a config key or renamed an option. If you skip that earlier one, the later migration can fail, or worse, report success without doing anything. Record every skipped migration in the commit message or upgrade notes. Include its name, the reason you skipped it, and what manual change replaced it. The next person to upgrade will need that record.
Closing out
After the final migration commit, delete migrations.json. If you leave it in place, someone may re-run it against code that has already been changed. Before merging, bring every @nx/* package, third-party Nx plugin, and local plugin up to versions compatible with the new Nx version. Mismatched plugin versions can load without errors and still produce wrong task configuration.
How nx migrate saves engineering time
The main saving is that the people who made a breaking change also write the fix for it. Without migrations, every team using Nx has to read the release notes, work out which of its files are affected, and make the same edits by hand. With migrations, one transform written by the plugin author runs in every workspace. The time you save comes from three sources.
Mechanical edits are automated. Option renames, executor moves and nx.json restructures are repetitive edits that are easy to get wrong. If a workspace has dozens of project.json files, a migration updates all of them consistently in one run. Doing the same work by hand means opening each file and risking a missed project.
Discovery is done for you. migrations.json lists what changed between your version and the target, with a description of each change. That list is the starting point for the upgrade. You still read the release notes for the gaps described above, but you do not have to rebuild the list of configuration changes yourself.
Review and rollback get cheaper. Because the plan is a file and each migration can be committed separately, reviewers check focused diffs instead of one large change. If one change is bad, you revert one commit instead of redoing the upgrade.
An illustrative estimate shows the scale. All of the inputs below are assumed, not measured:
- Assumed workload: a release renames an executor option used by 40 projects.
- By hand: if each edit takes an assumed 5 minutes, the edits alone take about 200 minutes. That figure leaves out the time needed to find every affected file and to fix any you miss.
- With the migration: if running it and reviewing its diff takes an assumed 20 to 30 minutes, the saving on this one change is roughly two and a half to three hours.
Multiply that by the number of such changes in a major release to get a rough sense of the total. Your own figures will depend on workspace size and how much custom tooling you maintain.
These savings only hold if you follow the practices in this article. Applying several majors in one jump, or merging generated and manual changes into a single commit, turns the time saved on edits into time spent debugging and reviewing.
Alternatives, cost trade-offs and making upgrades routine
Each approach moves cost to a different place: up front, spread over time, or deferred until it is larger.
| Approach | Calendar time | CI runs | Review effort | Risk |
|---|---|---|---|---|
| One-shot jump across several majors | Short if nothing breaks | Few, but failures are hard to bisect | One very large diff | High |
| Incremental two-phase, one major at a time | Longer | At least one per major | Small, per-migration commits | Low |
Manual upgrade without nx migrate |
Variable | Many trial-and-error runs | High, because you hand-write what migrations would generate | Medium to high, since config changes are easy to miss |
| Defer | None now | None now | None now | Grows with every skipped release |
Long-lived upgrade branches are expensive. While one is open, other developers keep editing nx.json, project configuration, and the lockfile. Each rebase can conflict with migrated files, and resolving those conflicts by hand undoes the reproducibility that migrations provide. Keep each major's branch short enough to merge within days.
Large generated diffs cause review fatigue. Reviewers skim thousands of mechanical lines and miss the few hand edits that matter. If generated commits are kept separate from manual fixes, reviewers can spend their attention on the manual fixes.
Custom plugins add upgrade debt. Every internal Nx API a plugin imports can break in a future release, and no migration will fix it for you.
To keep upgrades cheap:
- Schedule small, regular upgrades. A short gap between versions means fewer migrations to apply and fewer interacting changes to debug.
- Assign an owner. Without one, upgrades get postponed until they become urgent.
- Keep plugins thin and on public APIs. This limits how often a release can break them.
- Pin all Nx-related packages to the same exact version. Consistent pins prevent the silent mismatches described above.
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 migrate upgrade, 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.